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
@@ -3,6 +3,10 @@ require "open3"
3
3
  module IOStreams
4
4
  module Pgp
5
5
  class Reader < IOStreams::Reader
6
+ def self.option_names
7
+ %i[passphrase ignore_mdc_error verify_first]
8
+ end
9
+
6
10
  # Passphrase to use to open the private key to decrypt the received file
7
11
  class << self
8
12
  attr_writer :default_passphrase
@@ -16,6 +20,15 @@ module IOStreams
16
20
 
17
21
  # Read from a PGP / GPG file , decompressing the contents as it is read.
18
22
  #
23
+ # SECURITY WARNING:
24
+ # By default the decrypted contents are passed to the block as gpg decrypts them,
25
+ # before gpg has checked the file's integrity (MDC) and any signature, since those are
26
+ # only known once the whole file has been read. When either check fails,
27
+ # `IOStreams::Pgp::Failure` is raised after the block has processed the data.
28
+ # Do not commit any side effects, such as database updates, until the block returns
29
+ # without raising, for example by processing the file within a database transaction.
30
+ # Otherwise supply `verify_first: true`.
31
+ #
19
32
  # file_name: [String]
20
33
  # Name of file to read from
21
34
  #
@@ -30,7 +43,15 @@ module IOStreams
30
43
  # Only enable this for files from a trusted source: without MDC the decrypted
31
44
  # contents are not protected against tampering.
32
45
  # Default: false
33
- def self.file(file_name, passphrase: nil, ignore_mdc_error: false)
46
+ #
47
+ # verify_first: [true|false]
48
+ # Decrypt the whole file into a temporary file, only readable by the current user,
49
+ # and only pass its contents to the block once gpg has checked the file's integrity
50
+ # and any signature.
51
+ # Requires local disk space for the decrypted contents, which are deleted afterwards,
52
+ # and an extra pass over the data.
53
+ # Default: false
54
+ def self.file(file_name, passphrase: nil, ignore_mdc_error: false, verify_first: false, &)
34
55
  # Cannot use `passphrase: self.default_passphrase` since it is considered private
35
56
  passphrase ||= default_passphrase
36
57
 
@@ -43,11 +64,13 @@ module IOStreams
43
64
  args += ["--batch", "--no-tty", "--yes", "--decrypt"]
44
65
  # Only feed a passphrase when one is supplied; sign-only files need none.
45
66
  args += ["--passphrase-fd", "0"] if passphrase
46
- args << file_name.to_s
67
+ args += ["--", file_name.to_s]
47
68
 
48
69
  command = IOStreams::Pgp.gpg_command(*args)
49
70
  IOStreams.logger&.debug { "IOStreams::Pgp::Reader.open: #{command.shelljoin}" }
50
71
 
72
+ return decrypt_then_read(command, file_name, passphrase, &) if verify_first
73
+
51
74
  # Read decrypted contents from stdout
52
75
  Open3.popen3(*command) do |stdin, stdout, stderr, waith_thr|
53
76
  stdin.puts(passphrase) if passphrase
@@ -65,6 +88,23 @@ module IOStreams
65
88
  result
66
89
  end
67
90
  end
91
+
92
+ # Decrypts the file into a temporary file, and only yields it once gpg has succeeded.
93
+ def self.decrypt_then_read(command, file_name, passphrase, &block)
94
+ Utils.private_temp_file("iostreams_pgp") do |temp_file_name|
95
+ Open3.popen3(*command) do |stdin, stdout, stderr, waith_thr|
96
+ stdin.puts(passphrase) if passphrase
97
+ stdin.close
98
+ ::File.open(temp_file_name, "wb") { |io| ::IO.copy_stream(stdout, io) }
99
+ unless waith_thr.value.success?
100
+ raise(Pgp::Failure, "GPG Failed to decrypt file: #{file_name}: #{stderr.read.chomp}")
101
+ end
102
+ end
103
+
104
+ ::File.open(temp_file_name, "rb", &block)
105
+ end
106
+ end
107
+ private_class_method :decrypt_then_read
68
108
  end
69
109
  end
70
110
  end
@@ -3,6 +3,14 @@ require "open3"
3
3
  module IOStreams
