iostreams 2.0.0 → 2.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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -22
  3. data/Rakefile +45 -0
  4. data/docs/CLAUDE.md +9 -0
  5. data/docs/config.md +157 -0
  6. data/docs/copy_files.md +75 -0
  7. data/docs/extensions.md +111 -0
  8. data/docs/formats.md +188 -0
  9. data/docs/index.md +388 -0
  10. data/docs/path.md +652 -0
  11. data/docs/pgp.md +436 -0
  12. data/docs/streams.md +337 -0
  13. data/docs/tutorial.md +483 -0
  14. data/docs/upgrading.md +217 -0
  15. data/lib/io_streams/builder.rb +62 -2
  16. data/lib/io_streams/bzip2/reader.rb +25 -2
  17. data/lib/io_streams/bzip2/writer.rb +26 -2
  18. data/lib/io_streams/encode/reader.rb +4 -0
  19. data/lib/io_streams/encode/writer.rb +4 -0
  20. data/lib/io_streams/errors.rb +4 -0
  21. data/lib/io_streams/gzip/reader.rb +4 -0
  22. data/lib/io_streams/gzip/writer.rb +11 -2
  23. data/lib/io_streams/io_streams.rb +111 -1
  24. data/lib/io_streams/line/reader.rb +7 -2
  25. data/lib/io_streams/path.rb +115 -6
  26. data/lib/io_streams/paths/file.rb +47 -1
  27. data/lib/io_streams/paths/http.rb +45 -4
  28. data/lib/io_streams/paths/s3.rb +66 -15
  29. data/lib/io_streams/paths/sftp/net_ssh.rb +104 -0
  30. data/lib/io_streams/paths/sftp.rb +97 -57
  31. data/lib/io_streams/pgp/reader.rb +42 -2
  32. data/lib/io_streams/pgp/writer.rb +26 -6
  33. data/lib/io_streams/pgp.rb +78 -21
  34. data/lib/io_streams/reader.rb +10 -1
  35. data/lib/io_streams/record/reader.rb +72 -2
  36. data/lib/io_streams/stream.rb +12 -7
  37. data/lib/io_streams/symmetric_encryption/reader.rb +4 -0
  38. data/lib/io_streams/symmetric_encryption/writer.rb +4 -0
  39. data/lib/io_streams/tabular/header.rb +31 -4
  40. data/lib/io_streams/tabular/parser/base.rb +10 -0
  41. data/lib/io_streams/tabular/parser/csv.rb +5 -0
  42. data/lib/io_streams/tabular/parser/fixed.rb +3 -1
  43. data/lib/io_streams/tabular/parser/psv.rb +6 -2
  44. data/lib/io_streams/utils.rb +31 -0
  45. data/lib/io_streams/version.rb +1 -1
  46. data/lib/io_streams/writer.rb +10 -1
  47. data/lib/io_streams/xlsx/reader.rb +5 -1
  48. data/lib/io_streams/zip/reader.rb +4 -0
  49. data/lib/io_streams/zip/writer.rb +4 -0
  50. metadata +24 -7
@@ -105,6 +105,18 @@ module IOStreams
105
105
  @format = format
106
106
  end
107
107
 
108
+ # Returns [String] the quote character within which field delimiters and newlines may be
109
+ # embedded for the current tabular format, or [nil] when the format has no such quoting,
110
+ # or when the format cannot be determined.
111
+ #
112
+ # Used by the line reader to avoid treating a newline as a line ending when it is embedded
113
+ # within a quoted field (e.g. CSV). Delegates to the format's parser, so the per-format
114
+ # knowledge lives with the parser. Driven entirely by `format`, so an explicitly set format
115
+ # (e.g. `.format(:psv)`) overrides any extension auto-detected from the `file_name`.
116
+ def quote_character
117
+ format && IOStreams::Tabular.parser_class(format).quote_character
118
+ end
119
+
108
120
  private
109
121
 
110
122
  def build_pipeline
@@ -146,14 +158,62 @@ module IOStreams
146
158
  block.call(io_stream)
147
159
  elsif pipeline.size == 1
148
160
  stream, opts = pipeline.first
