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.
- checksums.yaml +4 -4
- data/README.md +5 -22
- data/Rakefile +45 -0
- data/docs/CLAUDE.md +9 -0
- data/docs/config.md +157 -0
- data/docs/copy_files.md +75 -0
- data/docs/extensions.md +111 -0
- data/docs/formats.md +188 -0
- data/docs/index.md +388 -0
- data/docs/path.md +652 -0
- data/docs/pgp.md +436 -0
- data/docs/streams.md +337 -0
- data/docs/tutorial.md +483 -0
- data/docs/upgrading.md +217 -0
- data/lib/io_streams/builder.rb +62 -2
- data/lib/io_streams/bzip2/reader.rb +25 -2
- data/lib/io_streams/bzip2/writer.rb +26 -2
- data/lib/io_streams/encode/reader.rb +4 -0
- data/lib/io_streams/encode/writer.rb +4 -0
- data/lib/io_streams/errors.rb +4 -0
- data/lib/io_streams/gzip/reader.rb +4 -0
- data/lib/io_streams/gzip/writer.rb +11 -2
- data/lib/io_streams/io_streams.rb +111 -1
- data/lib/io_streams/line/reader.rb +7 -2
- data/lib/io_streams/path.rb +115 -6
- data/lib/io_streams/paths/file.rb +47 -1
- data/lib/io_streams/paths/http.rb +45 -4
- data/lib/io_streams/paths/s3.rb +66 -15
- data/lib/io_streams/paths/sftp/net_ssh.rb +104 -0
- data/lib/io_streams/paths/sftp.rb +97 -57
- data/lib/io_streams/pgp/reader.rb +42 -2
- data/lib/io_streams/pgp/writer.rb +26 -6
- data/lib/io_streams/pgp.rb +78 -21
- data/lib/io_streams/reader.rb +10 -1
- data/lib/io_streams/record/reader.rb +72 -2
- data/lib/io_streams/stream.rb +12 -7
- data/lib/io_streams/symmetric_encryption/reader.rb +4 -0
- data/lib/io_streams/symmetric_encryption/writer.rb +4 -0
- data/lib/io_streams/tabular/header.rb +31 -4
- data/lib/io_streams/tabular/parser/base.rb +10 -0
- data/lib/io_streams/tabular/parser/csv.rb +5 -0
- data/lib/io_streams/tabular/parser/fixed.rb +3 -1
- data/lib/io_streams/tabular/parser/psv.rb +6 -2
- data/lib/io_streams/utils.rb +31 -0
- data/lib/io_streams/version.rb +1 -1
- data/lib/io_streams/writer.rb +10 -1
- data/lib/io_streams/xlsx/reader.rb +5 -1
- data/lib/io_streams/zip/reader.rb +4 -0
- data/lib/io_streams/zip/writer.rb +4 -0
- metadata +24 -7
data/lib/io_streams/builder.rb
CHANGED
|
@@ -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
|
-
|
|
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) {
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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
|
data/lib/io_streams/errors.rb
CHANGED
|
@@ -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,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
|
-
|
|
6
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/io_streams/path.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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) }
|