4
4
  module Pgp
5
5
  class Writer < IOStreams::Writer
6
+ # File descriptor in the gpg process that the signer passphrase is read from.
7
+ PASSPHRASE_FD = 3
8
+
9
+ def self.option_names
10
+ %i[encrypt recipient import_and_trust_key import_and_trust_level signer signer_passphrase
11
+ compress compress_level]
12
+ end
13
+
6
14
  class << self
7
15
  # Sign all encrypted files with this users key.
8
16
  # Default: Do not sign encrypted files.
@@ -122,13 +130,22 @@ module IOStreams
122
130
  recipients: recipients
123
131
  )
124
132
  command = IOStreams::Pgp.gpg_command(*args)
133
+ IOStreams.logger&.debug { "IOStreams::Pgp::Writer.open: #{command.shelljoin}" }
125
134
 
126
- # Do not log the command, it may contain the signer passphrase.
127
- action = encrypt ? "encrypt" : "sign"
128
- IOStreams.logger&.debug { "IOStreams::Pgp::Writer.open: #{action} -o #{file_name}" }
135
+ # Since stdin carries the data, supply the signer passphrase on file descriptor 3
136
+ # so that it is not visible in the process list.
137
+ spawn_options = {}
138
+ if signer_passphrase
139
+ passphrase_reader, passphrase_writer = IO.pipe
140
+ passphrase_writer.puts(signer_passphrase.to_s)
141
+ passphrase_writer.close
142
+ spawn_options[PASSPHRASE_FD] = passphrase_reader
143
+ end
129
144
 
130
145
  result = nil
131
- Open3.popen2e(*command) do |stdin, out, waith_thr|
146
+ Open3.popen2e(*command, spawn_options) do |stdin, out, waith_thr|
147
+ # Only the gpg process needs the passphrase.
148
+ passphrase_reader&.close
132
149
  begin
133
150
  stdin.binmode
134
151
  result = yield(stdin)
@@ -144,6 +161,8 @@ module IOStreams
144
161
  end
145
162
  end
146
163
  result
164
+ ensure
165
+ passphrase_reader&.close
147
166
  end
148
167
 
149
168
  def self.build_args(file_name:, encrypt:, signer:, signer_passphrase:, compress:, compress_level:, recipients:)
@@ -153,7 +172,7 @@ module IOStreams
153
172
  if signer_passphrase
154
173
  args += ["--pinentry-mode", "loopback"] if IOStreams::Pgp.pgp_version.to_f >= 2.1
155
174
  args << "--no-symkey-cache" if IOStreams::Pgp.pgp_version.to_f >= 2.4
156
- args += ["--passphrase", signer_passphrase.to_s]
175
+ args += ["--passphrase-fd", PASSPHRASE_FD.to_s]
157
176
  end
158
177
  args += ["-z", compress_level.to_s] if compress_level != 6
159
178
  args += ["--compress-algo", compress.to_s] unless compress == :none
@@ -168,7 +187,8 @@ module IOStreams
168
187
  recipients << audit_recipient if audit_recipient
169
188
 
170
189
  Array(import_and_trust_key).each do |key|
171
- recipients << IOStreams::Pgp.import_and_trust(key: key, trust_level: import_and_trust_level)
190
+ # Encrypt to the imported key's fingerprint, since its email address could match another key in the keyring.
191
+ recipients << IOStreams::Pgp.import_and_trust_recipient(key: key, trust_level: import_and_trust_level)
172
192
  end
173
193
  recipients
174
194
  end
@@ -160,13 +160,14 @@ module IOStreams
160
160
  end
161
161
  end
162
162
 
163
- # Delete all private and public keys for a particular email.
163
+ # Delete all private and public keys for a particular email or key id.
164
164
  #
165
165
  # Returns false if no key was found.
166
166
  # Raises an exception if it fails to delete the key.
167
+ # Raises ArgumentError when neither :email nor :key_id is supplied.
167
168
  #
168
- # email: [String] Optional email address for the key.
169
- # key_id: [String] Optional id for the key.
169
+ # email: [String] Email address for the key.
170
+ # key_id: [String] Id for the key.
170
171
  #
171
172
  # public: [true|false]