149
- class_for_stream(type, stream).open(io_stream, **opts, &block)
161
+ open_stream(type, stream, io_stream, opts, &block)
150
162
  else
151
163
  # Daisy chain multiple streams together
152
164
  last = pipeline.keys.inject(block) do |inner, stream_sym|
153
- ->(io) { class_for_stream(type, stream_sym).open(io, **pipeline[stream_sym], &inner) }
165
+ ->(io) { open_stream(type, stream_sym, io, pipeline[stream_sym], &inner) }
154
166
  end
155
167
  last.call(io_stream)
156
168
  end
157
169
  end
170
+
171
+ def open_stream(type, stream, io_stream, opts, &)
172
+ klass = class_for_stream(type, stream)
173
+ validate_options(type, stream, klass, opts)
174
+ klass.open(io_stream, **opts, &)
175
+ end
176
+
177
+ # Options are strict: an option the stream does not accept raises instead of being ignored.
178
+ # One option hash is shared by the reader and the writer for a stream, so when the option
179
+ # is only valid in the other direction the message says so.
180
+ #
181
+ # Streams registered via `IOStreams.register_extension` need not inherit from `IOStreams::Reader`
182
+ # or `IOStreams::Writer`, so a class that does not declare `option_names` is not validated here.
183
+ def validate_options(type, stream, klass, opts)
184
+ accepted = option_names(klass)
185
+ return if accepted.nil?
186
+
187
+ unknown = opts.keys - accepted
188
+ return if unknown.empty?
189
+
190
+ other_type = type == :reader ? :writer : :reader
191
+ other_names = option_names(IOStreams.extensions[stream].send("#{other_type}_class")) || []
192
+ other_only = unknown & other_names
193
+ invalid = unknown - other_names
194
+ direction = type == :reader ? "reading" : "writing"
195
+
196
+ messages = []
197
+ if other_only.any?
198
+ messages << "#{list(other_only)} only #{other_only.size == 1 ? 'applies' : 'apply'} when " \
199
+ "#{type == :reader ? 'writing' : 'reading'} a #{stream.inspect} stream and cannot be used when " \
200
+ "#{direction}. Configure a separate path or stream without #{other_only.size == 1 ? 'it' : 'them'} " \
201
+ "for #{direction}."
202
+ end
203
+ if invalid.any?
204
+ valid = accepted.empty? ? "none" : list(accepted)
205
+ messages << "Unknown #{invalid.size == 1 ? 'option' : 'options'} #{list(invalid)} when #{direction} " \
206
+ "a #{stream.inspect} stream. Valid options: #{valid}."
207
+ end
208
+ raise(ArgumentError, messages.join(" "))
209
+ end
210
+
211
+ def option_names(klass)
212
+ klass.option_names if klass.respond_to?(:option_names)
213
+ end
214
+
215
+ def list(names)
216
+ names.map(&:inspect).join(", ")
217
+ end
158
218
  end
159
219
  end
@@ -1,12 +1,35 @@
1
1
  module IOStreams
2
2
  module Bzip2
3
3
  class Reader < IOStreams::Reader
4
+ OPTION_NAMES = %i[autoclose first_only small].freeze
5
+
6
+ # Not declared until v3.0, so that an unknown option logs a warning instead of raising `ArgumentError`.
7
+ def self.option_names
8
+ nil
9
+ end
10
+
4
11
  # Read from a Bzip2 stream, decompressing the contents as it is read
5
- def self.stream(input_stream, **args)
12
+ #
13
+ # Any other option is ignored and logs a warning. It will raise `ArgumentError` in v3.0.
14
+ #
15
+ # Parameters are passed through to `Bzip2::FFI::Reader`:
16
+ # autoclose: [true|false]
17
+ # Close the input stream when the reader is closed.
18
+ # Default: false
19
+ #
20
+ # first_only: [true|false]
21
+ # Only decompress the first of any consecutive bzip2 structures in the input.
22
+ # Default: false
23
+ #
24
+ # small: [true|false]
25
+ # Use an alternative decompression algorithm that uses less memory but is slower.
26
+ # Default: false
27
+ def self.stream(input_stream, autoclose: false, first_only: false, small: false, **unknown)
28
+ Utils.warn_unknown_options(unknown, :bz2, "reading", OPTION_NAMES)
6
29
  Utils.load_soft_dependency("bzip2-ffi", "Bzip2", "bzip2/ffi") unless defined?(::Bzip2::FFI)
