iostreams 1.11.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 +14 -13
- data/Rakefile +52 -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 +71 -11
- 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 +6 -2
- data/lib/io_streams/encode/writer.rb +9 -5
- data/lib/io_streams/errors.rb +4 -0
- data/lib/io_streams/gzip/reader.rb +5 -1
- data/lib/io_streams/gzip/writer.rb +11 -2
- data/lib/io_streams/io_streams.rb +156 -20
- data/lib/io_streams/line/reader.rb +9 -4
- data/lib/io_streams/line/writer.rb +1 -1
- data/lib/io_streams/path.rb +117 -8
- data/lib/io_streams/paths/file.rb +57 -11
- data/lib/io_streams/paths/http.rb +123 -9
- data/lib/io_streams/paths/matcher.rb +3 -3
- data/lib/io_streams/paths/s3.rb +69 -18
- data/lib/io_streams/paths/sftp/net_ssh.rb +104 -0
- data/lib/io_streams/paths/sftp.rb +103 -64
- data/lib/io_streams/pgp/reader.rb +63 -10
- data/lib/io_streams/pgp/writer.rb +111 -30
- data/lib/io_streams/pgp.rb +256 -71
- data/lib/io_streams/reader.rb +14 -5
- data/lib/io_streams/record/reader.rb +75 -6
- data/lib/io_streams/record/writer.rb +3 -4
- data/lib/io_streams/row/reader.rb +1 -1
- data/lib/io_streams/row/writer.rb +1 -1
- data/lib/io_streams/stream.rb +48 -37
- data/lib/io_streams/symmetric_encryption/reader.rb +6 -2
- data/lib/io_streams/symmetric_encryption/writer.rb +8 -4
- data/lib/io_streams/tabular/header.rb +49 -10
- data/lib/io_streams/tabular/parser/array.rb +0 -10
- data/lib/io_streams/tabular/parser/base.rb +10 -0
- data/lib/io_streams/tabular/parser/csv.rb +9 -36
- data/lib/io_streams/tabular/parser/fixed.rb +8 -6
- data/lib/io_streams/tabular/parser/psv.rb +6 -14
- data/lib/io_streams/tabular.rb +5 -10
- data/lib/io_streams/utils.rb +34 -2
- data/lib/io_streams/version.rb +1 -1
- data/lib/io_streams/writer.rb +16 -7
- data/lib/io_streams/xlsx/reader.rb +6 -2
- data/lib/io_streams/zip/reader.rb +4 -0
- data/lib/io_streams/zip/writer.rb +26 -10
- data/lib/iostreams.rb +0 -1
- metadata +46 -112
- data/lib/io_streams/deprecated.rb +0 -216
- data/lib/io_streams/tabular/utility/csv_row.rb +0 -105
- data/test/builder_test.rb +0 -311
- data/test/bzip2_reader_test.rb +0 -27
- data/test/bzip2_writer_test.rb +0 -56
- data/test/deprecated_test.rb +0 -121
- data/test/encode_reader_test.rb +0 -51
- data/test/encode_writer_test.rb +0 -90
- data/test/files/embedded_lines_test.csv +0 -7
- data/test/files/multiple_files.zip +0 -0
- data/test/files/spreadsheet.xlsx +0 -0
- data/test/files/test.csv +0 -4
- data/test/files/test.json +0 -3
- data/test/files/test.psv +0 -4
- data/test/files/text file.txt +0 -3
- data/test/files/text.txt +0 -3
- data/test/files/text.txt.bz2 +0 -0
- data/test/files/text.txt.gz +0 -0
- data/test/files/text.txt.gz.zip +0 -0
- data/test/files/text.zip +0 -0
- data/test/files/text.zip.gz +0 -0
- data/test/files/unclosed_quote_large_test.csv +0 -1658
- data/test/files/unclosed_quote_test.csv +0 -4
- data/test/files/unclosed_quote_test2.csv +0 -3
- data/test/gzip_reader_test.rb +0 -27
- data/test/gzip_writer_test.rb +0 -52
- data/test/io_streams_test.rb +0 -132
- data/test/line_reader_test.rb +0 -325
- data/test/line_writer_test.rb +0 -59
- data/test/minimal_file_reader.rb +0 -25
- data/test/path_test.rb +0 -55
- data/test/paths/file_test.rb +0 -213
- data/test/paths/http_test.rb +0 -34
- data/test/paths/matcher_test.rb +0 -120
- data/test/paths/s3_test.rb +0 -220
- data/test/paths/sftp_test.rb +0 -106
- data/test/pgp_reader_test.rb +0 -46
- data/test/pgp_test.rb +0 -267
- data/test/pgp_writer_test.rb +0 -130
- data/test/record_reader_test.rb +0 -60
- data/test/record_writer_test.rb +0 -82
- data/test/row_reader_test.rb +0 -35
- data/test/row_writer_test.rb +0 -56
- data/test/stream_test.rb +0 -577
- data/test/tabular_test.rb +0 -338
- data/test/test_helper.rb +0 -40
- data/test/utils_test.rb +0 -20
- data/test/xlsx_reader_test.rb +0 -37
- data/test/zip_reader_test.rb +0 -53
- data/test/zip_writer_test.rb +0 -48
|
@@ -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,27 +20,59 @@ 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
|
#
|
|
22
35
|
# passphrase: [String]
|
|
23
|
-
# Pass phrase for private key to decrypt the file with
|
|
24
|
-
|
|
36
|
+
# Pass phrase for private key to decrypt the file with.
|
|
37
|
+
# Not required when the file is signed but not encrypted.
|
|
38
|
+
#
|
|
39
|
+
# ignore_mdc_error: [true|false]
|
|
40
|
+
# Decrypt files that lack MDC (Modification Detection Code) integrity protection.
|
|
41
|
+
# Some legacy/enterprise systems (e.g. Workday) still produce such files, which
|
|
42
|
+
# modern GnuPG refuses to decrypt with `gpg: decryption forced to fail!`.
|
|
43
|
+
# Only enable this for files from a trusted source: without MDC the decrypted
|
|
44
|
+
# contents are not protected against tampering.
|
|
45
|
+
# Default: 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, &)
|
|
25
55
|
# Cannot use `passphrase: self.default_passphrase` since it is considered private
|
|
26
56
|
passphrase ||= default_passphrase
|
|
27
|
-
raise(ArgumentError, "Missing both passphrase and IOStreams::Pgp::Reader.default_passphrase") unless passphrase
|
|
28
57
|
|
|
58
|
+
args = []
|
|
29
59
|
# Use --pinentry-mode loopback for all GnuPG versions >= 2.1
|
|
30
|
-
loopback
|
|
31
|
-
|
|
60
|
+
args += ["--pinentry-mode", "loopback"] if IOStreams::Pgp.pgp_version.to_f >= 2.1
|
|
32
61
|
# Use --no-symkey-cache for GnuPG versions >= 2.4 to avoid caching session keys
|
|
33
|
-
|
|
62
|
+
args << "--no-symkey-cache" if IOStreams::Pgp.pgp_version.to_f >= 2.4
|
|
63
|
+
args << "--ignore-mdc-error" if ignore_mdc_error
|
|
64
|
+
args += ["--batch", "--no-tty", "--yes", "--decrypt"]
|
|
65
|
+
# Only feed a passphrase when one is supplied; sign-only files need none.
|
|
66
|
+
args += ["--passphrase-fd", "0"] if passphrase
|
|
67
|
+
args += ["--", file_name.to_s]
|
|
34
68
|
|
|
35
|
-
command
|
|
36
|
-
IOStreams
|
|
69
|
+
command = IOStreams::Pgp.gpg_command(*args)
|
|
70
|
+
IOStreams.logger&.debug { "IOStreams::Pgp::Reader.open: #{command.shelljoin}" }
|
|
71
|
+
|
|
72
|
+
return decrypt_then_read(command, file_name, passphrase, &) if verify_first
|
|
37
73
|
|
|
38
74
|
# Read decrypted contents from stdout
|
|
39
|
-
Open3.popen3(command) do |stdin, stdout, stderr, waith_thr|
|
|
75
|
+
Open3.popen3(*command) do |stdin, stdout, stderr, waith_thr|
|
|
40
76
|
stdin.puts(passphrase) if passphrase
|
|
41
77
|
stdin.close
|
|
42
78
|
result =
|
|
@@ -52,6 +88,23 @@ module IOStreams
|
|
|
52
88
|
result
|
|
53
89
|
end
|
|
54
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
|
|
55
108
|
end
|
|
56
109
|
end
|
|
57
|
-
end
|
|
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.
|
|
@@ -26,18 +34,42 @@ module IOStreams
|
|
|
26
34
|
@audit_recipient = nil
|
|
27
35
|
end
|
|
28
36
|
|
|
29
|
-
# Write to a PGP / GPG file, encrypting the contents as it is written.
|
|
37
|
+
# Write to a PGP / GPG file, encrypting and/or signing the contents as it is written.
|
|
30
38
|
#
|
|
31
39
|
# file_name: [String]
|
|
32
40
|
# Name of file to write to.
|
|
33
41
|
#
|
|
42
|
+
# encrypt: [true|false]
|
|
43
|
+
# Whether to encrypt the file for the supplied recipient(s).
|
|
44
|
+
# When set to false the file is signed but not encrypted, in which case a
|
|
45
|
+
# :signer must be supplied and :recipient / :import_and_trust_key are ignored.
|
|
46
|
+
# Default: true
|
|
47
|
+
#
|
|
34
48
|
# recipient: [String|Array<String>]
|
|
35
49
|
# One or more emails of users for which to encrypt the file.
|
|
50
|
+
# Ignored when encrypt is false.
|
|
36
51
|
#
|
|
37
52
|
# import_and_trust_key: [String|Array<String>]
|
|
38
53
|
# One or more pgp keys to import and then use to encrypt the file.
|
|
39
54
|
# Note: Ascii Keys can contain multiple keys, only the last one in the file is used.
|
|
40
55
|
#
|
|
56
|
+
# import_and_trust_level: [Integer]
|
|
57
|
+
# The owner-trust level to assign to keys supplied via :import_and_trust_key.
|
|
58
|
+
# 1 : Undefined (no opinion)
|
|
59
|
+
# 2 : Never (do not trust)
|
|
60
|
+
# 3 : Marginal
|
|
61
|
+
# 4 : Full
|
|
62
|
+
# 5 : Ultimate
|
|
63
|
+
# Default: 5 : Ultimate
|
|
64
|
+
#
|
|
65
|
+
# SECURITY WARNING:
|
|
66
|
+
# Only import and trust keys received from a verified, trusted source.
|
|
67
|
+
# The default trust level is `5` (Ultimate), which tells GPG to treat the imported key
|
|
68
|
+
# as if it were one of your own keys. An ultimately trusted key is implicitly valid and
|
|
69
|
+
# can in turn confer validity on other keys it has signed. Importing an attacker supplied
|
|
70
|
+
# key at this level allows that attacker to impersonate other recipients.
|
|
71
|
+
# When the key cannot be fully verified, supply a lower `import_and_trust_level`.
|
|
72
|
+
#
|
|
41
73
|
# signer: [String]
|
|
42
74
|
# Name of user with which to sign the encypted file.
|
|
43
75
|
# Default: default_signer or do not sign.
|
|
@@ -55,63 +87,112 @@ module IOStreams
|
|
|
55
87
|
# compress_level: [Integer]
|
|
56
88
|
# Compression level
|
|
57
89
|
# Default: 6
|
|
90
|
+
#
|
|
91
|
+
# Note: There is intentionally no option here to disable MDC (Modification Detection
|
|
92
|
+
# Code) integrity protection on the files we produce. The reader exposes
|
|
93
|
+
# `ignore_mdc_error:` so we can *consume* legacy files that lack MDC (see Reader),
|
|
94
|
+
# but we never want to *generate* them: MDC is what protects the encrypted contents
|
|
95
|
+
# against tampering, and modern GnuPG mandates it for current ciphers anyway
|
|
96
|
+
# (`--disable-mdc` is a no-op unless an obsolete cipher is forced). Omitting MDC on
|
|
97
|
+
# output would only weaken files we create, with no upside for this library.
|
|
58
98
|
def self.file(file_name,
|
|
99
|
+
encrypt: true,
|
|
59
100
|
recipient: nil,
|
|
60
101
|
import_and_trust_key: nil,
|
|
102
|
+
import_and_trust_level: 5,
|
|
61
103
|
signer: default_signer,
|
|
62
104
|
signer_passphrase: default_signer_passphrase,
|
|
63
105
|
compress: :zip,
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
# Backward compatibility
|
|
71
|
-
compress = compression if compression
|
|
106
|
+
compress_level: 6)
|
|
107
|
+
if encrypt
|
|
108
|
+
raise(ArgumentError, "Requires either :recipient or :import_and_trust_key") unless recipient || import_and_trust_key
|
|
109
|
+
elsif !signer
|
|
110
|
+
raise(ArgumentError, "Requires a :signer when encrypt is false")
|
|
111
|
+
end
|
|
72
112
|
|
|
73
113
|
compress_level = 0 if compress == :none
|
|
74
114
|
|
|
75
|
-
recipients =
|
|
76
|
-
|
|
115
|
+
recipients =
|
|
116
|
+
if encrypt
|
|
117
|
+
collect_recipients(recipient, import_and_trust_key, import_and_trust_level)
|
|
118
|
+
else
|
|
119
|
+
[]
|
|
120
|
+
end
|
|
77
121
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
122
|
+
# Write to stdin, with the encrypted and/or signed contents being written to the file
|
|
123
|
+
args = build_args(
|
|
124
|
+
file_name: file_name,
|
|
125
|
+
encrypt: encrypt,
|
|
126
|
+
signer: signer,
|
|
127
|
+
signer_passphrase: signer_passphrase,
|
|
128
|
+
compress: compress,
|
|
129
|
+
compress_level: compress_level,
|
|
130
|
+
recipients: recipients
|
|
131
|
+
)
|
|
132
|
+
command = IOStreams::Pgp.gpg_command(*args)
|
|
133
|
+
IOStreams.logger&.debug { "IOStreams::Pgp::Writer.open: #{command.shelljoin}" }
|
|
81
134
|
|
|
82
|
-
#
|
|
83
|
-
|
|
84
|
-
|
|
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 = {}
|
|
85
138
|
if signer_passphrase
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
89
143
|
end
|
|
90
|
-
command << " -z #{compress_level}" if compress_level != 6
|
|
91
|
-
command << " --compress-algo #{compress}" unless compress == :none
|
|
92
|
-
recipients.each { |address| command << " --recipient \"#{address}\"" }
|
|
93
|
-
command << " -o \"#{file_name}\""
|
|
94
|
-
|
|
95
|
-
IOStreams::Pgp.logger&.debug { "IOStreams::Pgp::Writer.open: #{command}" }
|
|
96
144
|
|
|
97
145
|
result = nil
|
|
98
|
-
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
|
|
99
149
|
begin
|
|
100
150
|
stdin.binmode
|
|
101
151
|
result = yield(stdin)
|
|
102
152
|
stdin.close
|
|
103
153
|
rescue Errno::EPIPE
|
|
104
154
|
# Ignore broken pipe because gpg terminates early due to an error
|
|
105
|
-
::
|
|
155
|
+
::FileUtils.rm_f(file_name)
|
|
106
156
|
raise(Pgp::Failure, "GPG Failed writing to encrypted file: #{file_name}: #{out.read.chomp}")
|
|
107
157
|
end
|
|
108
158
|
unless waith_thr.value.success?
|
|
109
|
-
::
|
|
159
|
+
::FileUtils.rm_f(file_name)
|
|
110
160
|
raise(Pgp::Failure, "GPG Failed to create encrypted file: #{file_name}: #{out.read.chomp}")
|
|
111
161
|
end
|
|
112
162
|
end
|
|
113
163
|
result
|
|
164
|
+
ensure
|
|
165
|
+
passphrase_reader&.close
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def self.build_args(file_name:, encrypt:, signer:, signer_passphrase:, compress:, compress_level:, recipients:)
|
|
169
|
+
args = ["--batch", "--no-tty", "--yes"]
|
|
170
|
+
args << "--encrypt" if encrypt
|
|
171
|
+
args += ["--sign", "--local-user", signer.to_s] if signer
|
|
172
|
+
if signer_passphrase
|
|
173
|
+
args += ["--pinentry-mode", "loopback"] if IOStreams::Pgp.pgp_version.to_f >= 2.1
|
|
174
|
+
args << "--no-symkey-cache" if IOStreams::Pgp.pgp_version.to_f >= 2.4
|
|
175
|
+
args += ["--passphrase-fd", PASSPHRASE_FD.to_s]
|
|
176
|
+
end
|
|
177
|
+
args += ["-z", compress_level.to_s] if compress_level != 6
|
|
178
|
+
args += ["--compress-algo", compress.to_s] unless compress == :none
|
|
179
|
+
recipients.each { |address| args += ["--recipient", address.to_s] }
|
|
180
|
+
args += ["-o", file_name.to_s]
|
|
181
|
+
args
|
|
182
|
+
end
|
|
183
|
+
private_class_method :build_args
|
|
184
|
+
|
|
185
|
+
def self.collect_recipients(recipient, import_and_trust_key, import_and_trust_level)
|
|
186
|
+
recipients = Array(recipient)
|
|
187
|
+
recipients << audit_recipient if audit_recipient
|
|
188
|
+
|
|
189
|
+
Array(import_and_trust_key).each do |key|
|
|
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)
|
|
192
|
+
end
|
|
193
|
+
recipients
|
|
114
194
|
end
|
|
195
|
+
private_class_method :collect_recipients
|
|
115
196
|
end
|
|
116
197
|
end
|
|
117
|
-
end
|
|
198
|
+
end
|