172
173
  # Whether to delete the public key
@@ -176,6 +177,8 @@ module IOStreams
176
177
  # Whether to delete the private key
177
178
  # Default: false
178
179
  def self.delete_keys(email: nil, key_id: nil, public: true, private: false)
180
+ raise(ArgumentError, "Either :email, or :key_id must be supplied") if email.nil? && key_id.nil?
181
+
179
182
  version_check
180
183
  # Version 2.1+ uses delete_public_or_private_keys
181
184
  # Version < 2.1 uses delete_public_or_private_keys_v1
@@ -207,7 +210,8 @@ module IOStreams
207
210
  def self.list_keys(email: nil, key_id: nil, private: false)
208
211
  version_check
209
212
  args = [private ? "--list-secret-keys" : "--list-keys"]
210
- args << (email || key_id).to_s if email || key_id
213
+ # `--` stops gpg from treating the email or key id as an option.
214
+ args += ["--", (email || key_id).to_s] if email || key_id
211
215
  command = gpg_command(*args)
212
216
 
213
217
  out, err, status = Open3.capture3(*command, binmode: true)
@@ -255,14 +259,26 @@ module IOStreams
255
259
  parse_list_output(out)
256
260
  end
257
261
 
258
- # Returns [String] containing all the public keys for the supplied email address.
262
+ # Returns [String] containing all the public keys for the supplied email address or key id.
263
+ #
264
+ # Raises ArgumentError when neither :email nor :key_id is supplied.
265
+ # Raises Pgp::Failure when `email: nil` is supplied without a :key_id, as before :key_id was added.
266
+ # In v3.0 this will raise ArgumentError instead.
259
267
  #
260
268
  # email: [String] Email address for requested key.
261
269
  #
270
+ # key_id: [String] Id for the requested key.
271
+ #
262
272
  # ascii: [true|false]
263
273
  # Whether to export as ASCII text instead of binary format
264
274
  # Default: true
265
- def self.export(email:, ascii: true, private: false, passphrase: nil)
275
+ def self.export(email: (email_omitted = true) && nil, key_id: nil, ascii: true, private: false, passphrase: nil)
276
+ if email.nil? && key_id.nil?
277
+ raise(ArgumentError, "Either :email, or :key_id must be supplied") if email_omitted
278
+
279
+ raise(Pgp::Failure, "GPG Failed reading key: no email address or key id was supplied")
280
+ end
281
+
266
282
  version_check
267
283
 
268
284
  args = []
@@ -270,15 +286,16 @@ module IOStreams
270
286
  args << "--no-symkey-cache" if pgp_version.to_f >= 2.4
271
287
  args << "--armor" if ascii
272
288
  args += ["--no-tty", "--batch"]
273
- args += passphrase ? ["--passphrase", passphrase] : ["--passphrase-fd", "0"]
274
- args += private ? ["--export-secret-keys", email.to_s] : ["--export", email.to_s]
289
+ # Supply the passphrase on stdin so that it is not visible in the process list.
290
+ args += ["--passphrase-fd", "0"]
291
+ args << (private ? "--export-secret-keys" : "--export")
292
+ args += ["--", (email || key_id).to_s]
275
293
  command = gpg_command(*args)
276
294
 
277
- out, err, status = Open3.capture3(*command, binmode: true)
278
- # Do not log the command, it may contain the passphrase.
279
- IOStreams.logger&.debug { "IOStreams::Pgp.export: #{email}\n#{err}" }
295
+ out, err, status = Open3.capture3(*command, binmode: true, stdin_data: passphrase.to_s)
296
+ IOStreams.logger&.debug { "IOStreams::Pgp.export: #{command.shelljoin}\n#{err}" }
280
297
 
281
- raise(Pgp::Failure, "GPG Failed reading key: #{email}: #{err}") unless status.success? && out.length.positive?
298
+ raise(Pgp::Failure, "GPG Failed reading key: #{email || key_id}: #{err}") unless status.success? && out.length.positive?
282
299
 
283
300
  out
284
301
  end
@@ -424,18 +441,47 @@ module IOStreams
424
441
  # Notes:
425
442
  # - If the same email address has multiple keys then only the first is currently trusted.