7
30
 
8
31
  begin
9
- io = ::Bzip2::FFI::Reader.new(input_stream, args)
32
+ io = ::Bzip2::FFI::Reader.new(input_stream, autoclose: autoclose, first_only: first_only, small: small)
10
33
  yield io
11
34
  ensure
12
35
  io&.close
@@ -1,12 +1,36 @@
1
1
  module IOStreams
2
2
  module Bzip2
3
3
  class Writer < IOStreams::Writer
4
+ OPTION_NAMES = %i[autoclose block_size work_factor].freeze
5
+
6
+ # Not declared until v3.0, so that an unknown option logs a warning instead of raising `ArgumentError`.
7
+ def self.option_names
8
+ nil
9
+ end
10
+
4
11
  # Write to a stream, compressing with Bzip2
5
- def self.stream(input_stream, **args)
12
+ #
13
+ # Any other option is ignored and logs a warning. It will raise `ArgumentError` in v3.0.
14
+ #
15
+ # Parameters are passed through to `Bzip2::FFI::Writer`:
16
+ # autoclose: [true|false]
17
+ # Close the output stream when the writer is closed.
18
+ # Default: false
19
+ #
20
+ # block_size: [Integer]
21
+ # Compression block size, from 1 (100k) to 9 (900k).
22
+ # Default: 9
23
+ #
24
+ # work_factor: [Integer]
25
+ # How much effort to spend on highly repetitive input before falling back
26
+ # to a slower algorithm, from 0 to 250. 0 uses the libbz2 default.
27
+ # Default: 0
28
+ def self.stream(input_stream, autoclose: false, block_size: nil, work_factor: nil, **unknown)
29
+ Utils.warn_unknown_options(unknown, :bz2, "writing", OPTION_NAMES)
6
30
  Utils.load_soft_dependency("bzip2-ffi", "Bzip2", "bzip2/ffi") unless defined?(::Bzip2::FFI)
7
31
 
8
32
  begin
9
- io = ::Bzip2::FFI::Writer.new(input_stream, args)
33
+ io = ::Bzip2::FFI::Writer.new(input_stream, autoclose: autoclose, block_size: block_size, work_factor: work_factor)
10
34
  yield io
11
35
  ensure
12
36
  io&.close
@@ -1,6 +1,10 @@
1
1
  module IOStreams
2
2
  module Encode
3
3
  class Reader < IOStreams::Reader
4
+ def self.option_names
5
+ %i[encoding cleaner replace]
6
+ end
7
+
4
8
  attr_reader :encoding, :cleaner
5
9
 
6
10
  NOT_PRINTABLE = /[^[:print:]|\r\n]/
@@ -1,6 +1,10 @@
1
1
  module IOStreams
2
2
  module Encode
3
3
  class Writer < IOStreams::Writer
4
+ def self.option_names
5
+ %i[encoding cleaner replace]
6
+ end
7
+
4
8
  attr_reader :encoding, :cleaner
5
9
 
6
10
  # Write a line at a time to a file or stream
@@ -18,6 +18,10 @@ module IOStreams
18
18
  class CommunicationsFailure < Error
19
19
  end
20
20
 
21
+ # When a path is not within any of the allowed paths, see `IOStreams.add_allowed_path`.
22
+ class AccessDenied < Error
23
+ end
24
+
21
25
  # When the specified delimiter is not found in the supplied stream / file
22
26
  class DelimiterNotFound < Error
23
27
  end
@@ -1,6 +1,10 @@
1
1
  module IOStreams
2
2
  module Gzip
3
3
  class Reader < IOStreams::Reader
4
+ def self.option_names
5
+ []
6
+ end
7
+
4
8
  # Read from a gzip stream, decompressing the contents as it is read
5
9
  def self.stream(input_stream)
6
10
  io = ::Zlib::GzipReader.new(input_stream)
@@ -1,9 +1,18 @@
1
1
  module IOStreams
