net-sftp 4.0.1 → 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: 1d42976ec0ca9fa6131be80a2d84e8e4b375a95dc410aa20f987ebf56b5717c3
4
- data.tar.gz: 8b3b507190fed78c32dedd040e4d5d814ca36a7ecc0b72a6de402459f9f8b999
3
+ metadata.gz: 4d3d330529ea03b189dc54e18be90d35bb2bfb79374917e9e22a2fcd3d7d79ca
4
+ data.tar.gz: feaf13c15e37ea24c5ed45f52a842b0a8d4413cc2a27f1ee6de5a03a0975d527
5
5
  SHA512:
6
- metadata.gz: 1ac5c4215ede73ce6f16b1db405647cc545d420c2e4ab87ba64e7db5efd4535a32549217a34b8a3989978dd47af1dd274f28a953c5f28c8d16f3f6948047e458
7
- data.tar.gz: b61e54caed650be40e5b8a3d89f9d6cb52ffcb0c4fbe37ebfd2c2f20e5fca15405e7c06bd579b71d3458c6182fdc2e247caae4c1d5e0a5c6ed0adab73c205e6c
6
+ metadata.gz: 3da2ef173bd6e6ac212cf3ae73243a887ae0d136fc01f03a06b383f00a6ea19a55d2f461f5fadff10d6991dd295c6fb9c826edf6557f1d2a831fe32f2782dfdf
7
+ data.tar.gz: e12c6bdf4efc55e51fbf6dbe3ddb12494b8f3b090e9786bdf6deb002a6966505e9a6a63440fdb43fb23da9c20fce7b0c40d2b2e5425dd095a97dfe1648102dfb
checksums.yaml.gz.sig CHANGED
Binary file
@@ -0,0 +1 @@
1
+ <!-- After opening the PR, add a changelog.d/<PR number>.<type>.txt fragment (none, change, security, breaking or breaking-security) instead of editing CHANGES.txt; see CONTRIBUTING.md#changelog. -->
@@ -0,0 +1,18 @@
1
+ name: Changelog
2
+
3
+ on:
4
+ pull_request:
5
+ types: [opened, synchronize, reopened, labeled, unlabeled]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ changelog:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ with:
16
+ # HEAD is the PR merged into the base branch; HEAD^1 is the base it was merged into.
17
+ fetch-depth: 2
18
+ - uses: net-ssh/changelogd@v1
@@ -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:
@@ -21,3 +22,16 @@ jobs:
21
22
  bundler-cache: true
22
23
  - name: Run Tests
23
24
  run: bundle exec rake test