426
443
  def self.import_and_trust(key:, trust_level: 5)
444
+ info = import_and_trust_key_info(key: key, trust_level: trust_level)
445
+ info[:email] || info[:key_id]
446
+ end
447
+
448
+ # Imports and trusts the supplied key, see #import_and_trust.
449
+ #
450
+ # Returns [String] the recipient to encrypt to: the key's fingerprint when gpg supplies it,
451
+ # otherwise the email address or key id returned by #import_and_trust.
452
+ #
453
+ # Encrypting to the fingerprint ensures that the imported key is used, since gpg looks up an
454
+ # email address in the keyring, where another key may have the same email address.
455
+ #
456
+ # Used internally by the PGP writer.
457
+ def self.import_and_trust_recipient(key:, trust_level: 5)
458
+ info = import_and_trust_key_info(key: key, trust_level: trust_level)
459
+ key_id = info[:key_id].to_s
460
+ # gpg v2.1 and later supply the full fingerprint: 40 hex digits for v4 keys, 64 for v5 and v6 keys.
461
+ # Earlier versions only supply a short key id, which is not unique.
462
+ return key_id if key_id.match?(/\A(\h{40}|\h{64})\z/)
463
+
464
+ info[:email] || info[:key_id]
465
+ end
466
+
467
+ # Returns [Hash] the key info for the supplied key, after importing and trusting it.
468
+ def self.import_and_trust_key_info(key:, trust_level:)
427
469
  raise(ArgumentError, "Key cannot be empty") if key.nil? || (key == "")
428
470
 
429
- key_info = key_info(key: key).last
471
+ infos = key_info(key: key)
472
+ info = infos.last&.dup || {}
473
+ # When a key has several user ids, only the entry for its first user id includes the key id.
474
+ info[:key_id] ||= infos.reverse_each.find { |entry| entry[:key_id] }&.fetch(:key_id)
430
475
 
431
- email = key_info.fetch(:email, nil)
432
- key_id = key_info.fetch(:key_id, nil)
476
+ email = info[:email]
477
+ key_id = info[:key_id]
433
478
  raise(ArgumentError, "Recipient email or key id cannot be extracted from supplied key") unless email || key_id
434
479
 
435
480
  import(key: key)
436
481
  set_trust(email: email, key_id: key_id, level: trust_level)
437
- email || key_id
482
+ info
438
483
  end
484
+ private_class_method :import_and_trust_key_info
439
485
 
440
486
  # Set the trust level for an existing key.
441
487
  #
@@ -444,6 +490,10 @@ module IOStreams
444
490
  #
445
491
  # After importing keys, they are not trusted and the relevant trust level must be set.
446
492
  #
493
+ # key_id: [String]
494
+ # The fingerprint of the key, as hexadecimal digits only.
495
+ # Raises ArgumentError when it contains any other characters.
496
+ #
447
497
  # level: [Integer]
448
498
  # The owner-trust level to assign to the key:
449
499
  # 1 : Undefined (no opinion)
@@ -461,6 +511,12 @@ module IOStreams
461
511
  # key at this level allows that attacker to impersonate other recipients.
462
512
  # When the key cannot be fully verified, supply a lower `level`.
463
513
  def self.set_trust(email: nil, key_id: nil, level: 5)
514
+ # The key_id is written into gpg's ownertrust input, where any other character, such as a newline,
515
+ # could add trust lines for other keys.
516
+ if key_id && !key_id.to_s.match?(/\A\h+\z/)
517
+ raise(ArgumentError, "Invalid :key_id, it must only contain hexadecimal digits: #{key_id.inspect}")
518
+ end
519
+
464
520
  version_check
465
521
  fingerprint = key_id || fingerprint(email: email)
466
522
  return unless fingerprint
@@ -479,7 +535,7 @@ module IOStreams
479
535
  # Public callers should identify keys by `key_id` (see #list_keys / #key_info).
480
536
  def self.fingerprint(email:)
481
537
  version_check
482
- command = gpg_command("--list-keys", "--fingerprint", "--with-colons", email.to_s)
538
+ command = gpg_command("--list-keys", "--fingerprint", "--with-colons", "--", email.to_s)
483
539
  Open3.popen2e(*command) do |_stdin, out, waith_thr|