2
2
  module Gzip
3
3
  class Writer < IOStreams::Writer
4
+ def self.option_names
5
+ %i[level]
6
+ end
7
+
4
8
  # Write to a stream, compressing with GZip
5
- def self.stream(input_stream, &block)
6
- io = ::Zlib::GzipWriter.new(input_stream)
9
+ #
10
+ # Parameters
11
+ # level: [Integer]
12
+ # Compression level, from 0 (no compression) to 9 (best compression).
13
+ # Default: Zlib::DEFAULT_COMPRESSION
14
+ def self.stream(input_stream, level: nil, &block)
15
+ io = ::Zlib::GzipWriter.new(input_stream, level)
7
16
  block.call(io)
8
17
  ensure
9
18
  io&.close
@@ -119,8 +119,12 @@ module IOStreams
119
119
  #
120
120
  # Example:
121
121
  # IOStreams.temp_file("export", ".csv") { |path| path.write("Hello World") }
122
+ #
123
+ # Note: The temp file is accessible even when it is not within the allowed paths, see `IOStreams.add_allowed_path`.
122
124
  def self.temp_file(basename, extension = "")
123
- Utils.temp_file_name(basename, extension) { |file_name| yield(Paths::File.new(file_name).stream(:none)) }
125
+ Utils.temp_file_name(basename, extension) do |file_name|
126
+ yield(Paths::File.new(file_name).send(:permit!).stream(:none))
127
+ end
124
128
  end
125
129
 
126
130
  # Returns [IOStreams::Paths::File] current or named users home path
@@ -241,6 +245,77 @@ module IOStreams
241
245
  @root_paths.dup
242
246
  end
243
247
 
248
+ # Restrict IOStreams to only access paths within the supplied path.
249
+ #
250
+ # Once any allowed path has been added, reading, writing, listing, deleting or otherwise accessing
251
+ # a path that is not within one of the allowed paths raises `IOStreams::Errors::AccessDenied`.
252
+ # This prevents an untrusted file name, for example one supplied by a user, from accessing anything
253
+ # else that the process can access.
254
+ #
255
+ # Parameters: Same as `IOStreams.path`
256
+ #
257
+ # Returns [String] the normalized path that was added, against which paths are compared.
258
+ #
259
+ # Example:
260
+ # IOStreams.add_allowed_path("/var/data/uploads")
261
+ # IOStreams.add_allowed_path("s3://my-bucket/exports")
262
+ #
263
+ # IOStreams.path("/var/data/uploads/file.csv").read
264
+ # IOStreams.path("/etc/passwd").read
265
+ # # => IOStreams::Errors::AccessDenied
266
+ #
267
+ # Notes:
268
+ # * By default no allowed paths are added, and every path is accessible.
269
+ # * Add allowed paths in an initializer at startup, where they cannot be changed by untrusted input.
270
+ # * Paths are normalized before they are compared, so `..` cannot be used to leave an allowed path:
271
+ # * Local file names are resolved to their real path, following symbolic links.
272
+ # A relative path is resolved against the current working directory when it is added.
273
+ # * For S3 the bucket must match, and keys containing `.` or `..` segments are denied.
274
+ # * For SFTP and HTTP the host and port must match, and `.` and `..` segments are resolved.
275
+ # * `#each_child` skips children that are not within the allowed paths, for example a symbolic link
276
+ # to a file elsewhere.
277
+ # * Temp files created by `IOStreams.temp_file` are always accessible.
278
+ # * Paths from a scheme registered with `IOStreams.register_scheme` are denied, unless its path class
279
+ # implements the private method `#allowed_location`.
280
+ # * A local file could be replaced with a symbolic link after it is checked but before it is opened.
281
+ # Allowed paths do not prevent this, so do not allow paths where untrusted users can create files.
282
+ def self.add_allowed_path(*elements, **args)
283
+ location = allowed_location(path(*elements, **args))
284
+ @allowed_paths_mutex.synchronize { @allowed_paths = (@allowed_paths + [location]).uniq.freeze }
285
+ location
286
+ end
287
+
288
+ # Removes a path previously added with `IOStreams.add_allowed_path`.
289
+ #
290
+ # Returns [String] the normalized path that was removed.
291
+ def self.delete_allowed_path(*elements, **args)
292
+ location = allowed_location(path(*elements, **args))
293
+ @allowed_paths_mutex.synchronize { @allowed_paths = (@allowed_paths - [location]).freeze }
294
+ location
295
+ end
296
+
297
+ # Returns [Array<String>] the normalized allowed paths, see `IOStreams.add_allowed_path`.
298
+ def self.allowed_paths
299
+ @allowed_paths
300
+ end
301
+
302
+ # Returns [true|false] whether the supplied path can be accessed, see `IOStreams.add_allowed_path`.
303
+ #
304
+ # Always true when no allowed paths have been added.
305
+ #
306
+ # Parameters: Same as `IOStreams.path`
307
+ def self.allowed_path?(*elements, **args)
308
+ path(*elements, **args).send(:allowed?)
309
+ end
310
+
311
+ def self.allowed_location(path)
312
+ path.send(:allowed_location)
313
+ rescue Errors::AccessDenied => e
314
+ raise(ArgumentError, e.message)
315
+ end
316
+
317
+ private_class_method :allowed_location
318
+
244
319
  # Set the temporary path to use when creating local temp files.
