net-sftp 4.0.1 → 4.0.2

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: 6e7dc5adb1f352eecedf9c4d5f6150b32e0e26ccdd76cb6cb242c66fbe636fd4
4
+ data.tar.gz: e9b3ade5165aa0c1dbb61e64acd303794b16a31aa385472c3c513d983322fb3a
5
5
  SHA512:
6
- metadata.gz: 1ac5c4215ede73ce6f16b1db405647cc545d420c2e4ab87ba64e7db5efd4535a32549217a34b8a3989978dd47af1dd274f28a953c5f28c8d16f3f6948047e458
7
- data.tar.gz: b61e54caed650be40e5b8a3d89f9d6cb52ffcb0c4fbe37ebfd2c2f20e5fca15405e7c06bd579b71d3458c6182fdc2e247caae4c1d5e0a5c6ed0adab73c205e6c
6
+ metadata.gz: 8534ab3b4109bd1e5333c1e4814e5e01a855c2f84c40f60d58bfb2d6303bdc1fab20bf8102b31ef2a1b8c408e6565a7a0a26ff1dbf1b1c48445dd2f9aa49bee8
7
+ data.tar.gz: b36ad98cdca42bdf48a29957105f4a104c2965280f983f937bfd64e8958b86a2354dc612e52404bba88374944bdda86016f4e06449e8fb541e26f74a0e0eeacc
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
@@ -21,3 +21,16 @@ jobs:
21
21
  bundler-cache: true
22
22
  - name: Run Tests
23
23
  run: bundle exec rake test