484
540
  output = out.read.chomp
485
541
  if !waith_thr.value.success? && output !~ /(public key not found|No public key)/i
@@ -609,10 +665,11 @@ module IOStreams
609
665
  hash[:trust] = match[2].to_s.strip if match[1]
610
666
  results << hash
611
667
  hash = {}
612
- elsif (match = line.match(/\s+([A-Z0-9]{16,40})/))
668
+ elsif (match = line.match(/\s+([A-Z0-9]{16,64})/))
613
669
  # v2.2/v2.4 key id on separate line:
614
670
  # 18A0FC1C09C0D8AE34CE659257DC4AE323C7368C
615
671
  # Or shorter format: 7932AB23D7238F6B
672
+ # Or a 64 digit fingerprint for v5 and v6 keys.
616
673
  hash[:key_id] ||= match[1]
617
674
  end
618
675
  end
@@ -636,7 +693,7 @@ module IOStreams
636
693
  key_id = key_info[:key_id]
637
694
  next unless key_id
638
695
 
639
- command = gpg_command("--batch", "--no-tty", "--yes", "--delete-#{keys}", key_id)
696
+ command = gpg_command("--batch", "--no-tty", "--yes", "--delete-#{keys}", "--", key_id)
640
697
  out, err, status = Open3.capture3(*command, binmode: true)
641
698
  IOStreams.logger&.debug { "IOStreams::Pgp.delete_keys: #{command.shelljoin}\n#{err}#{out}" }
642
699
 
@@ -653,7 +710,7 @@ module IOStreams
653
710
 
654
711
  # List the fingerprints, then delete each one. Previously this shelled out
655
712
  # to a `for` loop, which allowed shell injection via :email / :key_id.
656
- list_command = gpg_command("--list-#{keys}", "--with-colons", "--fingerprint", (email || key_id).to_s)
713
+ list_command = gpg_command("--list-#{keys}", "--with-colons", "--fingerprint", "--", (email || key_id).to_s)
657
714
  list_out, list_err, = Open3.capture3(*list_command, binmode: true)
658
715
  IOStreams.logger&.debug { "IOStreams::Pgp.delete_keys: #{list_command.shelljoin}\n#{list_err}: #{list_out}" }
659
716
 
@@ -663,7 +720,7 @@ module IOStreams
663
720
  return false if fingerprints.empty?
664
721
 
665
722
  fingerprints.each do |fingerprint|
666
- command = gpg_command("--batch", "--no-tty", "--yes", "--delete-#{keys}", fingerprint)
723
+ command = gpg_command("--batch", "--no-tty", "--yes", "--delete-#{keys}", "--", fingerprint)
667
724
  out, err, status = Open3.capture3(*command, binmode: true)
668
725
  IOStreams.logger&.debug { "IOStreams::Pgp.delete_keys: #{command.shelljoin}\n#{err}: #{out}" }
669
726
 
@@ -1,9 +1,18 @@
1
1
  module IOStreams
2
2
  class Reader
3
+ # Returns [Array<Symbol>] the names of the options this reader accepts,
4
+ # or [nil] when the reader does not declare them.
5
+ #
6
+ # When declared, `IOStreams::Builder` rejects any other option before the reader is opened,
7
+ # naming the direction an option belongs to when it is only valid for the other direction.
8
+ def self.option_names
9
+ nil
10
+ end
11
+
3
12
  # When a Reader does not support streams, we copy the stream to a local temp file
4
13
  # and then pass that filename in for this reader.
5
14
  def self.stream(input_stream, **args, &block)
6
- Utils.temp_file_name("iostreams_reader") do |file_name|
15
+ Utils.private_temp_file("iostreams_reader") do |file_name|
7
16
  ::File.open(file_name, "wb") { |target| ::IO.copy_stream(input_stream, target) }
8
17
  file(file_name, **args, &block)
9
18
  end
@@ -56,6 +56,10 @@ module IOStreams
56
56
  # #as_hash will skip these additional columns entirely as if they were not in the file at all.
57
57
  # false:
58
58
  # Raises Tabular::InvalidHeader when a column is supplied that is not in the whitelist.