245
320
  def self.temp_dir=(temp_dir)
246
321
  temp_dir = File.expand_path(temp_dir)
@@ -259,6 +334,37 @@ module IOStreams
259
334
 
260
335
  @temp_dir = nil
261
336
 
337
+ # Apply `allowed_columns`, `required_columns` and `skip_unknown` to every input when reading records.
338
+ #
339
+ # When false, they only apply to a header row read from the file, and only when `cleanse_header` is true.
340
+ # They are ignored for JSON and `:hash` input, when `columns:` is supplied, and with `cleanse_header: false`,
341
+ # and a warning is logged when applying them would change the result.
342
+ #
343
+ # When true, they also apply to the supplied `columns:`, to a header row read with `cleanse_header: false`,
344
+ # and to the keys of each JSON or `:hash` record. JSON keys are cleansed like a header row, unless
345
+ # `cleanse_header` is false, unknown keys are skipped or raise `IOStreams::Errors::InvalidHeader`,
346
+ # and a record missing a required column raises `IOStreams::Errors::InvalidHeader`.
347
+ #
348
+ # Since the format is usually inferred from the file name, set this to true when the allowed columns
349
+ # restrict what an uploaded file can set, so that renaming the file to `.json` cannot bypass them.
350
+ #
351
+ # Default: false. It will default to true in v3.0.
352
+ #
353
+ # Example:
354
+ # IOStreams.enforce_column_restrictions = true
355
+ def self.enforce_column_restrictions=(enforce)
356
+ raise(ArgumentError, "enforce_column_restrictions must be true or false") unless [true, false].include?(enforce)
357
+
358
+ @enforce_column_restrictions = enforce
359
+ end
360
+
361
+ # Returns [true|false] whether column restrictions apply to every input, see `IOStreams.enforce_column_restrictions=`.
362
+ def self.enforce_column_restrictions?
363
+ @enforce_column_restrictions
364
+ end
365
+
366
+ @enforce_column_restrictions = false
367
+
262
368
  # Returns [Logger] the logger used by IOStreams for debug logging.
263
369
  #
264
370
  # When SemanticLogger is loaded a SemanticLogger instance is used by default,
@@ -327,6 +433,10 @@ module IOStreams
327
433
  # Hold root paths
328
434
  @root_paths = {}
329
435
 
436
+ # Hold allowed paths. Replaced rather than modified, so that it can be read without a lock.
437
+ @allowed_paths = [].freeze
438
+ @allowed_paths_mutex = Mutex.new
439
+
330
440
  # A registry to hold formats for processing files during upload or download
331
441
  @extensions = {}
332
442
  @schemes = {}
@@ -43,7 +43,8 @@ module IOStreams
43
43
  # For CSV files set `embedded_within: '"'`
44
44
  #
45
45
  # Note:
46
- # * When using a line reader and the file_name ends with ".csv" then embedded_within is automatically set to `"`
46
+ # * When reached via `IOStreams::Stream`, `embedded_within` defaults to the quote character of the
47
+ # resolved tabular format (e.g. `"` for CSV). See `IOStreams::Builder#quote_character`.
47
48
  def initialize(input_stream, delimiter: nil, buffer_size: 65_536, embedded_within: nil)
48
49
  super(input_stream)
49
50
 
@@ -93,7 +94,9 @@ module IOStreams
93
94
  line = _readline
94
95
  if line && @embedded_within
95
96
  initial_line_number = @line_number
96
- while line.count(@embedded_within).odd?
97
+ # Count the delimiters incrementally, since recounting the whole line each time is quadratic.
98
+ embedded_count = line.count(@embedded_within)
99
+ while embedded_count.odd?
97
100
  if eof? || line.length > @buffer_size * 10
98
101
  raise(Errors::MalformedDataError.new(
99
102
  "Unbalanced delimited field, delimiter: #{@embedded_within}",
@@ -101,6 +104,7 @@ module IOStreams
101
104
  ))
102
105
  end
103
106
  line << @delimiter
107
+ embedded_count += @delimiter.count(@embedded_within)
104
108
  next_line = _readline
105
109
  if next_line.nil?
106
110
  raise(Errors::MalformedDataError.new(
@@ -109,6 +113,7 @@ module IOStreams
109
113
  ))
110
114
  end
111
115
  line << next_line
116
+ embedded_count += next_line.count(@embedded_within)
112
117
  end
113
118
  end
114
119
  line
@@ -1,5 +1,9 @@
1
1
  module IOStreams
2
2
  class Path < IOStreams::Stream
3
+ # Stream option names whose values must not be displayed, such as `passphrase` or `signer_passphrase`.
4
+ SENSITIVE_OPTION = /pass(phrase|word)|secret/i
5
+ private_constant :SENSITIVE_OPTION
6
+
3
7
  attr_accessor :path
4
8
 
5
9
  def initialize(path)
@@ -21,7 +25,7 @@ module IOStreams
21
25
 
22
26
  new_path = dup
23
27
  new_path.builder = nil
24
- new_path.path = relative.start_with?(path) ? relative : ::File.join(path, relative)
28
+ new_path.path = contains?(relative) ? relative : ::File.join(path, relative)
25
29
  new_path
26
30
  end
27
31
 
@@ -57,6 +61,24 @@ module IOStreams
57
61
  path
58
62
  end
59
63
 
64
+ # See Stream#reader.
65
+ #
66
+ # Raises [IOStreams::Errors::AccessDenied] when this path is not within any of the allowed paths,
67
+ # see `IOStreams.add_allowed_path`.
68
+ def reader(...)
69
+ authorize!
70
+ super
71
+ end
72
+
73
+ # See Stream#writer.
74
+ #
75
+ # Raises [IOStreams::Errors::AccessDenied] when this path is not within any of the allowed paths,
76
+ # see `IOStreams.add_allowed_path`.
77
+ def writer(...)
78
+ authorize!
79
+ super
80
+ end
81
+
60
82
  # Removes the last element of the path, the file name, before creating the entire path.
61
83
  # Returns self
62
84
  def mkpath
@@ -82,7 +104,7 @@ module IOStreams
82
104
  end
83
105
 
84
106
  # Cleanup an incomplete write to the target "file" if the copy fails.
85
- # rubocop:disable Lint/SuppressedException
107
+ # rubocop:disable-next Lint/SuppressedException
86
108
  def copy_from(source, **args)
87
109
  super
88
110
  rescue StandardError => e
@@ -92,7 +114,6 @@ module IOStreams
92
114
  end
93
115
  raise(e)
94
116
  end
95
- # rubocop:enable Lint/SuppressedException
96
117
 
97
118
  # Moves the file by copying it to the new path and then deleting the current path.
98
119
  # Returns [IOStreams::Path] the target path.
@@ -190,15 +211,103 @@ module IOStreams
190
211
 
191
212
  def inspect
192
213
  str = "#<#{self.class.name}:#{path}"