24
+ frozen-string-literal:
25
+ runs-on: ubuntu-latest
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+ - name: Set up Ruby
29
+ uses: ruby/setup-ruby@v1
30
+ with:
31
+ ruby-version: '4.0'
32
+ bundler-cache: true
33
+ - name: Run Tests with frozen string literals
34
+ run: bundle exec rake test
35
+ env:
36
+ RUBYOPT: --enable-frozen-string-literal
data/CHANGES.txt CHANGED
@@ -1,3 +1,28 @@
1
+ === 4.0.2
2
+
3
+ * 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]
4
+ * 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]
5
+ * 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]
6
+ * `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]
7
+ * Show fields an SFTP v4+ server omitted as `?` in directory listings (`longname`), like `ls -l` does, instead of blank, `-` or `---------` [#188]
8
+ * Fixed `File#write` hanging on data of 256 KiB or more by sending it in 32 KiB write requests [#99, #187]
9
+ * 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]
10
+ * 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]
11
+ * 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]
12
+ * 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]
13
+ * `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]
14
+ * `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]
15
+ * `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]
16
+ * 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]
17
+ * 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]
18
+ * 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]
19
+ * `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]
20
+ * 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]
21
+ * `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]
22
+ * `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]
23
+ * 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]
24
+ * Accept a `Pathname` as the local or remote path for uploads and downloads. Thanks @Tietew and @til [#153, #102]
25
+
1
26
  === 4.0.1
2
27
 
3
28
  * 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/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,6 +63,7 @@ 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
@@ -102,6 +113,7 @@ def change_version(&block)
102
113
 
103
114
  FileUtils.mv version_file, "#{version_file}.old"
104
115
  FileUtils.mv "#{version_file}.new", version_file
116
+ Rake::Task["changelog:release"].invoke
105
117
  end
106
118
 
107
119
  namespace :vbump do
@@ -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
@@ -145,6 +146,9 @@ module Net; module SFTP; module Operations
145
146
  # This will return immediately, and requires that the SSH event loop be
146
147
  # run in order to effect the download. (See #wait.)
147
148
  def initialize(sftp, local, remote, options={}, &progress)
149
+ local = local.to_path if local.is_a?(Pathname)
150
+ remote = remote.to_path if remote.is_a?(Pathname)
151
+
148
152
  @sftp = sftp
149
153
  @local = local
150
154
  @remote = remote
@@ -152,6 +156,8 @@ module Net; module SFTP; module Operations
152
156
  @options = options
153
157
  @active = 0
154
158
  @properties = options[:properties] || {}
159
+ requests
160
+ read_size
155
161
 
156
162
  self.logger = sftp.logger
157
163
 
@@ -222,13 +228,20 @@ module Net; module SFTP; module Operations
222
228
 
223
229
  # The number of bytes to read at a time from remote files.
224
230
  def read_size
225
- options[:read_size] || DEFAULT_READ_SIZE
231
+ @read_size ||= positive_option(:read_size, DEFAULT_READ_SIZE)
226
232
  end
227
233
 
228
234
  # The number of simultaneou SFTP requests to use to effect the download.
229
235
  # Defaults to 16 for recursive downloads.
230
236
  def requests
231
- options[:requests] || (recursive? ? 16 : 2)
237
+ @requests ||= positive_option(:requests, recursive? ? 16 : 2)
238
+ end
239
+
240
+ def positive_option(name, default)
241
+ value = (options[name] || default).to_i
242
+ raise ArgumentError, ":#{name} must be positive" unless value > 0
243
+
244
+ value
232
245
  end
233
246
 
234
247
  # Enqueues as many files and directories from the stack as possible
@@ -240,6 +253,7 @@ module Net; module SFTP; module Operations
240
253
 
241
254
  if entry.directory
242
255
  update_progress(:mkdir, entry.local)
256
+ refuse_symlink!(entry.local) if server_named?(entry) && ::File.symlink?(entry.local)
243
257
  ::Dir.mkdir(entry.local) unless ::File.directory?(entry.local)
244
258
  request = sftp.opendir(entry.remote, &method(:on_opendir))
245
259
  request[:entry] = entry
@@ -275,6 +289,7 @@ module Net; module SFTP; module Operations
275
289
  else
276
290
  response[:names].each do |item|
277
291
  next if item.name == "." || item.name == ".."
292
+ validate_entry_name!(entry, item.name)
278
293
  stack << Entry.new(::File.join(entry.remote, item.name), ::File.join(entry.local, item.name), item.directory?, item.attributes.size)
279
294
  end
280
295
 
@@ -286,6 +301,36 @@ module Net; module SFTP; module Operations
286
301
  end
287
302
  end
288
303
 
304
+ # The name comes from the server, so anything but a single path component
305
+ # would let a malicious server write outside the target directory.
306
+ def validate_entry_name!(entry, name)
307
+ if name.empty? || name.include?("/") || name.include?("\0") ||
308
+ (::File::ALT_SEPARATOR && name.include?(::File::ALT_SEPARATOR))
309
+ raise Net::SFTP::Exception, "readdir #{entry.remote} returned an invalid file name: #{name.inspect}"
310
+ end
311
+ end
312
+
313
+ # The caller's own +local+ may be a symlink on purpose; anything below it
314
+ # was named by the server (net-sftp#162).
315
+ def server_named?(entry)
316
+ entry.local != local
317
+ end
318
+
319
+ def open_local_file(entry)
320
+ return ::File.open(entry.local, "wb") unless server_named?(entry) && defined?(::File::NOFOLLOW)
321
+
322
+ begin
323
+ ::File.open(entry.local, "wb", :flags => ::File::NOFOLLOW)
324
+ rescue SystemCallError
325
+ raise unless ::File.symlink?(entry.local)
326
+ refuse_symlink!(entry.local)
327
+ end
328
+ end
329
+
330
+ def refuse_symlink!(path)
331
+ raise Net::SFTP::Exception, "#{path} is a symlink, refusing to follow it"
332
+ end
333
+
289
334
  # Called when a file is to be opened for reading from the remote server.
290
335
  def open_file(entry)
291
336
  update_progress(:open, entry)
@@ -308,7 +353,7 @@ module Net; module SFTP; module Operations
308
353
  raise StatusException.new(response, "open #{entry.remote}") unless response.ok?
309
354
 
310
355
  entry.handle = response[:handle]
311
- entry.sink = entry.local.respond_to?(:write) ? entry.local : ::File.open(entry.local, "wb")
356
+ entry.sink = entry.local.respond_to?(:write) ? entry.local : open_local_file(entry)
312
357
  entry.offset = 0
313
358
 
314
359
  download_next_chunk(entry)
@@ -12,6 +12,11 @@ module Net; module SFTP; module Operations
12
12
  # puts file.gets
13
13
  # file.close
14
14
  class File
15
+ # The largest amount of data sent in a single write request. Servers drop
16
+ # the connection on oversized packets (OpenSSH's limit is 256 KiB including
17
+ # headers), and the SFTP drafts only guarantee packets of about 34000 bytes.
18
+ MAX_WRITE_SIZE = 32 * 1024
19
+
15
20
  # A reference to the Net::SFTP::Session instance that drives this wrapper
16
21
  attr_reader :sftp
17
22
 
@@ -30,14 +35,14 @@ module Net; module SFTP; module Operations
30
35
  @pos = 0
31
36
  @real_pos = 0
32
37
  @real_eof = false
33
- @buffer = ""
38
+ @buffer = +""
34
39
  end
35
40
 
36
41
  # Repositions the file pointer to the given offset (relative to the
37
42
  # start of the file). This will also reset the EOF flag.
38
43
  def pos=(offset)
39
44
  @real_pos = @pos = offset
40
- @buffer = ""
45
+ @buffer = +""
41
46
  @real_eof = false
42
47
  end
43
48
 
@@ -63,15 +68,17 @@ module Net; module SFTP; module Operations
63
68
  #
64
69
  # This will advance the file pointer (#pos).
65
70
  def read(n=nil)
71
+ raise ArgumentError, "negative length #{n} given" if n && n < 0
72
+
66
73
  loop do
67
74
  break if n && @buffer.length >= n
68
75
  break unless fill
69
76
  end
70
77
 
71
78
  if n
72
- result, @buffer = @buffer[0,n], (@buffer[n..-1] || "")
79
+ result, @buffer = @buffer[0,n], (@buffer[n..-1] || +"")
73
80
  else
74
- result, @buffer = @buffer, ""
81
+ result, @buffer = @buffer, +""
75
82
  end
76
83
 
77
84
  @pos += result.length
@@ -92,6 +99,7 @@ module Net; module SFTP; module Operations
92
99
  sep_string = sep_or_limit
93
100
  lim = limit
94
101
  end
102
+ lim = Float::INFINITY if lim.nil? || lim < 0
95
103
 
96
104
  delim = if sep_string && sep_string.length == 0
97
105
  "#{$/}#{$/}"
@@ -99,22 +107,26 @@ module Net; module SFTP; module Operations
99
107
  sep_string
100
108
  end
101
109
 
110
+ search_from = 0
102
111
  loop do
103
- at = @buffer.index(delim) if delim
112
+ at = @buffer.index(delim, search_from) if delim
104
113
  if at
105
114
  offset = [at + delim.length, lim].min
106
115
  @pos += offset
107
116
  line, @buffer = @buffer[0,offset], @buffer[offset..-1]
108
117
  return line
109
- elsif lim < @buffer.length
118
+ elsif lim <= @buffer.length
110
119
  @pos += lim
111
120
  line, @buffer = @buffer[0,lim], @buffer[lim..-1]
112
121
  return line
113
- elsif !fill
114
- return nil if @buffer.empty?
115
- @pos += @buffer.length
116
- line, @buffer = @buffer, ""
117
- return line
122
+ else
123
+ search_from = [@buffer.length - delim.length + 1, 0].max if delim
124
+ unless fill
125
+ return nil if @buffer.empty?
126
+ @pos += @buffer.length
127
+ line, @buffer = @buffer, +""
128
+ return line
129
+ end
118
130
  end
119
131
  end
120
132
  end
@@ -128,13 +140,21 @@ module Net; module SFTP; module Operations
128
140
  end
129
141
 
130
142
  # Writes the given data to the stream, incrementing the file position and
131
- # returning the number of bytes written.
143
+ # returning the number of bytes written. Data larger than MAX_WRITE_SIZE
144
+ # is sent as several sequential write requests; if one fails, the
145
+ # position reflects the bytes written before it.
132
146
  def write(data)
133
147
  data = data.to_s
134
- sftp.write!(handle, @real_pos, data)
135
- @real_pos += data.bytes.length
136
- @pos = @real_pos
137
- data.bytes.length
148
+ size = data.bytesize
149
+ written = 0
150
+ loop do
151
+ chunk = data.byteslice(written, MAX_WRITE_SIZE)
152
+ sftp.write!(handle, @pos, chunk)
153
+ written += chunk.bytesize
154
+ self.pos = @pos + chunk.bytesize unless chunk.empty?
155
+ break if written >= size
156
+ end
157
+ size
138
158
  end
139
159
 
140
160
  # Writes each argument to the stream. If +$\+ is set, it will be written
@@ -155,16 +175,12 @@ module Net; module SFTP; module Operations
155
175
  end
156
176
 
157
177
  # Writes each argument to the stream, appending a newline to any item
158
- # that does not already end in a newline. Array arguments are flattened.
178
+ # that does not already end in a newline, like IO#puts. Array arguments
179
+ # are flattened, and with no arguments a single newline is written.
159
180
  def puts(*items)
160
- items.each do |item|
161
- if Array === item
162
- puts(*item)
163
- else
164
- write(item)
165
- write("\n") unless item[-1] == ?\n
166
- end
167
- end
181
+ items = [""] if items.empty?
182
+ recursion = {}.compare_by_identity
183
+ items.each { |item| write_puts_item(item, recursion) }
168
184
  nil
169
185
  end
170
186
 
@@ -178,12 +194,30 @@ module Net; module SFTP; module Operations
178
194
 
179
195
  private
180
196
 
197
+ def write_puts_item(item, recursion)
198
+ array = Array.try_convert(item)
199
+ if array
200
+ if recursion.key?(item)
201
+ item = "[...]"
202
+ else
203
+ recursion[item] = true
204
+ array.each { |child| write_puts_item(child, recursion) }
205
+ recursion.delete(item)
206
+ return
207
+ end
208
+ end
209
+
210
+ item = item.to_s
211
+ write(item) unless item.empty?
212
+ write("\n") unless item[-1] == ?\n
213
+ end
214
+
181
215
  # Fills the buffer. Returns +true+ if it succeeded, and +false+ if
182
216
  # EOF was encountered before any data was read.
183
217
  def fill
184
218
  data = sftp.read!(handle, @real_pos, 8192)
185
219
 
186
- if data.nil?
220
+ if data.nil? || data.empty?
187
221
  @real_eof = true
188
222
  return false
189
223
  else
@@ -1,3 +1,4 @@
1
+ require 'pathname'
1
2
  require 'net/ssh/loggable'
2
3
 
3
4
  module Net; module SFTP; module Operations
@@ -50,7 +51,9 @@ module Net; module SFTP; module Operations
50
51
  # higher might improve throughput. Reducing it will reduce throughput.
51
52
  # * <tt>:read_size</tt> - the maximum number of bytes to read at a time
52
53
  # from the source. Increasing this value might improve throughput. It
53
- # defaults to 32,000 bytes.
54
+ # defaults to 32,000 bytes. Each chunk is sent as one write request, so
55
+ # it must stay within the server's packet limit (OpenSSH drops the
56
+ # connection on packets over 256 KiB).
54
57
  # * <tt>:name</tt> - the filename to report to the progress monitor when
55
58
  # an IO object is given as +local+. This defaults to "<memory>".
56
59
  #
@@ -141,6 +144,9 @@ module Net; module SFTP; module Operations
141
144
  # This will return immediately, and requires that the SSH event loop be
142
145
  # run in order to effect the upload. (See #wait.)
143
146
  def initialize(sftp, local, remote, options={}, &progress) #:nodoc:
147
+ local = local.to_path if local.is_a?(Pathname)
148
+ remote = remote.to_path if remote.is_a?(Pathname)
149
+
144
150
  @sftp = sftp
145
151
  @local = local
146
152
  @remote = remote
@@ -153,6 +159,8 @@ module Net; module SFTP; module Operations
153
159
 
154
160
  @uploads = []
155
161
  @recursive = local.respond_to?(:read) ? false : ::File.directory?(local)
162
+ requests
163
+ read_size
156
164
 
157
165
  if recursive?
158
166
  @stack = [entries_for(local)]
@@ -164,7 +172,7 @@ module Net; module SFTP; module Operations
164
172
  sftp.mkdir(remote) do |response|
165
173
  @active -= 1
166
174
  raise StatusException.new(response, "mkdir `#{remote}'") unless response.ok?
167
- (options[:requests] || RECURSIVE_READERS).to_i.times do
175
+ requests.times do
168
176
  break unless process_next_entry
169
177
  end
170
178
  end
@@ -225,6 +233,21 @@ module Net; module SFTP; module Operations
225
233
  # The progress handler for this instance. Possibly nil.
226
234
  def progress; @progress; end
227
235
 
236
+ def requests
237
+ @requests ||= positive_option(:requests, recursive? ? RECURSIVE_READERS : SINGLE_FILE_READERS)
238
+ end
239
+
240
+ def read_size
241
+ @read_size ||= positive_option(:read_size, DEFAULT_READ_SIZE)
242
+ end
243
+
244
+ def positive_option(name, default)
245
+ value = (options[name] || default).to_i
246
+ raise ArgumentError, ":#{name} must be positive" unless value > 0
247
+
248
+ value
249
+ end
250
+
228
251
  # A simple struct for recording metadata about the file currently being
229
252
  # uploaded.
230
253
  LiveFile = Struct.new(:local, :remote, :io, :size, :handle)
@@ -326,7 +349,7 @@ module Net; module SFTP; module Operations
326
349
  write_next_chunk(file)
327
350
 
328
351
  if !recursive?
329
- (options[:requests] || SINGLE_FILE_READERS).to_i.times { write_next_chunk(file) }
352
+ (requests - 1).times { write_next_chunk(file) }
330
353
  end
331
354
  end
332
355
 
@@ -358,7 +381,7 @@ module Net; module SFTP; module Operations
358
381
  else
359
382
  @active += 1
360
383
  offset = file.io.pos
361
- data = file.io.read(options[:read_size] || DEFAULT_READ_SIZE)
384
+ data = file.io.read(read_size)
362
385
  if data.nil?
363
386
  update_progress(:close, file)
364
387
  request = sftp.close(file.handle, &method(:on_close))
@@ -150,7 +150,7 @@ module Net; module SFTP; module Protocol; module V01
150
150
  # * :mtime:: the modification time of the file (integer, seconds since epoch)
151
151
  # * :extended:: a hash of name/value pairs identifying extended info
152
152
  def initialize(attributes={})
153
- @attributes = attributes
153
+ @attributes = attributes.dup
154
154
  end
155
155
 
156
156
  # Returns the user-id of the user that owns the file, or +nil+ if that
@@ -5,7 +5,7 @@ module Net; module SFTP; module Protocol; module V01
5
5
  # for use when displaying directory data, and has no specified format.
6
6
  class Name
7
7
  # The name of the item on the remote server.
8
- attr_reader :name
8
+ attr_accessor :name
9
9
 
10
10
  # The display-ready name of the item, possibly with other attributes.
11
11
  attr_reader :longname
@@ -18,6 +18,16 @@ module Net; module SFTP; module Protocol; module V03
18
18
  3
19
19
  end
20
20
 
21
+ # Version 3 adds a message and language tag to status responses.
22
+ def parse_status_packet(packet)
23
+ data = super
24
+ unless packet.eof?
25
+ data[:message] = packet.read_string
26
+ data[:language] = packet.read_string
27
+ end
28
+ data
29
+ end
30
+
21
31
  # Sends a FXP_READLINK packet to the server to request that the target of
22
32
  # the given symlink on the remote host (+path+) be returned.
23
33
  def readlink(path)
@@ -32,4 +42,4 @@ module Net; module SFTP; module Protocol; module V03
32
42
 
33
43
  end
34
44
 
35
- end; end; end; end
45
+ end; end; end; end
@@ -123,7 +123,7 @@ module Net; module SFTP; module Protocol; module V04
123
123
  # to T_REGULAR.
124
124
  def initialize(attributes={})
125
125
  super
126
- attributes[:type] ||= T_REGULAR
126
+ self.type ||= T_REGULAR
127
127
  end
128
128
 
129
129
  private
@@ -134,6 +134,15 @@ module Net; module SFTP; module Protocol; module V04
134
134
  # were given on instantiation
135
135
  owner
136
136
  group
137
+
138
+ # The subsecond flag applies to every timestamp present in the packet.
139
+ subseconds = self.class.elements.select { |_name, _type, flags| flags & F_SUBSECOND_TIMES != 0 }
140
+ if subseconds.any? { |name, _type, _flags| attributes[name] }
141
+ subseconds.each do |name, _type, _flags|
142
+ seconds = name.to_s.sub(/_nseconds\z/, "").to_sym
143
+ attributes[name] ||= 0 if attributes[seconds]
144
+ end
145
+ end
137
146
  end
138
147
 
139
148
  # Performs protocol-version-specific encoding of the access control
@@ -9,7 +9,7 @@ module Net; module SFTP; module Protocol; module V04
9
9
  # a directory listing.
10
10
  class Name
11
11
  # The name of the item on the remote server.
12
- attr_reader :name
12
+ attr_accessor :name
13
13
 
14
14
  # Attributes instance describing this item.
15
15
  attr_reader :attributes
@@ -44,24 +44,33 @@ module Net; module SFTP; module Protocol; module V04
44
44
  "l"
45
45
  else
46
46
  "-"
47
- end
47
+ end.dup
48
48
 
49
- longname << (attributes.permissions & 0400 != 0 ? "r" : "-")
50
- longname << (attributes.permissions & 0200 != 0 ? "w" : "-")
51
- longname << (attributes.permissions & 0100 != 0 ? "x" : "-")
52
- longname << (attributes.permissions & 0040 != 0 ? "r" : "-")
53
- longname << (attributes.permissions & 0020 != 0 ? "w" : "-")
54
- longname << (attributes.permissions & 0010 != 0 ? "x" : "-")
55
- longname << (attributes.permissions & 0004 != 0 ? "r" : "-")
56
- longname << (attributes.permissions & 0002 != 0 ? "w" : "-")
57
- longname << (attributes.permissions & 0001 != 0 ? "x" : "-")
49
+ # Fields the server omitted are shown as "?", like GNU ls -l does for entries it cannot stat.
50
+ if (permissions = attributes.permissions)
51
+ longname << (permissions & 0400 != 0 ? "r" : "-")
52
+ longname << (permissions & 0200 != 0 ? "w" : "-")
53
+ longname << (permissions & 0100 != 0 ? "x" : "-")
54
+ longname << (permissions & 0040 != 0 ? "r" : "-")
55
+ longname << (permissions & 0020 != 0 ? "w" : "-")
56
+ longname << (permissions & 0010 != 0 ? "x" : "-")
57
+ longname << (permissions & 0004 != 0 ? "r" : "-")
58
+ longname << (permissions & 0002 != 0 ? "w" : "-")
59
+ longname << (permissions & 0001 != 0 ? "x" : "-")
60
+ else
61
+ longname << "?" * 9
62
+ end
58
63
 
59
- longname << (" %-8s %-8s %8d " % [attributes.owner, attributes.group, attributes.size])
64
+ longname << (" %-8s %-8s %8s " % [attributes.owner || "?", attributes.group || "?", attributes.size || "?"])
60
65
 
61
- longname << Time.at(attributes.mtime).strftime("%b %e %H:%M ")
66
+ longname << if attributes.mtime
67
+ Time.at(attributes.mtime).strftime("%b %e %H:%M ")
68
+ else
69
+ "%12s " % "?"
70
+ end
62
71
  longname << name
63
72
  end
64
73
  end
65
74
  end
66
75
 
67
- end; end; end; end
76
+ end; end; end; end
@@ -35,25 +35,28 @@ module Net; module SFTP; module Protocol; module V05
35
35
  def open(path, flags, options)
36
36
  flags = normalize_open_flags(flags)
37
37
 
38
- sftp_flags, desired_access = if flags & (IO::WRONLY | IO::RDWR) != 0
39
- open = if flags & (IO::CREAT | IO::EXCL) == (IO::CREAT | IO::EXCL)
38
+ sftp_flags = if flags & (IO::CREAT | IO::EXCL) == (IO::CREAT | IO::EXCL)
40
39
  FV5::CREATE_NEW
41
40
  elsif flags & (IO::CREAT | IO::TRUNC) == (IO::CREAT | IO::TRUNC)
42
41
  FV5::CREATE_TRUNCATE
43
42
  elsif flags & IO::CREAT == IO::CREAT
44
43
  FV5::OPEN_OR_CREATE
44
+ elsif flags & IO::TRUNC == IO::TRUNC
45
+ FV5::TRUNCATE_EXISTING
45
46
  else
46
47
  FV5::OPEN_EXISTING
47
48
  end
49
+
50
+ desired_access = if flags & (IO::WRONLY | IO::RDWR) != 0
48
51
  access = ACE::Mask::WRITE_DATA | ACE::Mask::WRITE_ATTRIBUTES
49
52
  access |= ACE::Mask::READ_DATA | ACE::Mask::READ_ATTRIBUTES if (flags & IO::RDWR) == IO::RDWR
50
53
  if flags & IO::APPEND == IO::APPEND
51
- open |= FV5::APPEND_DATA
54
+ sftp_flags |= FV5::APPEND_DATA
52
55
  access |= ACE::Mask::APPEND_DATA
53
56
  end
54
- [open, access]
57
+ access
55
58
  else
56
- [FV5::OPEN_EXISTING, ACE::Mask::READ_DATA | ACE::Mask::READ_ATTRIBUTES]
59
+ ACE::Mask::READ_DATA | ACE::Mask::READ_ATTRIBUTES
57
60
  end
58
61
 
59
62
  attributes = attribute_factory.new(options)
@@ -63,4 +66,4 @@ module Net; module SFTP; module Protocol; module V05
63
66
 
64
67
  end
65
68
 
66
- end; end; end; end
69
+ end; end; end; end
@@ -19,6 +19,7 @@ module Net; module SFTP; module Protocol
19
19
  @session = session
20
20
  self.logger = session.logger
21
21
  @request_id_counter = -1
22
+ @request_ids_wrapped = false
22
23
  end
23
24
 
24
25
  # Attept to parse the given packet. If the packet is of an unsupported
@@ -41,10 +42,15 @@ module Net; module SFTP; module Protocol
41
42
  # A new request identifier will be allocated to this request, and will
42
43
  # be returned.
43
44
  def send_request(type, *args)
44
- @request_id_counter += 1
45
+ begin
46
+ previous_request_id = @request_id_counter
47
+ @request_id_counter = (@request_id_counter + 1) & 0xffffffff
48
+ @request_ids_wrapped = true if previous_request_id == 0xffffffff
49
+ end while @request_ids_wrapped && session.pending_requests.key?(@request_id_counter)
50
+
45
51
  session.send_packet(type, :long, @request_id_counter, *args)
46
52
  return @request_id_counter
47
53
  end
48
54
  end
49
55
 
50
- end; end; end
56
+ end; end; end
@@ -269,6 +269,10 @@ module Net; module SFTP
269
269
  # Request object. If a block is given, it will be invoked when the
270
270
  # server responds.
271
271
  #
272
+ # +data+ is sent as a single request, so it must stay within the
273
+ # server's packet limit (OpenSSH drops the connection on packets over
274
+ # 256 KiB). Net::SFTP::Operations::File#write splits large data for you.
275
+ #
272
276
  # request = sftp.write(handle, 0, "hello, world!\n")
273
277
  # request.wait
274
278
  def write(handle, offset, data, &callback)
@@ -772,6 +776,7 @@ module Net; module SFTP
772
776
  when :closed
773
777
  @state = :opening
774
778
  @channel = session.open_channel(&method(:when_channel_confirmed))
779
+ @input = Net::SSH::Buffer.new
775
780
  @packet_length = nil
776
781
  @protocol = nil
777
782
  @on_ready = Array(block)
@@ -52,7 +52,7 @@ module Net
52
52
  MINOR = 0
53
53
 
54
54
  # The tiny component of this version of the Net::SFTP library
55
- TINY = 1
55
+ TINY = 2
56
56
 
57
57
  # The prerelease component of this version of the Net::SFTP library
58
58
  # nil allowed
data/lib/net/sftp.rb CHANGED
@@ -34,18 +34,24 @@ module Net
34
34
  # set the SFTP protocol version to be used)
35
35
  def self.start(host, user, ssh_options={}, sftp_options={}, &block)
36
36
  session = Net::SSH.start(host, user, ssh_options)
37
- # We only use a single option here, but this leaves room for more later
38
- # without breaking the external API.
39
- version = sftp_options.fetch(:version, nil)
40
- sftp = Net::SFTP::Session.new(session, version, &block).connect!
37
+ begin
38
+ # We only use a single option here, but this leaves room for more later
39
+ # without breaking the external API.
40
+ version = sftp_options.fetch(:version, nil)
41
+ sftp = Net::SFTP::Session.new(session, version, &block).connect!
41
42
 
42
- if block_given?
43
- sftp.loop
44
- session.close
45
- return nil
46
- end
43
+ if block_given?
44
+ sftp.loop
45
+ return nil
46
+ end
47
47
 
48
- sftp
48
+ sftp
49
+ rescue Object
50
+ failed = true
51
+ raise
52
+ ensure
53
+ session.close if block_given? && !failed
54
+ end
49
55
  rescue Object => anything
50
56
  begin
51
57
  session.shutdown!
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.1
4
+ version: 4.0.2
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-22 00:00:00.000000000 Z
35
+ date: 2026-10-25 00:00:00.000000000 Z
36
36
  dependencies:
37
37
  - !ruby/object:Gem::Dependency
38
38
  name: net-ssh
@@ -91,14 +91,18 @@ extra_rdoc_files:
91
91
  - LICENSE.txt
92
92
  - README.rdoc
93
93
  files:
94
+ - ".github/pull_request_template.md"
95
+ - ".github/workflows/changelog.yml"
94
96
  - ".github/workflows/ci.yml"
95
97
  - ".gitignore"
96
98
  - CHANGES.txt
99
+ - CONTRIBUTING.md
97
100
  - Gemfile
98
101
  - LICENSE.txt
99
102
  - Manifest
100
103
  - README.rdoc
101
104
  - Rakefile
105
+ - changelog.d/README.md
102
106
  - lib/net/sftp.rb
103
107
  - lib/net/sftp/constants.rb
104
108
  - lib/net/sftp/errors.rb
metadata.gz.sig CHANGED
Binary file