59
+ #
60
+ # Note:
61
+ # * `allowed_columns`, `required_columns` and `skip_unknown` only apply to every input, including JSON records,
62
+ # supplied `columns` and `cleanse_header: false`, when `IOStreams.enforce_column_restrictions?` is true.
59
63
  def initialize(line_reader, cleanse_header: true, original_file_name: nil, **args)
60
64
  unless line_reader.respond_to?(:each)
61
65
  raise(ArgumentError, "Stream must be a IOStreams::Line::Reader or implement #each")
@@ -64,18 +68,84 @@ module IOStreams
64
68
  @tabular = IOStreams::Tabular.new(file_name: original_file_name, **args)
65
69
  @line_reader = line_reader
66
70
  @cleanse_header = cleanse_header
71
+ @warned = false
72
+
73
+ # Supplied columns take the place of a header row, so apply the allowed and required columns to them.
74
+ restrict_columns if restricted? && !@tabular.header?
67
75
  end
68
76
 
69
77
  def each
70
78
  @line_reader.each do |line|
71
79
  if @tabular.header?
72
80
  @tabular.parse_header(line)
73
- @tabular.cleanse_header! if @cleanse_header
81
+ if @cleanse_header
82
+ cleanse_columns
83
+ elsif restricted?
84
+ restrict_columns
85
+ end
74
86
  else
75
- yield @tabular.record_parse(line)
87
+ yield restrict(@tabular.record_parse(line))
76
88
  end
77
89
  end
78
90
  end
91
+
92
+ private
93
+
94
+ def restricted?
95
+ @tabular.header.restricted?
96
+ end
97
+
98
+ def cleanse_columns
99
+ @tabular.header.cleanse!(rename: @cleanse_header)
100
+ end
101
+
102
+ # Apply the allowed and required columns to supplied columns, or to a header row read with
103
+ # `cleanse_header: false`. Unless `IOStreams.enforce_column_restrictions?`, only warn when they would
104
+ # change the columns.
105
+ def restrict_columns
106
+ return cleanse_columns if IOStreams.enforce_column_restrictions?
107
+ return if @warned
108
+
109
+ header = @tabular.header
110
+ columns = header.columns
111
+ changed = changed_by_restriction? do
112
+ copy = IOStreams::Tabular::Header.new(
113
+ columns: columns,
114
+ allowed_columns: header.allowed_columns,
115
+ required_columns: header.required_columns,
116
+ skip_unknown: header.skip_unknown
117
+ )
118
+ copy.cleanse!(rename: @cleanse_header)
119
+ copy.columns != columns
120
+ end
121
+ warn_restriction if changed
122
+ end
123
+
124
+ # Formats such as JSON have no header row, so apply the allowed and required columns to each record's keys.
125
+ # Unless `IOStreams.enforce_column_restrictions?`, only warn when they would change the record.
126
+ def restrict(record)
127
+ return record unless record.is_a?(Hash) && restricted? && @tabular.header.columns.nil?
128
+ return @tabular.header.restrict_hash(record, rename: @cleanse_header) if IOStreams.enforce_column_restrictions?
129
+ return record if @warned
130
+
131
+ warn_restriction if changed_by_restriction? { @tabular.header.restrict_hash(record, rename: @cleanse_header) != record }
132
+ record
133
+ end
134
+
135
+ def changed_by_restriction?
136
+ yield
137
+ rescue IOStreams::Errors::InvalidHeader
138
+ true
139
+ end
140
+
141
+ # Warn once per reader, since the same columns usually apply to every record.
142
+ def warn_restriction
143
+ @warned = true
144
+ IOStreams.logger&.warn(
145
+ "allowed_columns and required_columns are not applied to this input, but would change the records read. " \
146
+ "In v3.0 they will apply to every input. Set `IOStreams.enforce_column_restrictions = true` to apply them now."
147
+ )
148
+ end
79
149
  end
80
150
  end
81
151
  end
@@ -87,9 +87,11 @@ module IOStreams
87
87
  # end
88
88
  #
89
89
  # Notes:
90
- # - Embedded lines (within double quotes) will be skipped if
91
- # 1. The file name contains .csv
92
- # 2. Or the embedded_within argument is set
90
+ # - Newlines embedded within quoted fields are kept within the same line when
91
+ # 1. The resolved tabular format quotes its fields (e.g. CSV, whether detected from a
92
+ # `.csv` file name or set explicitly via `.format(:csv)`)
93
+ # 2. Or the `embedded_within` argument is supplied (e.g. `embedded_within: '"'`)
94
+ # - Pass `embedded_within: nil` to disable quote-aware line joining for a quoted format.
93
95
  def each(mode = :line, **args, &block)
94
96
  raise(ArgumentError, "Invalid mode: #{mode.inspect}") if mode == :stream
95
97
 
@@ -331,8 +333,11 @@ module IOStreams
331
333
  builder.reader(io_stream, &)
332
334
  end
333
335
 
334
- def line_reader(embedded_within: nil, **args)
335
- embedded_within = '"' if embedded_within.nil? && builder.file_name&.include?(".csv")
336
+ def line_reader(embedded_within: :auto, **args)
337
+ # `:auto` defers the decision to the resolved tabular format (e.g. CSV quotes with `"`),
338
+ # while distinguishing "not supplied" from an explicit value such as `nil` (disable) or
339
+ # `'"'` (force). Centralizing this in the builder keeps all format-based decisions there.
340
+ embedded_within = builder.quote_character if embedded_within == :auto
336
341
 
337
342
  stream_reader do |io|