193
- str << " @builder=#{builder.streams.inspect}" if builder.streams
194
- str << " @options=#{builder.options.inspect}" if builder.options
195
- str << " pipeline=#{pipeline.inspect}>"
214
+ str << " @builder=#{redact(builder.streams).inspect}" if builder.streams
215
+ str << " @options=#{redact(builder.options).inspect}" if builder.options
216
+ str << " pipeline=#{redact(pipeline).inspect}>"
217
+ end
218
+
219
+ protected
220
+
221
+ # Raises [IOStreams::Errors::AccessDenied] when allowed paths have been added, see `IOStreams.add_allowed_path`,
222
+ # and this path is not within any of them.
223
+ def authorize!
224
+ return if IOStreams.allowed_paths.empty? || (@permitted_path && @permitted_path == path)
225
+
226
+ authorize_location!(allowed_location)
227
+ end
228
+
229
+ # Returns [true|false] whether this path is within the allowed paths, see `#authorize!`.
230
+ def allowed?
231
+ authorize!
232
+ true
233
+ rescue Errors::AccessDenied
234
+ false
196
235
  end
197
236
 
198
237
  private
199
238
 
239
+ # Returns [String] the normalized location of this path, which is compared against the allowed paths.
240
+ #
241
+ # Each path class that can be used with allowed paths overrides this method. Without it every path
242
+ # of that class is denied once allowed paths have been added.
243
+ #
244
+ # Raises [IOStreams::Errors::AccessDenied] when the location cannot be determined.
245
+ def allowed_location
246
+ raise(Errors::AccessDenied, "Access denied: #{self.class.name} does not support allowed paths")
247
+ end
248
+
249
+ # Raises [IOStreams::Errors::AccessDenied] when the supplied location, see `#allowed_location`,
250
+ # is not within any of the allowed paths.
251
+ def authorize_location!(location)
252
+ return if IOStreams.allowed_paths.any? { |allowed_path| within?(location, allowed_path) }
253
+
254
+ raise(Errors::AccessDenied, "Access denied to #{location}: it is not within any of the allowed paths")
255
+ end
256
+
257
+ # Returns [String] the supplied path with `.`, `..` and repeated `/` resolved, the way a remote server
258
+ # resolves them, without accessing it. The path always starts with `/`, and `..` cannot go above it.
259
+ def normalize_path(name)
260
+ segments = []
261
+ name.split("/").each do |segment|
262
+ case segment
263
+ when "", "."
264
+ next
265
+ when ".."
266
+ segments.pop
267
+ else
268
+ segments << segment
269
+ end
270
+ end
271
+ "/#{segments.join('/')}"
272
+ end
273
+
274
+ # Returns [true|false] whether a child found by `#each_child` is within the allowed paths, logging it when it is not.
275
+ def allowed_child?(child)
276
+ return true if child.allowed?
277
+
278
+ IOStreams.logger&.warn("Skipping #{child} since it is not within any of the allowed paths")
279
+ false
280
+ end
281
+
282
+ # Allows this exact path regardless of the allowed paths, for paths created by IOStreams itself such as temp files.
283
+ # Returns self
284
+ def permit!
285
+ @permitted_path = path
286
+ self
287
+ end
288
+
289
+ # Returns [Hash<Symbol:Hash>] the streams with the values of sensitive options replaced.
290
+ def redact(streams)
291
+ streams.transform_values do |options|
292
+ next options unless options.is_a?(Hash)
293
+
294
+ options.to_h { |name, value| [name, name.to_s.match?(SENSITIVE_OPTION) ? "[FILTERED]" : value] }
295
+ end
296
+ end
297
+
200
298
  def builder
201
299
  @builder ||= IOStreams::Builder.new(path)
202
300
  end
301
+
302
+ # Returns [true|false] whether the supplied path is this path, or is within this path.
303
+ # For example "a/b" contains "a/b/c.csv", but not "a/bc.csv".
304
+ def contains?(other)
305
+ path.empty? || within?(other, path)
306
+ end
307
+
308
+ # Returns [true|false] whether the path `child` is `parent`, or is within `parent`.
309
+ def within?(child, parent)
310
+ child == parent || child.start_with?(parent.end_with?("/") ? parent : "#{parent}/")
311
+ end
203
312
  end
204
313
  end
