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.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +14 -13
  3. data/Rakefile +52 -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 +71 -11
  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 +6 -2
  19. data/lib/io_streams/encode/writer.rb +9 -5
  20. data/lib/io_streams/errors.rb +4 -0
  21. data/lib/io_streams/gzip/reader.rb +5 -1
  22. data/lib/io_streams/gzip/writer.rb +11 -2
  23. data/lib/io_streams/io_streams.rb +156 -20
  24. data/lib/io_streams/line/reader.rb +9 -4
  25. data/lib/io_streams/line/writer.rb +1 -1
  26. data/lib/io_streams/path.rb +117 -8
  27. data/lib/io_streams/paths/file.rb +57 -11
  28. data/lib/io_streams/paths/http.rb +123 -9
  29. data/lib/io_streams/paths/matcher.rb +3 -3
  30. data/lib/io_streams/paths/s3.rb +69 -18
  31. data/lib/io_streams/paths/sftp/net_ssh.rb +104 -0
  32. data/lib/io_streams/paths/sftp.rb +103 -64
  33. data/lib/io_streams/pgp/reader.rb +63 -10
  34. data/lib/io_streams/pgp/writer.rb +111 -30
  35. data/lib/io_streams/pgp.rb +256 -71
  36. data/lib/io_streams/reader.rb +14 -5
  37. data/lib/io_streams/record/reader.rb +75 -6
  38. data/lib/io_streams/record/writer.rb +3 -4
  39. data/lib/io_streams/row/reader.rb +1 -1
  40. data/lib/io_streams/row/writer.rb +1 -1
  41. data/lib/io_streams/stream.rb +48 -37
  42. data/lib/io_streams/symmetric_encryption/reader.rb +6 -2
  43. data/lib/io_streams/symmetric_encryption/writer.rb +8 -4
  44. data/lib/io_streams/tabular/header.rb +49 -10
  45. data/lib/io_streams/tabular/parser/array.rb +0 -10
  46. data/lib/io_streams/tabular/parser/base.rb +10 -0
  47. data/lib/io_streams/tabular/parser/csv.rb +9 -36
  48. data/lib/io_streams/tabular/parser/fixed.rb +8 -6
  49. data/lib/io_streams/tabular/parser/psv.rb +6 -14
  50. data/lib/io_streams/tabular.rb +5 -10
  51. data/lib/io_streams/utils.rb +34 -2
  52. data/lib/io_streams/version.rb +1 -1
  53. data/lib/io_streams/writer.rb +16 -7
  54. data/lib/io_streams/xlsx/reader.rb +6 -2
  55. data/lib/io_streams/zip/reader.rb +4 -0
  56. data/lib/io_streams/zip/writer.rb +26 -10
  57. data/lib/iostreams.rb +0 -1
  58. metadata +46 -112
  59. data/lib/io_streams/deprecated.rb +0 -216
  60. data/lib/io_streams/tabular/utility/csv_row.rb +0 -105
  61. data/test/builder_test.rb +0 -311
  62. data/test/bzip2_reader_test.rb +0 -27
  63. data/test/bzip2_writer_test.rb +0 -56
  64. data/test/deprecated_test.rb +0 -121
  65. data/test/encode_reader_test.rb +0 -51
  66. data/test/encode_writer_test.rb +0 -90
  67. data/test/files/embedded_lines_test.csv +0 -7
  68. data/test/files/multiple_files.zip +0 -0
  69. data/test/files/spreadsheet.xlsx +0 -0
  70. data/test/files/test.csv +0 -4
  71. data/test/files/test.json +0 -3
  72. data/test/files/test.psv +0 -4
  73. data/test/files/text file.txt +0 -3
  74. data/test/files/text.txt +0 -3
  75. data/test/files/text.txt.bz2 +0 -0
  76. data/test/files/text.txt.gz +0 -0
  77. data/test/files/text.txt.gz.zip +0 -0
  78. data/test/files/text.zip +0 -0
  79. data/test/files/text.zip.gz +0 -0
  80. data/test/files/unclosed_quote_large_test.csv +0 -1658
  81. data/test/files/unclosed_quote_test.csv +0 -4
  82. data/test/files/unclosed_quote_test2.csv +0 -3
  83. data/test/gzip_reader_test.rb +0 -27
  84. data/test/gzip_writer_test.rb +0 -52
  85. data/test/io_streams_test.rb +0 -132
  86. data/test/line_reader_test.rb +0 -325
  87. data/test/line_writer_test.rb +0 -59
  88. data/test/minimal_file_reader.rb +0 -25
  89. data/test/path_test.rb +0 -55
  90. data/test/paths/file_test.rb +0 -213
  91. data/test/paths/http_test.rb +0 -34
  92. data/test/paths/matcher_test.rb +0 -120
  93. data/test/paths/s3_test.rb +0 -220
  94. data/test/paths/sftp_test.rb +0 -106
  95. data/test/pgp_reader_test.rb +0 -46
  96. data/test/pgp_test.rb +0 -267
  97. data/test/pgp_writer_test.rb +0 -130
  98. data/test/record_reader_test.rb +0 -60
  99. data/test/record_writer_test.rb +0 -82
  100. data/test/row_reader_test.rb +0 -35
  101. data/test/row_writer_test.rb +0 -56
  102. data/test/stream_test.rb +0 -577
  103. data/test/tabular_test.rb +0 -338
  104. data/test/test_helper.rb +0 -40
  105. data/test/utils_test.rb +0 -20
  106. data/test/xlsx_reader_test.rb +0 -37
  107. data/test/zip_reader_test.rb +0 -53
  108. 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
- def self.file(file_name, passphrase: nil)
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 = IOStreams::Pgp.pgp_version.to_f >= 2.1 ? "--pinentry-mode 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
- no_symkey_cache = IOStreams::Pgp.pgp_version.to_f >= 2.4 ? "--no-symkey-cache" : ""
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 = "#{IOStreams::Pgp.executable} #{loopback} #{no_symkey_cache} --batch --no-tty --yes --decrypt --passphrase-fd 0 #{file_name}"
36
- IOStreams::Pgp.logger&.debug { "IOStreams::Pgp::Reader.open: #{command}" }
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
- compression: nil, # Deprecated
65
- compress_level: 6,
66
- original_file_name: nil)
67
-
68
- raise(ArgumentError, "Requires either :recipient or :import_and_trust_key") unless recipient || import_and_trust_key
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 = Array(recipient)
76
- recipients << audit_recipient if audit_recipient
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
- Array(import_and_trust_key).each do |key|
79
- recipients << IOStreams::Pgp.import_and_trust(key: key)
80
- end
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
- # Write to stdin, with encrypted contents being written to the file
83
- command = "#{IOStreams::Pgp.executable} --batch --no-tty --yes --encrypt"
84
- command << " --sign --local-user \"#{signer}\"" if signer
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
- command << " --pinentry-mode loopback" if IOStreams::Pgp.pgp_version.to_f >= 2.1
87
- command << " --no-symkey-cache" if IOStreams::Pgp.pgp_version.to_f >= 2.4
88
- command << " --passphrase \"#{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
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
- ::File.delete(file_name) if ::File.exist?(file_name)
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
- ::File.delete(file_name) if ::File.exist?(file_name)
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