25
+ frozen-string-literal:
26
+ runs-on: ubuntu-latest
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+ - name: Set up Ruby
30
+ uses: ruby/setup-ruby@v1
31
+ with:
32
+ ruby-version: '4.0'
33
+ bundler-cache: true
34
+ - name: Run Tests with frozen string literals
35
+ run: bundle exec rake test
36
+ env:
37
+ RUBYOPT: --enable-frozen-string-literal
data/CHANGES.txt CHANGED
@@ -1,3 +1,39 @@
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
+
12
+ === 4.0.2
13
+
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]
15
+ * Security: Recursive downloads reject file names from the server that are not a single path component (such as `../x` or `a/b`) instead of writing outside the target directory. Reported in #162 by srosenhamer [#185, #162]
16
+ * Net::SFTP now works with frozen string literals enabled: `File` reads and `Dir#glob` no longer mutate frozen strings, and `glob` no longer modifies entry names in place (adds `Name#name=`). Based on #157 by @frederikspang [#198, #157]
17
+ * `File#read` and `File#gets` no longer loop forever when the server returns an empty data packet, and `read` with a negative length raises `ArgumentError` like `IO#read`. Based on #174 by @OskarEichler [#189, #174]
18
+ * Show fields an SFTP v4+ server omitted as `?` in directory listings (`longname`), like `ls -l` does, instead of blank, `-` or `---------` [#188]
19
+ * Fixed `File#write` hanging on data of 256 KiB or more by sending it in 32 KiB write requests [#99, #187]
20
+ * SFTP request ids now wrap around at the uint32 limit, skipping ids still awaiting a response, instead of growing past it so that responses on very long-lived sessions could no longer be matched to their requests. Thanks @OskarEichler [#179]
21
+ * Uploads keep exactly `:requests` writes in flight (one more was sent before, so the default is now 2 rather than 3), and a zero, negative or non-numeric `:requests` or `:read_size` on `upload` or `download` now raises `ArgumentError` when the transfer is set up instead of stalling. Thanks @OskarEichler [#178]
22
+ * Reconnecting a closed `Net::SFTP::Session` now discards partially received packet data from the old channel, so stale bytes are no longer parsed as the start of the new channel's first packet. Thanks @OskarEichler [#177]
23
+ * Don't crash formatting a directory listing (`longname`) when an SFTP v4+ server omits the modification time, size or permissions, and don't show omitted permissions as `rwxrwxrwx`. Thanks @OskarEichler [#175, #146]
24
+ * `Dir#glob` no longer lists every subdirectory for a pattern without `/` (which can only match top-level names), saving a round trip per directory and no longer failing on unreadable subdirectories. Thanks @OskarEichler [#173, #180]
25
+ * `File#gets` no longer rescans the whole line after each chunk, so reading very long lines is no longer quadratic (a 16 MiB line ending in `"\r\n"` went from about 5 s to 0.01 s). Thanks @OskarEichler [#172]
26
+ * `File#gets` treats a nil or negative limit as no limit, like `IO#gets`, instead of returning nil and corrupting the position (or raising for nil). Thanks @OskarEichler [#171]
27
+ * Fixed SFTP v5/v6 `open` ignoring `IO::TRUNC` and `IO::CREAT` in numeric flags: `IO::WRONLY | IO::TRUNC` now requests `TRUNCATE_EXISTING` instead of opening without truncation, and read-only opens with `IO::CREAT` now request `OPEN_OR_CREATE`. Thanks @OskarEichler [#170]
28
+ * Include the server's error message in `StatusException` and `Response#to_s` for SFTP protocol v3 and later, instead of only the status code. Thanks @OskarEichler [#169, #156]
29
+ * Fixed SFTP v4+ attribute serialization raising `TypeError` when nanoseconds were given for only some timestamps (e.g. `atime_nseconds` with `mtime` but no `mtime_nseconds`); the missing subsecond fields are now sent as 0. Thanks @OskarEichler [#168]
30
+ * `Net::SFTP.start` with a block now closes the SSH session when the block exits with `break`, `return` or `throw`, instead of leaking it. Thanks @OskarEichler [#167]
31
+ * Attributes objects now copy the hash they are given, so a frozen hash no longer raises `FrozenError` and the caller's hash is no longer modified by the default `:type` or by cached owner/group/uid/gid lookups. Thanks @OskarEichler [#166]
32
+ * `File#gets(limit)` no longer makes an extra remote read when the buffered data already satisfies the limit, and `gets(0)` returns an empty string like `IO#gets`. Thanks @OskarEichler [#165]
33
+ * `File#puts` now behaves like `IO#puts`: with no arguments it writes a newline (it used to write nothing), `nil` and `false` are written instead of raising, objects with `to_ary` are expanded, and a recursive array no longer overflows the stack. Thanks @OskarEichler [#164]
34
+ * Fixed `Net::SFTP::Operations::File#write` writing at the read-ahead offset instead of the current position after a buffered read (e.g. `read(2)` on an `r+` file), which corrupted data. Thanks @OskarEichler [#163]
35
+ * Accept a `Pathname` as the local or remote path for uploads and downloads. Thanks @Tietew and @til [#153, #102]
36
+
1
37
  === 4.0.1
2
38
 
3
39
  * Allow net-ssh 8 [#181]
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,33 @@
1
+ # Contributing to net-sftp
2
+
3
+ Run the tests with `bundle exec rake test`.
4
+
5
+ ## Changelog
6
+
7
+ Don't edit `CHANGES.txt`. Instead, add a fragment file to `changelog.d/` in your PR; the release's version bump (`rake vbump:*`) moves all fragments into a new `=== <version>` section at the top of `CHANGES.txt` and deletes them.
8
+
9
+ A fragment is named `<number>.<type>.txt`, where `<number>` is the PR number (so open the PR first, then add the fragment), and contains a single UTF-8 paragraph: the entry as it should read in `CHANGES.txt`, without the bullet or prefix. Lines are joined, so it can be wrapped. If it doesn't end with a reference list such as `[#106, #107]` or `[GHSA-xxxx-xxxx-xxxx]`, ` [#<number>]` is appended.
10
+
11
+ | Type | Written as | Use for |
12
+ | ------------------- | ------------------------------ | ------------------------------------------------------------ |
13
+ | `breaking-security` | `* BREAKING: Security: <text>` | an incompatible change made for security |
14
+ | `breaking` | `* BREAKING: <text>` | an incompatible change |
15
+ | `security` | `* Security: <text>` | a security fix or hardening |
16
+ | `change` | `* <text>` | any other user-visible fix or feature |
17
+ | `none` | nothing | PRs with no user-visible change (tests, CI, docs, refactors) |
18
+
19
+ Examples:
20
+
21
+ ```sh
22
+ rake "changelog:new[184,change,Fixed downloading files with multibyte names]"
23
+ # changelog.d/184.change.txt -> * Fixed downloading files with multibyte names [#184]
24
+
25
+ echo 'Refuse symlinks that point outside the download directory [GHSA-xxxx-xxxx-xxxx]' > changelog.d/185.security.txt
26
+ touch changelog.d/186.none.txt
27
+ ```
28
+
29
+ For several entries from one PR add a suffix: `184.change.txt`, `184.change.2.txt` (`changelog:new` picks the next free one).
30
+
31
+ `rake changelog:check` validates the fragments and `rake changelog:preview` prints the section the next release would add. CI fails a PR that edits `CHANGES.txt` or doesn't add or modify a `changelog.d/<PR number>.<type>.txt` fragment, unless it only touches `changelog.d/` or a maintainer adds the `skip-changelog` label; Dependabot PRs are skipped.
32
+
33
+ The full rules, including how a release orders the entries, are in the [changelogd README](https://github.com/net-ssh/changelogd#readme).
data/Gemfile CHANGED
@@ -9,6 +9,9 @@ gem "rake", ">= 12.0"
9
9
  gem "rdoc"
10
10
  gem "logger" # no longer a default gem in Ruby 4.0+
11
11
 
12
+ # git: rather than github:, which Bundler 1.x (the default on Ruby 2.6) expands to an unsupported git:// URL.
13
+ gem "changelogd", git: "https://github.com/net-ssh/changelogd.git", tag: "v1", group: :development
14
+
12
15
  gem 'byebug', group: %i[development test] if !Gem.win_platform? && RUBY_ENGINE == "ruby"
13
16
 
14
17
  if ENV["CI"]
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
@@ -19,6 +19,16 @@ name = "net-sftp"
19
19
  require_relative "lib/net/sftp/version"
20
20
  version = Net::SFTP::Version::CURRENT
21
21
 
22
+ begin
23
+ require "changelogd/rake"
24
+ rescue LoadError
25
+ # A git gem isn't on the load path under plain `rake`, as the release workflow runs it; bundler/setup could clash with the running rake.
26
+ require "bundler"
27
+ $LOAD_PATH.unshift(*Bundler.definition.specs["changelogd"].flat_map(&:full_require_paths))
28
+ require "changelogd/rake"
29
+ end
30
+ Changelogd::RakeTasks.new(version_file: "lib/net/sftp/version.rb")
31
+
22
32
  namespace :cert do
23
33
  desc "Update public cert from private - only run if public is expired"
24
34
  task :update_public_when_expired do
@@ -53,16 +63,19 @@ RDoc::Task.new do |rdoc|
53
63
  end
54
64
 
55
65
  def change_version(&block)
66
+ Rake::Task["changelog:check"].invoke
56
67
  version_file = 'lib/net/sftp/version.rb'
57
68
  require_relative version_file
58
69
  pre = Net::SFTP::Version::PRE
59
70
  tiny = Net::SFTP::Version::TINY
60
- result = block[pre: pre, tiny: Net::SFTP::Version::TINY]
71
+ minor = Net::SFTP::Version::MINOR
72
+ result = block[pre: pre, tiny: tiny, minor: minor]
61
73
  raise ArgumentError, "Version change logic should always return a pre" unless result.key?(:pre)
62
74
 
63
75
  new_pre = result[:pre]
64
76
  new_tiny = result[:tiny] || tiny
65
- found = { pre: false, tiny: false }
77
+ new_minor = result[:minor] || minor
78
+ found = { pre: false, tiny: false, minor: false }
66
79
  File.open("#{version_file}.new", "w") do |f|
67
80
  File.readlines(version_file).each do |line|
68
81
  match =
@@ -94,20 +107,34 @@ def change_version(&block)
94
107
  end
95
108
  end
96
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
+
97
122
  f.write(line)
98
123
  end
99
124
  raise ArgumentError, "Cound not find line: PRE = \"#{pre}\" in #{version_file}" unless found[:pre]
100
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
101
127
  end
102
128
 
103
129
  FileUtils.mv version_file, "#{version_file}.old"
104
130
  FileUtils.mv "#{version_file}.new", version_file
131
+ Rake::Task["changelog:release"].invoke
105
132
  end
106
133
 
107
134
  namespace :vbump do
108
135
  desc "Final release"
109
136
  task :final do
110
- change_version do |pre:, tiny:|
137
+ change_version do |pre:, tiny:, **|
111
138
  _ = tiny
112
139
  if pre.nil?
113
140
  { tiny: tiny + 1, pre: nil }
@@ -119,9 +146,18 @@ namespace :vbump do
119
146
  end
120
147
  end
121
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
+
122
158
  desc "Increment prerelease"
123
159
  task :pre, [:type] do |_t, args|
124
- change_version do |pre:, tiny:|
160
+ change_version do |pre:, tiny:, **|
125
161
  puts " PRE => #{pre.inspect}"
126
162
  match = /^([a-z]+)(\d+)/.match(pre)
127
163
  raise ArgumentError, "Unexpected pre: #{pre}" if match.nil? && args[:type].nil?
@@ -0,0 +1 @@
1
+ Changelog fragments: one `<PR number>.<type>.txt` file per user-visible change, assembled into `CHANGES.txt` when the version is bumped. See [CONTRIBUTING.md](../CONTRIBUTING.md#changelog).
@@ -58,17 +58,18 @@ module Net; module SFTP; module Operations
58
58
  def glob(path, pattern, flags=0)
59
59
  flags |= ::File::FNM_PATHNAME
60
60
  path = path.chop if path.end_with?('/') && path != '/'
61
+ recursive = !pattern.is_a?(String) || pattern.include?('/')
61
62
 
62
63
  results = [] unless block_given?
63
64
  queue = entries(path).reject { |e| %w(. ..).include?(e.name) }
64
65
  while queue.any?
65
66
  entry = queue.shift
66
67
 
67
- if entry.directory? && !%w(. ..).include?(::File.basename(entry.name))
68
- queue += entries("#{path}/#{entry.name}").map do |e|
69
- e.name.replace("#{entry.name}/#{e.name}")
68
+ if recursive && entry.directory? && !%w(. ..).include?(::File.basename(entry.name))
69
+ queue.concat(entries("#{path}/#{entry.name}").map do |e|
70
+ e.name = "#{entry.name}/#{e.name}"
70
71
  e
71
- end
72
+ end)
72
73
  end
73
74
 
74
75
  if ::File.fnmatch(pattern, entry.name, flags)
@@ -1,3 +1,4 @@
1
+ require 'pathname'
1
2
  require 'net/ssh/loggable'
2
3
 
3
4
  module Net; module SFTP; module Operations
@@ -48,12 +49,26 @@ module Net; module SFTP; module Operations
48
49
  # * <tt>:progress</tt> - either a block or an object to act as a progress
49
50
  # callback. See the discussion of "progress monitoring" below.
50
51
  # * <tt>:requests</tt> - the number of pending SFTP requests to allow at
51
- # any given time. When downloading an entire directory tree recursively,
52
- # this will default to 16. Setting this higher might improve throughput.
53
- # Reducing it will reduce throughput.
52
+ # any given time. When downloading a single file, this is the number of
53
+ # reads kept in flight and defaults to 64. When downloading an entire
54
+ # directory tree recursively, it is the number of files and directories
55
+ # transferred at once and defaults to 16. Setting this higher might
56
+ # improve throughput, particularly on high-latency links. Reducing it
57
+ # will reduce throughput.
54
58
  # * <tt>:read_size</tt> - the maximum number of bytes to read at a time
55
59
  # from the source. Increasing this value might improve throughput. It
56
60
  # defaults to 32,000 bytes.
61
+ # * <tt>:preserve</tt> - if true, the local files and directories get the
62
+ # access and modification times of the remote ones, and their permissions
63
+ # are set exactly, even on files that already exist and regardless of the
64
+ # umask.
65
+ #
66
+ # Like OpenSSH's sftp client, new local files and directories are created
67
+ # with the permissions of the remote ones, without setuid, setgid and sticky
68
+ # bits and subject to the umask. Nothing is set when +local+ is an IO object.
69
+ # If the remote attributes can't be read, the download goes ahead with
70
+ # default permissions; if they can't be applied locally, a warning is logged
71
+ # and the download still succeeds.
57
72
  #
58
73
  # == Progress Monitoring
59
74
  #
@@ -145,13 +160,19 @@ module Net; module SFTP; module Operations
145
160
  # This will return immediately, and requires that the SSH event loop be
146
161
  # run in order to effect the download. (See #wait.)
147
162
  def initialize(sftp, local, remote, options={}, &progress)
163
+ local = local.to_path if local.is_a?(Pathname)
164
+ remote = remote.to_path if remote.is_a?(Pathname)
165
+
148
166
  @sftp = sftp
149
167
  @local = local
150
168
  @remote = remote
151
169
  @progress = progress || options[:progress]
152
170
  @options = options
153
171
  @active = 0
172
+ @directories = []
154
173
  @properties = options[:properties] || {}
174
+ requests
175
+ read_size
155
176
 
156
177
  self.logger = sftp.logger
157
178
 
@@ -204,7 +225,8 @@ module Net; module SFTP; module Operations
204
225
 
205
226
  # A simple struct for encapsulating information about a single remote
206
227
  # file or directory that needs to be downloaded.
207
- Entry = Struct.new(:remote, :local, :directory, :size, :handle, :offset, :sink)
228
+ Entry = Struct.new(:remote, :local, :directory, :size, :handle, :offset, :sink,
229
+ :next_read, :pending, :window, :chunks, :eof, :attributes)
208
230
 
209
231
  #--
210
232
  # "ruby -w" hates private attributes, so we have to do these longhand
@@ -222,13 +244,29 @@ module Net; module SFTP; module Operations
222
244
 
223
245
  # The number of bytes to read at a time from remote files.
224
246
  def read_size
225
- options[:read_size] || DEFAULT_READ_SIZE
247
+ @read_size ||= positive_option(:read_size, DEFAULT_READ_SIZE)
226
248
  end
227
249
 
228
- # The number of simultaneou SFTP requests to use to effect the download.
229
- # Defaults to 16 for recursive downloads.
250
+ # The number of simultaneous SFTP requests to use to effect the download.
251
+ # Defaults to 16 for recursive downloads and 64 for single files.
230
252
  def requests
231
- options[:requests] || (recursive? ? 16 : 2)
253
+ @requests ||= positive_option(:requests, recursive? ? 16 : 64)
254
+ end
255
+
256
+ # The most reads a single file may have outstanding at once.
257
+ def read_window_limit
258
+ recursive? ? 1 : requests
259
+ end
260
+
261
+ def preserve?
262
+ options[:preserve]
263
+ end
264
+
265
+ def positive_option(name, default)
266
+ value = (options[name] || default).to_i
267
+ raise ArgumentError, ":#{name} must be positive" unless value > 0
268
+
269
+ value
232
270
  end
233
271
 
234
272
  # Enqueues as many files and directories from the stack as possible
@@ -240,7 +278,13 @@ module Net; module SFTP; module Operations
240
278
 
241
279
  if entry.directory
242
280
  update_progress(:mkdir, entry.local)
243
- ::Dir.mkdir(entry.local) unless ::File.directory?(entry.local)
281
+ refuse_symlink!(entry.local) if server_named?(entry) && ::File.symlink?(entry.local)
282
+ created = !::File.directory?(entry.local)
283
+ ::Dir.mkdir(entry.local, local_permissions(entry, 0777) | 0300) if created
284
+ if created || preserve?
285
+ @directories << entry
286
+ stat_directory(entry) unless entry.attributes
287
+ end
244
288
  request = sftp.opendir(entry.remote, &method(:on_opendir))
245
289
  request[:entry] = entry
246
290
  else
@@ -248,7 +292,64 @@ module Net; module SFTP; module Operations
248
292
  end
249
293
  end
250
294
 
251
- update_progress(:finish) if !active?
295
+ if !active?
296
+ set_directory_attributes
297
+ update_progress(:finish)
298
+ end
299
+ end
300
+
301
+ # Only entries listed by readdir come with attributes.
302
+ def stat_directory(entry)
303
+ @active += 1
304
+ sftp.stat(entry.remote) do |response|
305
+ @active -= 1
306
+ entry.attributes = remote_attributes(response, "stat #{entry.remote}")
307
+ process_next_entry
308
+ end
309
+ end
310
+
311
+ # Without the remote attributes the download goes ahead with default
312
+ # permissions and no times, as it would for a server that never sent any.
313
+ def remote_attributes(response, operation)
314
+ return response[:attrs] if response.ok?
315
+
316
+ debug { "#{operation} failed, using default attributes: #{response}" }
317
+ nil
318
+ end
319
+
320
+ # Deepest directories first, after their contents; setting attributes on
321
+ # a directory doesn't change its parent's modification time.
322
+ def set_directory_attributes
323
+ @directories.reverse_each do |entry|
324
+ next unless entry.attributes
325
+
326
+ permissions = local_permissions(entry, 0777)
327
+ permissions &= ~::File.umask unless preserve?
328
+ set_local_attributes(entry, permissions)
329
+ end
330
+ @directories.clear
331
+ end
332
+
333
+ # setuid, setgid and sticky bits are never applied, matching OpenSSH.
334
+ def local_permissions(entry, default)
335
+ permissions = entry.attributes && entry.attributes.permissions
336
+ permissions ? permissions & 0777 : default
337
+ end
338
+
339
+ # Like OpenSSH's sftp client, failing to set attributes only warns; the
340
+ # data has already been transferred.
341
+ def set_local_attributes(entry, permissions)
342
+ attempt_local("chmod", entry) { ::File.chmod(permissions, entry.local) } if permissions
343
+
344
+ attributes = entry.attributes
345
+ return unless preserve? && attributes && attributes.atime && attributes.mtime
346
+ attempt_local("utime", entry) { ::File.utime(attributes.atime, attributes.mtime, entry.local) }
347
+ end
348
+
349
+ def attempt_local(operation, entry)
350
+ yield
351
+ rescue SystemCallError => e
352
+ lwarn { "#{operation} #{entry.local} failed: #{e.message}" }
252
353
  end
253
354
 
254
355
  # Called when a remote directory is "opened" for reading, e.g. to
@@ -275,7 +376,10 @@ module Net; module SFTP; module Operations
275
376
  else
276
377
  response[:names].each do |item|
277
378
  next if item.name == "." || item.name == ".."
278
- stack << Entry.new(::File.join(entry.remote, item.name), ::File.join(entry.local, item.name), item.directory?, item.attributes.size)
379
+ validate_entry_name!(entry, item.name)
380
+ child = Entry.new(::File.join(entry.remote, item.name), ::File.join(entry.local, item.name), item.directory?, item.attributes.size)
381
+ child.attributes = item.attributes
382
+ stack << child
279
383
  end
280
384
 
281
385
  # take this opportunity to enqueue more requests
@@ -286,6 +390,38 @@ module Net; module SFTP; module Operations
286
390
  end
287
391
  end
288
392
 
393
+ # The name comes from the server, so anything but a single path component
394
+ # would let a malicious server write outside the target directory.
395
+ def validate_entry_name!(entry, name)
396
+ if name.empty? || name.include?("/") || name.include?("\0") ||
397
+ (::File::ALT_SEPARATOR && name.include?(::File::ALT_SEPARATOR))
398
+ raise Net::SFTP::Exception, "readdir #{entry.remote} returned an invalid file name: #{name.inspect}"
399
+ end
400
+ end
401
+
402
+ # The caller's own +local+ may be a symlink on purpose; anything below it
403
+ # was named by the server (net-sftp#162).
404
+ def server_named?(entry)
405
+ entry.local != local
406
+ end
407
+
408
+ # Like OpenSSH, the file is always created writable by its owner.
409
+ def open_local_file(entry)
410
+ permissions = local_permissions(entry, 0666) | 0200
411
+ return ::File.open(entry.local, "wb", permissions) unless server_named?(entry) && defined?(::File::NOFOLLOW)
412
+
413
+ begin
414
+ ::File.open(entry.local, "wb", permissions, :flags => ::File::NOFOLLOW)
415
+ rescue SystemCallError
416
+ raise unless ::File.symlink?(entry.local)
417
+ refuse_symlink!(entry.local)
418
+ end
419
+ end
420
+
421
+ def refuse_symlink!(path)
422
+ raise Net::SFTP::Exception, "#{path} is a symlink, refusing to follow it"
423
+ end
424
+
289
425
  # Called when a file is to be opened for reading from the remote server.
290
426
  def open_file(entry)
291
427
  update_progress(:open, entry)
@@ -301,45 +437,116 @@ module Net; module SFTP; module Operations
301
437
  process_next_entry
302
438
  end
303
439
 
304
- # Called when a file has been opened. This will call #download_next_chunk
440
+ # Called when a file has been opened. This will call #request_reads
305
441
  # to initiate the data transfer.
306
442
  def on_open(response)
307
443
  entry = response.request[:entry]
308
444
  raise StatusException.new(response, "open #{entry.remote}") unless response.ok?
309
445
 
310
446
  entry.handle = response[:handle]
311
- entry.sink = entry.local.respond_to?(:write) ? entry.local : ::File.open(entry.local, "wb")
447
+ if entry.attributes || entry.local.respond_to?(:write)
448
+ start_download(entry)
449
+ else
450
+ request = sftp.fstat(entry.handle, &method(:on_fstat))
451
+ request[:entry] = entry
452
+ end
453
+ end
454
+
455
+ # Called when the attributes of a file opened by #open_file arrive.
456
+ def on_fstat(response)
457
+ entry = response.request[:entry]
458
+ entry.attributes = remote_attributes(response, "fstat #{entry.remote}")
459
+ start_download(entry)
460
+ end
461
+
462
+ def start_download(entry)
463
+ entry.sink = entry.local.respond_to?(:write) ? entry.local : open_local_file(entry)
312
464
  entry.offset = 0
465
+ entry.next_read = 0
466
+ entry.pending = 0
467
+ entry.window = 1
468
+ entry.chunks = {}
469
+
470
+ request_reads(entry)
471
+ end
313
472
 
314
- download_next_chunk(entry)
473
+ # Keeps up to entry.window reads of #read_size bytes outstanding until
474
+ # the server reports EOF. The window starts at one and grows with each
475
+ # data reply, so small files don't cost a burst of reads past their end.
476
+ def request_reads(entry)
477
+ while !entry.eof && entry.pending < entry.window
478
+ read_range(entry, entry.next_read, read_size)
479
+ entry.next_read += read_size
480
+ end
315
481
  end
316
482
 
317
- # Initiates a read of the next #read_size bytes from the file.
318
- def download_next_chunk(entry)
319
- request = sftp.read(entry.handle, entry.offset, read_size, &method(:on_read))
483
+ def read_range(entry, offset, length)
484
+ entry.pending += 1
485
+ request = sftp.read(entry.handle, offset, length, &method(:on_read))
320
486
  request[:entry] = entry
321
- request[:offset] = entry.offset
487
+ request[:offset] = offset
488
+ request[:length] = length
322
489
  end
323
490
 
324
- # Called when a read from a file finishes. If the read was successful
325
- # and returned data, this will call #download_next_chunk to read the
326
- # next bit from the file. Otherwise the file will be closed.
491
+ # Called when a read from a file finishes. Replies can arrive out of
492
+ # order, so data is buffered until everything before it has been
493
+ # written. Once the server has reported EOF and no reads are left
494
+ # outstanding, the file is closed.
327
495
  def on_read(response)
328
496
  entry = response.request[:entry]
497
+ entry.pending -= 1
329
498
 
330
499
  if response.eof?
331
- update_progress(:close, entry)
332
- entry.sink.close
333
- request = sftp.close(entry.handle, &method(:on_close))
334
- request[:entry] = entry
500
+ entry.eof = true
335
501
  elsif !response.ok?
336
502
  raise StatusException.new(response, "read #{entry.remote}")
337
503
  else
338
- entry.offset += response[:data].bytesize
339
- update_progress(:get, entry, response.request[:offset], response[:data])
340
- entry.sink.write(response[:data])
341
- download_next_chunk(entry)
504
+ offset = response.request[:offset]
505
+ length = response.request[:length]
506
+ data = response[:data]
507
+
508
+ if data.empty? || data.bytesize > length
509
+ raise Net::SFTP::Exception, "read #{entry.remote} returned #{data.bytesize} bytes for a #{length} byte request"
510
+ end
511
+
512
+ # Servers may return less than asked for without being at EOF, and
513
+ # the reads after this one have already been sent.
514
+ read_range(entry, offset + data.bytesize, length - data.bytesize) if data.bytesize < length
515
+
516
+ entry.chunks[offset] = data
517
+ write_buffered_chunks(entry)
518
+ entry.window += 1 if entry.window < read_window_limit
342
519
  end
520
+
521
+ request_reads(entry)
522
+ close_file(entry) if entry.pending.zero?
523
+ end
524
+
525
+ def write_buffered_chunks(entry)
526
+ while (data = entry.chunks.delete(entry.offset))
527
+ update_progress(:get, entry, entry.offset, data)
528
+ entry.sink.write(data)
529
+ entry.offset += data.bytesize
530
+ end
531
+ end
532
+
533
+ def close_file(entry)
534
+ if entry.chunks.any?
535
+ raise Net::SFTP::Exception, "read #{entry.remote} hit EOF at #{entry.offset} but the server returned data beyond it"
536
+ end
537
+
538
+ update_progress(:close, entry)
539
+ # The caller's IO stays open, but is flushed so its data is written when download! returns.
540
+ if entry.sink.equal?(local)
541
+ entry.sink.flush if entry.sink.respond_to?(:flush)
542
+ else
543
+ entry.sink.close
544
+ end
545
+ unless entry.local.respond_to?(:write)
546
+ set_local_attributes(entry, preserve? ? local_permissions(entry, nil) : nil)
547
+ end
548
+ request = sftp.close(entry.handle, &method(:on_close))
549
+ request[:entry] = entry
343
550
  end
344
551
 
345
552
  # Called when a file handle is closed.