@@ -94,6 +94,8 @@ module IOStreams
94
94
  case_sensitive: case_sensitive, directories: directories, hidden: hidden)
95
95
  end
96
96
 
97
+ authorize!
98
+
97
99
  flags = 0
98
100
  flags |= ::File::FNM_CASEFOLD unless case_sensitive
99
101
  flags |= ::File::FNM_DOTMATCH if hidden
@@ -116,7 +118,8 @@ module IOStreams
116
118
  results.each do |full_path|
117
119
  next if !directories && ::File.directory?(full_path)
118
120
 
119
- yield(self.class.new(full_path))
121
+ child = self.class.new(full_path)
122
+ yield(child) if allowed_child?(child)
120
123
  end
121
124
  end
122
125
 
@@ -128,6 +131,8 @@ module IOStreams
128
131
  target = IOStreams.new(target_path)
129
132
  return super(target) unless target.is_a?(self.class)
130
133
 
134
+ authorize!
135
+ target.authorize!
131
136
  target.mkpath
132
137
  # In case the file is being moved across partitions
133
138
  FileUtils.move(path, target.to_s)
@@ -135,25 +140,30 @@ module IOStreams
135
140
  end
136
141
 
137
142
  def mkpath
143
+ authorize!
138
144
  dir = ::File.dirname(path)
139
145
  FileUtils.mkdir_p(dir)
140
146
  self
141
147
  end
142
148
 
143
149
  def mkdir
150
+ authorize!
144
151
  FileUtils.mkdir_p(path)
145
152
  self
146
153
  end
147
154
 
148
155
  def exist?
156
+ authorize!
149
157
  ::File.exist?(path)
150
158
  end
151
159
 
152
160
  def size
161
+ authorize!
153
162
  ::File.size(path)
154
163
  end
155
164
 
156
165
  def delete
166
+ authorize!
157
167
  return self unless exist?
158
168
 
159
169
  ::File.directory?(path) ? Dir.delete(path) : ::File.unlink(path)
@@ -161,6 +171,7 @@ module IOStreams
161
171
  end
162
172
 
163
173
  def delete_all
174
+ authorize!
164
175
  return self unless exist?
165
176
 
166
177
  ::File.directory?(path) ? FileUtils.remove_dir(path) : ::File.unlink(path)
@@ -169,11 +180,46 @@ module IOStreams
169
180
 
170
181
  # Returns the real path by stripping `.`, `..` and expands any symlinks.
171
182
  def realpath
183
+ authorize!
172
184
  self.class.new(::File.realpath(path))
173
185
  end
174
186
 
175
187
  private
176
188
 
189
+ # Returns [String] the real path of this file, following any symbolic links, which is compared
190
+ # against the allowed paths.
191
+ #
192
+ # The part of the path that does not exist yet, for example a file that is about to be written,
193
+ # is appended to the real path of the part that does exist. It cannot contain `.` or `..`, since
194
+ # what they refer to depends on directories that have not been created yet.
195
+ def allowed_location
196
+ existing = ::File.absolute_path?(path) ? path : ::File.join(Dir.pwd, path)
197
+ missing = []
198
+ until present?(existing)
199
+ parent = ::File.dirname(existing)
200
+ break if parent == existing
201
+
202
+ missing.unshift(::File.basename(existing))
203
+ existing = parent
204
+ end
205
+
206
+ if missing.intersect?([".", ".."])
207
+ raise(Errors::AccessDenied, "Access denied to #{path}: '.' and '..' are not allowed after a missing directory")
208
+ end
209
+
210
+ ::File.join(::File.realpath(existing), *missing)
211
+ rescue SystemCallError => e
212
+ raise(Errors::AccessDenied, "Access denied to #{path}: #{e.message}")
213
+ end
214
+
215
+ # Returns [true|false] whether the file, directory or symbolic link exists, without following the link.
216
+ def present?(file_name)
217
+ ::File.lstat(file_name)
218
+ true
219
+ rescue SystemCallError
220
+ false
221
+ end
222
+
177
223
  # Read from file
178
224
  def stream_reader(&block)
179
225
  ::File.open(path, "rb") { |io| builder.reader(io, &block) }