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
|
@@ -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
|
-
|
|
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
|
|
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
|
-
#
|
|
127
|
-
|
|
128
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
data/lib/io_streams/pgp.rb
CHANGED
|
@@ -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]
|
|
169
|
-
# key_id: [String]
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
274
|
-
args +=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
432
|
-
key_id =
|
|
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
|
-
|
|
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,
|
|
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
|
|
data/lib/io_streams/reader.rb
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
data/lib/io_streams/stream.rb
CHANGED
|
@@ -87,9 +87,11 @@ module IOStreams
|
|
|
87
87
|
# end
|
|
88
88
|
#
|
|
89
89
|
# Notes:
|
|
90
|
-
# -
|
|
91
|
-
# 1. The
|
|
92
|
-
#
|
|
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:
|
|
335
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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(*
|
|
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
|
|
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)
|