338
343
  yield IOStreams::Line::Reader.new(
@@ -344,7 +349,7 @@ module IOStreams
344
349
  end
345
350
 
346
351
  # Iterate over a file / stream returning each line as an array, one at a time.
347
- def row_reader(delimiter: nil, embedded_within: nil, **args)
352
+ def row_reader(delimiter: nil, embedded_within: :auto, **args)
348
353
  line_reader(delimiter: delimiter, embedded_within: embedded_within) do |io|
349
354
  yield IOStreams::Row::Reader.new(
350
355
  io,
@@ -357,7 +362,7 @@ module IOStreams
357
362
  end
358
363
 
359
364
  # Iterate over a file / stream returning each line as a hash, one at a time.
360
- def record_reader(delimiter: nil, embedded_within: nil, **args)
365
+ def record_reader(delimiter: nil, embedded_within: :auto, **args)
361
366
  line_reader(delimiter: delimiter, embedded_within: embedded_within) do |io|
362
367
  yield IOStreams::Record::Reader.new(
363
368
  io,
@@ -1,6 +1,10 @@
1
1
  module IOStreams
2
2
  module SymmetricEncryption
3
3
  class Reader < IOStreams::Reader
4
+ def self.option_names
5
+ %i[buffer_size version]
6
+ end
7
+
4
8
  # read from a file/stream using Symmetric Encryption
5
9
  def self.stream(input_stream, **args, &)
6
10
  Utils.load_soft_dependency("symmetric-encryption", ".enc streaming") unless defined?(SymmetricEncryption)
@@ -1,6 +1,10 @@
1
1
  module IOStreams
2
2
  module SymmetricEncryption
3
3
  class Writer < IOStreams::Writer
4
+ def self.option_names
5
+ %i[compress version cipher_name header random_key random_iv]
6
+ end
7
+
4
8
  # Write to stream using Symmetric Encryption
5
9
  # By default the output stream is compressed.
6
10
  # If the input_stream is already compressed consider setting compress: false.
@@ -52,16 +52,22 @@ module IOStreams
52
52
  # - Spaces and '-' are converted to '_'.
53
53
  # - All characters except for letters, digits, and '_' are stripped.
54
54
  #
55
+ # Parameters:
56
+ # rename [true|false]
57
+ # Whether to cleanse the column names as described above.
58
+ # When false, the column names are compared to `allowed_columns` and `required_columns` as-is.
59
+ # Default: true
60
+ #
55
61
  # Notes:
56
62
  # * So that rejected columns can be identified in subsequent steps, they will be prefixed with `__rejected__`.
57
63
  # For example, `Unknown Column` would be cleansed as `__rejected__Unknown Column`.
58
64
  # * Raises Tabular::InvalidHeader when there are no rejected columns left after cleansing.
59
- def cleanse!
65
+ def cleanse!(rename: true)
60
66
  return [] if columns.nil? || columns.empty?
61
67
 
62
68
  ignored_columns = []
63
69
  self.columns = columns.collect do |column|
64
- cleansed = cleanse_column(column)
70
+ cleansed = rename ? cleanse_column(column) : column
65
71
  if allowed_columns.nil? || allowed_columns.include?(cleansed)
66
72
  cleansed
67
73
  else
@@ -112,6 +118,26 @@ module IOStreams
112
118
  end
113
119
  end
114
120
 
121
+ # Returns [true|false] whether `allowed_columns` or `required_columns` restrict the columns.
122
+ def restricted?
123
+ !allowed_columns.nil? || !required_columns.nil?
124
+ end
125
+
126
+ # Returns [Hash] the supplied hash after applying `allowed_columns`, `required_columns` and `skip_unknown`
127
+ # to its keys, as if its keys were the header row.
128
+ #
129
+ # Used for formats such as JSON where each record supplies its own keys instead of a header row.
130
+ def restrict_hash(hash, rename: true)
131
+ header = self.class.new(
132
+ columns: hash.keys,
133
+ allowed_columns: allowed_columns,
134
+ required_columns: required_columns,
135
+ skip_unknown: skip_unknown
136
+ )
137
+ header.cleanse!(rename: rename)
138
+ header.to_hash(hash.values)
139
+ end
140
+
115
141
  def to_array(row, cleanse = true)
116
142
  if row.is_a?(Hash) && columns
117
143
  row = cleanse_hash(row) if cleanse
@@ -142,12 +168,13 @@ module IOStreams
142
168
  # For example, avoids issues with case etc.
143
169
  def cleanse_hash(hash)
144
170
  hash = hash.transform_keys(&:to_s) unless hash.keys.all?(String)
145
- unmatched = columns - hash.keys
171
+ allowed = columns.reject { |column| column.start_with?(IGNORE_PREFIX) }
172
+ unmatched = allowed - hash.keys
146
173
  unless unmatched.empty?
147
174
  hash = hash.dup
148
175
  unmatched.each { |name| hash[cleanse_column(name)] = hash.delete(name) }
149
176
  end
150
- hash.slice(*columns)
177
+ hash.slice(*allowed)
151
178
  end
152
179
 
153
180
  def cleanse_column(name)
@@ -2,6 +2,16 @@ module IOStreams
2
2
  class Tabular
3
3
  module Parser
4
4
  class Base
5
+ # Returns [String] the quote character within which field delimiters and embedded
6
+ # newlines may appear for this format, or [nil] when the format has no such quoting.
7
+ #
8
+ # Used by the line reader to avoid treating a newline as a line ending when it is
9
+ # embedded within a quoted field (e.g. CSV). Defined at the class level since it is a
10
+ # static property of the format, independent of any per-instance format options.
11
+ def self.quote_character
12
+ nil
13
+ end
14
+
5
15
  # Returns [true|false] whether a header row is required for this format.
6
16
  def requires_header?
7
17
  true
@@ -3,6 +3,11 @@ module IOStreams
3
3
  class Tabular
4
4
  module Parser
5
5
  class Csv < Base
6
+ # CSV fields may contain embedded delimiters and newlines when wrapped in double quotes.
7
+ def self.quote_character
8
+ '"'
9
+ end
10
+
6
11
  # Returns [Array] the parsed CSV line
7
12
  def parse(row)
8
13
  return row if row.is_a?(::Array)
@@ -143,6 +143,7 @@ module IOStreams
143
143
 
144
144
  class Column
145
145
  TYPES = %i[string integer float].freeze
146
+ LINE_BREAK = /\r\n|\r|\n/
146
147
 
147
148
  attr_reader :key, :size, :type, :decimals
148
149
 
@@ -179,7 +180,8 @@ module IOStreams
179
180
  formatted =
180
181
  case type
181
182
  when :string
182
- value = value.to_s
183
+ # Replace line breaks with a space so that a value cannot add records.
184
+ value = value.to_s.gsub(LINE_BREAK, " ")
183
185
  return value if size == -1
184
186
 
185
187
  format(truncate ? "%-#{size}.#{size}s" : "%-#{size}s", value)