dbf 5.2.0 → 5.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 96b3352f6c6d2cc3a667c6f390021c4f99be7c83ff2b9c911ac38c75d4c261aa
4
- data.tar.gz: b787cd0e14a34ea66e670f88e793bcbebc1ccbdc260721da08ccee5d3de2624a
3
+ metadata.gz: f296a6fc75892231410af21b2ee94490666f740f425f671078b5c28ded1e2977
4
+ data.tar.gz: eed4dfd84582cdf9c8e125ca2ddb1e882ebe75147321469db26166f7777a1ea1
5
5
  SHA512:
6
- metadata.gz: 52ceab2c71a3093ff691be23a0a0590e4309fd88218e683032e12805585dea05dd97bebd0170f003e7678641810d5d9aa6b9731bb0af647a8508d015875312d2
7
- data.tar.gz: 41b87283de2deea377a3c27436d621486af57bc0421e5bc84dc1bd1f304abf8d264d72d8dda80350666895ca604efd335b829fd1efc79720721027146fe56b7d
6
+ metadata.gz: 578f0f6cc7c88a205c38fd2ef93af2d8e5c924483018af4b96fb349dd12f40386c13547dfc67cd6fbdd47e191a0d04ef46bf0e1c38f9baec6057197661cd6945
7
+ data.tar.gz: 6960aba5a5b253cc6041c0822a504714ab5a511fee4ea1ff84f43cde10c8cf7db4d74d46f84e670d49f3a4d7110525a3196ea2f76cbcc37174a657e56a755fae
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.4.0
4
+
5
+ - CLI: replace terminal control bytes in file-derived output so a crafted DBF cannot emit escape sequences to an interactive terminal; CSV and schema output are only filtered when writing to a terminal, so redirected exports are unchanged
6
+ - Security (CWE-248): write binary or invalidly encoded cells in `Table#to_csv` as representable text instead of raising an encoding error
7
+ - Security (CWE-248): replace unrepresentable column type bytes so a corrupt descriptor cannot raise when the schema is serialized to JSON
8
+ - Security (CWE-248): `Table#record` returns nil when the file ends after the delete flag instead of crashing on a nil record body
9
+ - Security (CWE-248): stop column parsing when the file ends mid-descriptor instead of raising `ArgumentError` from a short unpack
10
+ - Security (CWE-248): guard FoxPro memo-pointer decoding against a nil value from a truncated record instead of crashing on nil.unpack1
11
+ - Security (CWE-248): guard dBase IV memo reads against a nil/short block header instead of crashing on nil.unpack1
12
+ - Security (CWE-248): guard dBase III memo reads against a start block past EOF instead of crashing on a nil block
13
+ - Security (CWE-248): stop column parsing on a truncated descriptor instead of constructing an invalid column that crashes on a nil length
14
+ - Security (CWE-248): decode truncated numeric cells (Currency, AutoIncrement) to blank instead of raising an uncaught `nil` crash
15
+ - Security (CWE-248): handle truncated headers and missing column terminators gracefully instead of raising an uncaught `nil` crash during column parsing
16
+ - Security (CWE-400): bound FoxPro memo reads by the memo file size so a crafted memo size cannot force a ~4 GiB allocation
17
+ - Security (CWE-400): bound dBase IV memo reads by the memo file size so a crafted length field cannot force a ~4 GiB allocation
18
+ - Security (CWE-835): bound record iteration by the bytes actually read so a crafted `record_count` (or zero `record_length`) cannot cause an unbounded loop
19
+ - Security (CWE-789): bound the record read buffer by the file's actual size so a crafted header cannot force a multi-gigabyte allocation from a tiny file
20
+ - Security (CWE-400): resolve FoxPro `.dbc` tables by scanning the directory instead of `Dir.glob`, preventing glob brace-expansion CPU exhaustion from a crafted object name
21
+ - Security (CWE-22): confine Visual FoxPro `.dbc` table resolution to the database directory, preventing path traversal via a crafted container object name
22
+ - Security (CWE-1236): neutralize spreadsheet formula injection in `Table#to_csv` by prefixing a quote to string cells/headers starting with `= + - @`
23
+ - Security (CWE-94): escape table and column names when generating ActiveRecord/Sequel schemas, preventing Ruby code injection from crafted DBF header names
24
+ - Add support for 8 more code pages (issue #98): Mazovia cp620 and Kamenický cp895 via vendored translation tables (new DBF::Encoder), plus macRoman, cp1255, cp1256, macCyrillic, macCentEuro and macGreek
25
+ - Blank Visual FoxPro "T" (DateTime) columns now return nil instead of a Julian day-0 date
26
+ - Blank "F" (Float) columns now return nil instead of 0.0, matching "N" (Number) behavior
27
+ - Extract CLI logic into DBF::CLI class (bin/dbf is now a thin shim)
28
+ - DBF::Table#to_csv now accepts an IO in addition to a String path
29
+ - Fix broken link in README
30
+
3
31
  ## 5.2.0
4
32
 
5
33
  - Drop support for Ruby 3.1 and 3.2
data/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
  DBF is a small, fast Ruby library for reading dBase, xBase, Clipper, and FoxPro database files.
11
11
 
12
12
  * Project page: <https://github.com/infused/dbf>
13
- * API Documentation: <https://rubydoc.info/github/infused/dbf>
13
+ * API Documentation: <https://rdoc.info/gems/dbf>
14
14
  * Report bugs: <https://github.com/infused/dbf/issues>
15
15
  * Questions: Email <mailto:keithm@infused.org> and put DBF somewhere in the
16
16
  subject line
@@ -21,7 +21,7 @@ NOTE: Beginning with version 5.2 we have dropped support for Ruby 3.2 and earlie
21
21
  NOTE: Beginning with version 4.3 we have dropped support for Ruby 3.0 and earlier.
22
22
 
23
23
  NOTE: Beginning with version 4 we have dropped support for Ruby 2.0, 2.1, 2.2, and 2.3. If you need support for these older Rubies,
24
- please use 3.0.x (<https://github.com/infused.org/dbf/tree/3_stable>)
24
+ please use 3.0.x (<https://github.com/infused/dbf/tree/3_stable>)
25
25
 
26
26
  NOTE: Beginning with version 3 we have dropped support for Ruby 1.8 and 1.9. If you need support for older Rubies,
27
27
  please use 2.0.x (<https://github.com/infused/dbf/tree/2_stable>)
@@ -151,6 +151,7 @@ table = DBF::Table.new('dbf/books.dbf', nil, 'cp866')
151
151
  | 01 | cp437 | U.S. MS–DOS |
152
152
  | 02 | cp850 | International MS–DOS |
153
153
  | 03 | cp1252 | Windows ANSI |
154
+ | 04 | macRoman | Standard Macintosh |
154
155
  | 08 | cp865 | Danish OEM |
155
156
  | 09 | cp437 | Dutch OEM |
156
157
  | 0a | cp850 | Dutch OEM* |
@@ -191,6 +192,8 @@ table = DBF::Table.new('dbf/books.dbf', nil, 'cp866')
191
192
  | 65 | cp866 | Russian MS–DOS |
192
193
  | 66 | cp865 | Nordic MS–DOS |
193
194
  | 67 | cp861 | Icelandic MS–DOS |
195
+ | 68 | cp895 | Kamenický (Czech) MS–DOS |
196
+ | 69 | cp620 | Mazovia (Polish) MS–DOS |
194
197
  | 6a | cp737 | Greek MS–DOS (437G) |
195
198
  | 6b | cp857 | Turkish MS–DOS |
196
199
  | 6c | cp863 | French–Canadian MS–DOS |
@@ -199,9 +202,14 @@ table = DBF::Table.new('dbf/books.dbf', nil, 'cp866')
199
202
  | 7a | cp936 | PRC GBK |
200
203
  | 7b | cp932 | Japanese Shift-JIS |
201
204
  | 7c | cp874 | Thai Windows/MS–DOS |
205
+ | 7d | cp1255 | Hebrew Windows |
206
+ | 7e | cp1256 | Arabic Windows |
202
207
  | 86 | cp737 | Greek OEM |
203
208
  | 87 | cp852 | Slovenian OEM |
204
209
  | 88 | cp857 | Turkish OEM |
210
+ | 96 | macCyrillic | Russian Macintosh |
211
+ | 97 | macCentEuro | Macintosh EE |
212
+ | 98 | macGreek | Greek Macintosh |
205
213
  | c8 | cp1250 | Eastern European Windows |
206
214
  | c9 | cp1251 | Russian Windows |
207
215
  | ca | cp1254 | Turkish Windows |
@@ -328,16 +336,16 @@ at this time.
328
336
  | 04 | dBase IV without memo file | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - | - | - |
329
337
  | 05 | dBase V without memo file | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - | - | - |
330
338
  | 07 | Visual Objects 1.x | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - | - | - |
331
- | 30 | Visual FoxPro | Y | Y | Y | Y | Y | Y | Y | Y | N | Y | N | Y | N | N | N | N | - |
332
- | 31 | Visual FoxPro with AutoIncrement | Y | Y | Y | Y | Y | Y | Y | Y | N | Y | N | Y | N | N | N | N | N |
333
- | 32 | Visual FoxPro with field type Varchar or Varbinary | Y | Y | Y | Y | Y | Y | Y | Y | N | Y | N | Y | N | N | N | N | N |
339
+ | 30 | Visual FoxPro | Y | Y | Y | Y | Y | Y | Y | Y | N | Y | Y | Y | N | N | N | N | - |
340
+ | 31 | Visual FoxPro with AutoIncrement | Y | Y | Y | Y | Y | Y | Y | Y | N | Y | Y | Y | N | N | N | N | N |
341
+ | 32 | Visual FoxPro with field type Varchar or Varbinary | Y | Y | Y | Y | Y | Y | Y | Y | N | Y | Y | Y | N | N | N | N | N |
334
342
  | 7b | dBase IV with memo file | Y | Y | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - |
335
343
  | 83 | dBase III with memo file | Y | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - | - |
336
344
  | 87 | Visual Objects 1.x with memo file | Y | Y | Y | Y | Y | - | - | - | - | - | - | - | - | - | - | - | - |
337
345
  | 8b | dBase IV with memo file | Y | Y | Y | Y | Y | - | - | - | - | - | - | - | - | N | - | - | - |
338
346
  | 8e | dBase IV with SQL table | Y | Y | Y | Y | Y | - | - | - | - | - | - | - | - | N | - | - | - |
339
- | f5 | FoxPro with memo file | Y | Y | Y | Y | Y | Y | Y | Y | N | Y | N | Y | N | N | N | N | N |
340
- | fb | FoxPro without memo file | Y | Y | Y | Y | - | Y | Y | Y | N | Y | N | Y | N | N | N | N | N |
347
+ | f5 | FoxPro with memo file | Y | Y | Y | Y | Y | Y | Y | Y | N | Y | Y | Y | N | N | N | N | N |
348
+ | fb | FoxPro without memo file | Y | Y | Y | Y | - | Y | Y | Y | N | Y | Y | Y | N | N | N | N | N |
341
349
 
342
350
  Data type descriptions
343
351
 
data/bin/dbf CHANGED
@@ -1,60 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  Signal.trap('PIPE', 'SYSTEM_DEFAULT')
4
5
 
5
6
  require 'dbf'
6
- require 'dbf/version'
7
- require 'optparse'
8
7
 
9
- params = ARGV.getopts('h', 's', 'a', 'c', 'r', 'v')
10
-
11
- if params['v']
12
- puts "dbf version: #{DBF::VERSION}"
13
-
14
- elsif params['h']
15
- puts "usage: #{File.basename(__FILE__)} [-h|-s|-a|-c|-r] filename"
16
- puts ' -h = print this message'
17
- puts ' -v = print the DBF gem version'
18
- puts ' -s = print summary information'
19
- puts ' -a = create an ActiveRecord::Schema'
20
- puts ' -r = create a Sequel migration'
21
- puts ' -c = export as CSV'
22
- else
23
-
24
- filename = ARGV.shift
25
- abort 'You must supply a filename on the command line' unless filename
26
-
27
- # create an ActiveRecord::Schema
28
- if params['a']
29
- table = DBF::Table.new filename
30
- puts table.schema(:activerecord)
31
- end
32
-
33
- # create an Sequel::Migration
34
- if params['r']
35
- table = DBF::Table.new filename
36
- puts table.schema(:sequel)
37
- end
38
-
39
- if params['s']
40
- table = DBF::Table.new filename
41
- puts
42
- puts "Database: #{filename}"
43
- puts "Type: (#{table.version}) #{table.version_description}"
44
- puts "Encoding: #{table.header_encoding}" if table.header_encoding
45
- puts "Memo File: #{table.has_memo_file? ? 'true' : 'false'}"
46
- puts "Records: #{table.record_count}"
47
-
48
- puts "\nFields:"
49
- puts 'Name Type Length Decimal'
50
- puts '-' * 78
51
- table.columns.each do |f|
52
- puts format('%-16s %-10s %-10s %-10s', f.name, f.type, f.length, f.decimal)
53
- end
54
- end
55
-
56
- if params['c']
57
- table = DBF::Table.new filename
58
- table.to_csv
59
- end
60
- end
8
+ exit DBF::CLI.run(ARGV)
data/lib/dbf/cli.rb ADDED
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+
5
+ module DBF
6
+ class CLI
7
+ USAGE = <<~HELP
8
+ usage: dbf [-h|-s|-a|-c|-r] filename
9
+ -h = print this message
10
+ -v = print the DBF gem version
11
+ -s = print summary information
12
+ -a = create an ActiveRecord::Schema
13
+ -r = create a Sequel migration
14
+ -c = export as CSV
15
+ HELP
16
+
17
+ # Bytes a terminal interprets as control or escape sequences. A crafted
18
+ # DBF can carry these in column names and record values, so they are
19
+ # replaced before file-derived text reaches an interactive terminal.
20
+ CONTROL_BYTES = /[\x00-\x1F\x7F]/n
21
+ # The same, but keeping CR and LF so CSV row separators survive.
22
+ CONTROL_BYTES_KEEPING_NEWLINES = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/n
23
+
24
+ # Replaces terminal control bytes. Substitution happens on a byte copy so
25
+ # a value whose bytes are invalid in its encoding cannot raise here.
26
+ #
27
+ # @param value [Object]
28
+ # @param pattern [Regexp]
29
+ # @return [String]
30
+ def self.sanitize(value, pattern = CONTROL_BYTES)
31
+ string = value.to_s
32
+ string.b.gsub(pattern, '?').force_encoding(string.encoding)
33
+ end
34
+
35
+ # Wraps an IO so text written to an interactive terminal is stripped of
36
+ # control bytes. Redirected or piped output is never wrapped, so exported
37
+ # data is passed through unaltered.
38
+ class TerminalFilter
39
+ def initialize(io)
40
+ @io = io
41
+ end
42
+
43
+ def <<(data)
44
+ @io << CLI.sanitize(data, CONTROL_BYTES_KEEPING_NEWLINES)
45
+ self
46
+ end
47
+
48
+ def write(*data)
49
+ @io.write(*data.map { |datum| CLI.sanitize(datum, CONTROL_BYTES_KEEPING_NEWLINES) })
50
+ end
51
+
52
+ def method_missing(name, ...) # :nodoc:
53
+ @io.respond_to?(name) ? @io.send(name, ...) : super
54
+ end
55
+
56
+ def respond_to_missing?(name, include_private = false) # :nodoc:
57
+ @io.respond_to?(name, include_private) || super
58
+ end
59
+ end
60
+
61
+ def self.run(argv, stdout: $stdout, stderr: $stderr)
62
+ new(argv, stdout: stdout, stderr: stderr).run
63
+ end
64
+
65
+ def initialize(argv, stdout: $stdout, stderr: $stderr)
66
+ @argv = argv.dup
67
+ @stdout = stdout
68
+ @stderr = stderr
69
+ end
70
+
71
+ def run
72
+ params = OptionParser.new.getopts(@argv, 'h', 's', 'a', 'c', 'r', 'v')
73
+
74
+ if params['v']
75
+ print_version
76
+ elsif params['h']
77
+ print_help
78
+ else
79
+ filename = @argv.shift
80
+ return missing_filename unless filename
81
+
82
+ action = %w[a r s c].find { |flag| params[flag] }
83
+ case action
84
+ when 'a' then print_ar_schema(filename)
85
+ when 'r' then print_sequel_schema(filename)
86
+ when 's' then print_summary(filename)
87
+ when 'c' then print_csv(filename)
88
+ end
89
+ end
90
+ 0
91
+ rescue DBF::FileNotFoundError => e
92
+ @stderr.puts "DBF::FileNotFoundError: #{e.message}"
93
+ 1
94
+ end
95
+
96
+ private
97
+
98
+ def print_version
99
+ @stdout.puts "dbf version: #{DBF::VERSION}"
100
+ end
101
+
102
+ def print_help
103
+ @stdout.puts USAGE
104
+ end
105
+
106
+ def missing_filename
107
+ @stderr.puts 'You must supply a filename on the command line'
108
+ 1
109
+ end
110
+
111
+ def print_ar_schema(filename)
112
+ @stdout.puts terminal_safe(DBF::Table.new(filename).schema(:activerecord))
113
+ end
114
+
115
+ def print_sequel_schema(filename)
116
+ @stdout.puts terminal_safe(DBF::Table.new(filename).schema(:sequel))
117
+ end
118
+
119
+ def print_summary(filename)
120
+ table = DBF::Table.new(filename)
121
+ @stdout.puts
122
+ @stdout.puts "Database: #{filename}"
123
+ @stdout.puts "Type: (#{table.version}) #{table.version_description}"
124
+ @stdout.puts "Encoding: #{table.header_encoding}" if table.header_encoding
125
+ @stdout.puts "Memo File: #{table.has_memo_file? ? 'true' : 'false'}"
126
+ @stdout.puts "Records: #{table.record_count}"
127
+ @stdout.puts "\nFields:"
128
+ @stdout.puts 'Name Type Length Decimal'
129
+ @stdout.puts '-' * 78
130
+ table.columns.each do |f|
131
+ # Column names and types come from the file. Always replace control
132
+ # bytes here: they are never valid in a name and would otherwise both
133
+ # emit escape sequences and break the column alignment below.
134
+ @stdout.puts format('%-16s %-10s %-10s %-10s', self.class.sanitize(f.name), self.class.sanitize(f.type), f.length, f.decimal)
135
+ end
136
+ end
137
+
138
+ def print_csv(filename)
139
+ DBF::Table.new(filename).to_csv(interactive? ? TerminalFilter.new(@stdout) : @stdout)
140
+ end
141
+
142
+ # Exported data is only filtered when it is going to a terminal, so
143
+ # redirecting or piping still produces byte-for-byte the original values.
144
+ def terminal_safe(text)
145
+ return text unless interactive?
146
+
147
+ self.class.sanitize(text, CONTROL_BYTES_KEEPING_NEWLINES)
148
+ end
149
+
150
+ def interactive?
151
+ @stdout.respond_to?(:tty?) && @stdout.tty?
152
+ end
153
+ end
154
+ end
data/lib/dbf/column.rb CHANGED
@@ -38,7 +38,7 @@ module DBF
38
38
  def initialize(table, name, type, length, decimal)
39
39
  @table = table
40
40
  @name = clean(name)
41
- @type = type
41
+ @type = clean_type(type)
42
42
  @length = length
43
43
  @decimal = decimal
44
44
 
@@ -58,8 +58,8 @@ module DBF
58
58
  # @param raw [String]
59
59
  # @yield [raw] for memo column resolution
60
60
  # @return decoded value
61
- def decode(raw, &memo_handler)
62
- type_cast_class.decode(raw, &memo_handler)
61
+ def decode(raw, &)
62
+ type_cast_class.decode(raw, &)
63
63
  end
64
64
 
65
65
  # Returns a Hash with :name, :type, :length, and :decimal keys
@@ -85,6 +85,14 @@ module DBF
85
85
  @table.encode_string(value.strip.split("\x00", 2).first || +'')
86
86
  end
87
87
 
88
+ # The column type is a single ASCII character. A corrupt file can supply
89
+ # any byte, which would otherwise stay binary and raise when serialized
90
+ # (e.g. to JSON), so replace anything unrepresentable.
91
+ def clean_type(value) # :nodoc:
92
+ type = value.to_s.dup.force_encoding(Encoding::UTF_8)
93
+ type.valid_encoding? ? type : type.scrub('?')
94
+ end
95
+
88
96
  def type_cast_class # :nodoc:
89
97
  @type_cast_class ||= begin
90
98
  klass = @length == 0 ? ColumnType::Nil : TYPE_CAST_CLASS[type.to_sym]
@@ -12,7 +12,15 @@ module DBF
12
12
  safe_seek do
13
13
  @data.seek(@version_config.header_size)
14
14
  [].tap do |columns|
15
- columns << Column.new(*@version_config.read_column_args(@table, @data)) until end_of_record?
15
+ until end_of_record?
16
+ args = @version_config.read_column_args(@table, @data)
17
+ # A descriptor truncated by EOF is returned as nil, or unpacks to
18
+ # a nil length; stop rather than constructing an invalid column
19
+ # (which would crash on nil < 0).
20
+ break if args.nil? || args[3].nil?
21
+
22
+ columns << Column.new(*args)
23
+ end
16
24
  end
17
25
  end
18
26
  end
@@ -20,7 +28,12 @@ module DBF
20
28
  private
21
29
 
22
30
  def end_of_record?
23
- safe_seek { @data.read(1).ord == 13 }
31
+ safe_seek do
32
+ byte = @data.read(1)
33
+ # A truncated file that ends before the 0x0D column terminator marks
34
+ # the end of the column list rather than crashing on nil.ord.
35
+ byte.nil? || byte.ord == 13
36
+ end
24
37
  end
25
38
 
26
39
  def safe_seek
@@ -5,8 +5,7 @@ module DBF
5
5
  class Base
6
6
  attr_reader :decimal, :encoding
7
7
 
8
- # @param decimal [Integer]
9
- # @param encoding [String, Encoding]
8
+ # @param column [DBF::Column]
10
9
  def initialize(column)
11
10
  @decimal = column.decimal
12
11
  @encoding = column.encoding
@@ -20,7 +19,11 @@ module DBF
20
19
  false
21
20
  end
22
21
 
23
- def decode(raw, &_memo_handler)
22
+ def decode(raw, &)
23
+ # A record truncated before this column yields a nil slice; treat it
24
+ # as blank rather than crashing in the type cast.
25
+ return blank_value if raw.nil?
26
+
24
27
  if skip_blank? && raw.count(' ') == raw.length
25
28
  blank_value
26
29
  else
@@ -50,7 +53,8 @@ module DBF
50
53
  class Currency < Base
51
54
  # @param value [String]
52
55
  def type_cast(value)
53
- (value.unpack1('q<') / 10_000.0).to_f
56
+ int = value.unpack1('q<')
57
+ int && (int / 10_000.0).to_f
54
58
  end
55
59
  end
56
60
 
@@ -65,14 +69,20 @@ module DBF
65
69
  # @param value [String]
66
70
  def type_cast(value)
67
71
  bits = value.unpack1('B*')
72
+ return nil unless bits && bits.length >= 32
73
+
68
74
  sign_multiplier = bits[0] == '0' ? -1 : 1
69
75
  bits[1, 31].to_i(2) * sign_multiplier
70
76
  end
71
77
  end
72
78
 
73
79
  class Float < Base
80
+ def skip_blank? = true
81
+
74
82
  # @param value [String]
75
83
  def type_cast(value)
84
+ return nil if value.empty?
85
+
76
86
  value.to_f
77
87
  end
78
88
  end
@@ -111,6 +121,8 @@ module DBF
111
121
  # @param value [String]
112
122
  def type_cast(value)
113
123
  days, msecs = value.unpack('l2')
124
+ return nil if days.nil? || days.zero?
125
+
114
126
  secs = (msecs / 1000).to_i
115
127
  ::DateTime.jd(days, (secs / 3600).to_i, (secs / 60).to_i % 60, secs % 60).to_time
116
128
  rescue StandardError
@@ -119,8 +131,8 @@ module DBF
119
131
  end
120
132
 
121
133
  class Memo < Base
122
- def decode(raw, &memo_handler)
123
- memo_content = memo_handler.call(raw)
134
+ def decode(raw, &)
135
+ memo_content = yield(raw)
124
136
  memo_content ? type_cast(memo_content) : nil
125
137
  end
126
138
 
@@ -128,7 +140,7 @@ module DBF
128
140
  def type_cast(value)
129
141
  return value unless encoding && value
130
142
 
131
- value.dup.force_encoding(encoding).encode(Encoding.default_external, undef: :replace, invalid: :replace)
143
+ Encoder.encode(value.dup, encoding)
132
144
  end
133
145
  end
134
146
 
@@ -158,8 +170,9 @@ module DBF
158
170
  private
159
171
 
160
172
  def encode(value)
161
- value.force_encoding(encoding)
162
- @needs_encode ? value.encode(@target_encoding, undef: :replace, invalid: :replace) : value
173
+ return value.force_encoding(encoding) unless @needs_encode
174
+
175
+ Encoder.encode(value, encoding, @target_encoding)
163
176
  end
164
177
  end
165
178
  end
@@ -47,11 +47,25 @@ module DBF
47
47
  # @param name [String]
48
48
  # @return [String]
49
49
  def table_path(name)
50
- glob = File.join(@dirname, "#{name}.dbf")
51
- path = Dir.glob(glob, File::FNM_CASEFOLD).first
50
+ name = name.to_s
51
+ raise DBF::FileNotFoundError, "related table not found: #{name}" if name.empty? || name.include?("\x00")
52
+
53
+ # Treat the container-supplied name as an untrusted basename so that
54
+ # path separators and ".." cannot escape the database directory.
55
+ # Match case-insensitively by scanning the directory rather than
56
+ # globbing, so glob metacharacters (`{}`, `*`, `?`, `[]`) in the name
57
+ # cannot trigger brace-expansion CPU exhaustion.
58
+ target = "#{File.basename(name)}.dbf"
59
+ entry = Dir.children(@dirname).find { |child| child.casecmp?(target) }
60
+ path = entry && File.join(@dirname, entry)
52
61
 
53
62
  raise DBF::FileNotFoundError, "related table not found: #{name}" unless path && File.exist?(path)
54
63
 
64
+ # Defense in depth: confirm the resolved file really is inside the
65
+ # database directory before opening it.
66
+ contained = File.realpath(path).start_with?("#{File.realpath(@dirname)}#{File::SEPARATOR}")
67
+ raise DBF::FileNotFoundError, "related table not found: #{name}" unless contained
68
+
55
69
  path
56
70
  end
57
71
 
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DBF
4
+ # Transcodes strings from a source encoding to a target encoding.
5
+ #
6
+ # Most DBF code pages map to encodings built into Ruby and are converted
7
+ # with String#encode. A few (Mazovia cp620, Kamenický cp895) have no Ruby
8
+ # equivalent and are decoded through vendored byte-to-Unicode tables.
9
+ module Encoder
10
+ # Byte-to-Unicode tables generated from
11
+ # https://github.com/SheetJS/js-codepage/blob/master/codepages/620.TBL and
12
+ # https://github.com/SheetJS/js-codepage/blob/master/codepages/895.TBL
13
+
14
+ # Mazovia (Polish) MS-DOS
15
+ CP620_TO_UNICODE = [
16
+ 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
17
+ 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
18
+ 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
19
+ 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
20
+ 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
21
+ 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
22
+ 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
23
+ 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
24
+ 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
25
+ 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
26
+ 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
27
+ 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F,
28
+ 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
29
+ 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
30
+ 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
31
+ 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F,
32
+ 0x00C7, 0x00FC, 0x00E9, 0x00E2, 0x00E4, 0x00E0, 0x0105, 0x00E7,
33
+ 0x00EA, 0x00EB, 0x00E8, 0x00EF, 0x00EE, 0x0107, 0x00C4, 0x0104,
34
+ 0x0118, 0x0119, 0x0142, 0x00F4, 0x00F6, 0x0106, 0x00FB, 0x00F9,
35
+ 0x015A, 0x00D6, 0x00DC, 0x00A2, 0x0141, 0x00A5, 0x015B, 0x0192,
36
+ 0x0179, 0x017B, 0x00F3, 0x00D3, 0x0144, 0x0143, 0x017A, 0x017C,
37
+ 0x00BF, 0x2310, 0x00AC, 0x00BD, 0x00BC, 0x00A1, 0x00AB, 0x00BB,
38
+ 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556,
39
+ 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510,
40
+ 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
41
+ 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567,
42
+ 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B,
43
+ 0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580,
44
+ 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4,
45
+ 0x03A6, 0x0398, 0x03A9, 0x03B4, 0x221E, 0x03C6, 0x03B5, 0x2229,
46
+ 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248,
47
+ 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0
48
+ ].freeze
49
+
50
+ # Kamenický (Czech) MS-DOS
51
+ CP895_TO_UNICODE = [
52
+ 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
53
+ 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
54
+ 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
55
+ 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
56
+ 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
57
+ 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
58
+ 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
59
+ 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
60
+ 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
61
+ 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
62
+ 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
63
+ 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F,
64
+ 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
65
+ 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
66
+ 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
67
+ 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F,
68
+ 0x010C, 0x00FC, 0x00E9, 0x010F, 0x00E4, 0x010E, 0x0164, 0x010D,
69
+ 0x011B, 0x011A, 0x0139, 0x00CD, 0x013E, 0x01EA, 0x00C4, 0x00C1,
70
+ 0x00C9, 0x017E, 0x017D, 0x00F4, 0x00F6, 0x00D3, 0x016F, 0x00DA,
71
+ 0x00FD, 0x00D6, 0x00DC, 0x0160, 0x013D, 0x00DD, 0x0158, 0x0165,
72
+ 0x00E1, 0x00ED, 0x00F3, 0x00FA, 0x0148, 0x0147, 0x016E, 0x00D4,
73
+ 0x0161, 0x0159, 0x0155, 0x0154, 0x00BC, 0x00A7, 0x00AB, 0x00BB,
74
+ 0x2591, 0x2592, 0x2593, 0x2502, 0x2524, 0x2561, 0x2562, 0x2556,
75
+ 0x2555, 0x2563, 0x2551, 0x2557, 0x255D, 0x255C, 0x255B, 0x2510,
76
+ 0x2514, 0x2534, 0x252C, 0x251C, 0x2500, 0x253C, 0x255E, 0x255F,
77
+ 0x255A, 0x2554, 0x2569, 0x2566, 0x2560, 0x2550, 0x256C, 0x2567,
78
+ 0x2568, 0x2564, 0x2565, 0x2559, 0x2558, 0x2552, 0x2553, 0x256B,
79
+ 0x256A, 0x2518, 0x250C, 0x2588, 0x2584, 0x258C, 0x2590, 0x2580,
80
+ 0x03B1, 0x00DF, 0x0393, 0x03C0, 0x03A3, 0x03C3, 0x00B5, 0x03C4,
81
+ 0x03A6, 0x0398, 0x03A9, 0x03B4, 0x221E, 0x03C6, 0x03B5, 0x2229,
82
+ 0x2261, 0x00B1, 0x2265, 0x2264, 0x2320, 0x2321, 0x00F7, 0x2248,
83
+ 0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0
84
+ ].freeze
85
+
86
+ CUSTOM_TABLES = {
87
+ 'cp620' => CP620_TO_UNICODE,
88
+ 'cp895' => CP895_TO_UNICODE
89
+ }.freeze
90
+
91
+ # Is the encoding a code page without a Ruby Encoding, decoded through
92
+ # a vendored table?
93
+ #
94
+ # @param encoding [String, Encoding, nil]
95
+ # @return [Boolean]
96
+ def self.custom?(encoding)
97
+ encoding.is_a?(::String) && CUSTOM_TABLES.key?(encoding.downcase)
98
+ end
99
+
100
+ # Transcode a string from source_encoding to target_encoding
101
+ #
102
+ # @param string [String]
103
+ # @param source_encoding [String, Encoding]
104
+ # @param target_encoding [Encoding]
105
+ # @return [String]
106
+ def self.encode(string, source_encoding, target_encoding = Encoding.default_external)
107
+ table = source_encoding.is_a?(::String) && CUSTOM_TABLES[source_encoding.downcase]
108
+ if table
109
+ decoded = string.each_byte.map { |byte| table[byte] }.pack('U*')
110
+ target_encoding == Encoding::UTF_8 ? decoded : decoded.encode(target_encoding, undef: :replace, invalid: :replace)
111
+ else
112
+ string.force_encoding(source_encoding).encode(target_encoding, undef: :replace, invalid: :replace)
113
+ end
114
+ end
115
+ end
116
+ end
data/lib/dbf/encodings.rb CHANGED
@@ -5,6 +5,7 @@ module DBF
5
5
  '01' => 'cp437', # U.S. MS-DOS
6
6
  '02' => 'cp850', # International MS-DOS
7
7
  '03' => 'cp1252', # Windows ANSI
8
+ '04' => 'macRoman', # Standard Macintosh
8
9
  '08' => 'cp865', # Danish OEM
9
10
  '09' => 'cp437', # Dutch OEM
10
11
  '0a' => 'cp850', # Dutch OEM*
@@ -45,6 +46,8 @@ module DBF
45
46
  '65' => 'cp866', # Russian MS-DOS
46
47
  '66' => 'cp865', # Nordic MS-DOS
47
48
  '67' => 'cp861', # Icelandic MS-DOS
49
+ '68' => 'cp895', # Kamenicky (Czech) MS-DOS
50
+ '69' => 'cp620', # Mazovia (Polish) MS-DOS
48
51
  '6a' => 'cp737', # Greek MS-DOS (437G)
49
52
  '6b' => 'cp857', # Turkish MS-DOS
50
53
  '6c' => 'cp863', # French-Canadian MS-DOS
@@ -53,9 +56,14 @@ module DBF
53
56
  '7a' => 'cp936', # PRC GBK
54
57
  '7b' => 'cp932', # Japanese Shift-JIS
55
58
  '7c' => 'cp874', # Thai Windows/MS-DOS
59
+ '7d' => 'cp1255', # Hebrew Windows
60
+ '7e' => 'cp1256', # Arabic Windows
56
61
  '86' => 'cp737', # Greek OEM
57
62
  '87' => 'cp852', # Slovenian OEM
58
63
  '88' => 'cp857', # Turkish OEM
64
+ '96' => 'macCyrillic', # Russian Macintosh
65
+ '97' => 'macCentEuro', # Macintosh EE
66
+ '98' => 'macGreek', # Greek Macintosh
59
67
  'c8' => 'cp1250', # Eastern European Windows
60
68
  'c9' => 'cp1251', # Russian Windows
61
69
  'ca' => 'cp1254', # Turkish Windows
data/lib/dbf/header.rb CHANGED
@@ -2,9 +2,14 @@
2
2
 
3
3
  module DBF
4
4
  class Header
5
+ HEADER_SIZE = 32
6
+
5
7
  attr_reader :version, :record_count, :header_length, :record_length, :encoding_key, :encoding
6
8
 
7
9
  def initialize(data)
10
+ # Pad a nil or truncated header read so unpacking a short file yields
11
+ # empty values instead of raising.
12
+ data = data.to_s.b.ljust(HEADER_SIZE, "\x00")
8
13
  @version = data.unpack1('H2')
9
14
  @encoding_key = nil
10
15
  @encoding = nil
@@ -7,7 +7,12 @@ module DBF
7
7
  data.seek offset(start_block)
8
8
  memo_string = +''
9
9
  loop do
10
- block = data.read(BLOCK_SIZE).gsub(/(\000|\032)/, '')
10
+ block = data.read(BLOCK_SIZE)
11
+ # A start block past EOF yields nil; return what we have rather
12
+ # than crashing on nil.gsub.
13
+ break if block.nil?
14
+
15
+ block = block.gsub(/(\000|\032)/, '')
11
16
  memo_string << block
12
17
  break if block.size < BLOCK_SIZE
13
18
  end
@@ -5,7 +5,21 @@ module DBF
5
5
  class Dbase4 < Base
6
6
  def build_memo(start_block) # :nodoc:
7
7
  data.seek offset(start_block)
8
- data.read(data.read(BLOCK_HEADER_SIZE).unpack1('x4L'))
8
+
9
+ # A start block past EOF yields a nil/short header; return nil rather
10
+ # than crashing on nil.unpack1.
11
+ header = data.read(BLOCK_HEADER_SIZE)
12
+ return nil unless header && header.bytesize == BLOCK_HEADER_SIZE
13
+
14
+ length = header.unpack1('x4L')
15
+
16
+ # Bound the read by the bytes remaining so a crafted 32-bit length
17
+ # field cannot force a ~4 GiB allocation from a small memo file.
18
+ remaining = data.size - data.pos
19
+ length = remaining if length > remaining
20
+ return nil if length <= 0
21
+
22
+ data.read(length)
9
23
  end
10
24
  end
11
25
  end
@@ -23,11 +23,15 @@ module DBF
23
23
  private
24
24
 
25
25
  def read_memo_content(memo_string, memo_size) # :nodoc:
26
- if memo_size > block_content_size
27
- memo_string << @data.read(content_size(memo_size))
28
- else
29
- memo_string[0, memo_size]
30
- end
26
+ return memo_string[0, memo_size] unless memo_size > block_content_size
27
+
28
+ # Bound the read by the bytes remaining so a crafted 32-bit memo_size
29
+ # cannot force a ~4 GiB allocation from a small memo file.
30
+ length = content_size(memo_size)
31
+ remaining = @data.size - @data.pos
32
+ length = remaining if length > remaining
33
+ memo_string << @data.read(length) if length.positive?
34
+ memo_string
31
35
  end
32
36
 
33
37
  def block_size # :nodoc:
data/lib/dbf/record.rb CHANGED
@@ -83,7 +83,9 @@ module DBF
83
83
 
84
84
  def decode_memo_value(raw) # :nodoc:
85
85
  memo = @context.memo
86
- return nil unless memo
86
+ # A record truncated before the memo column yields a nil pointer; skip
87
+ # decoding rather than crashing on nil.unpack1.
88
+ return nil unless memo && raw
87
89
 
88
90
  version = @context.version
89
91
  raw = raw.unpack1('V') if version == '30' || version == '31'
@@ -14,8 +14,13 @@ module DBF
14
14
  buf = read_buffer
15
15
  return unless buf
16
16
 
17
+ # Bound the iteration by the bytes actually read so a crafted
18
+ # record_count (or record_length == 0) cannot drive an unbounded loop.
19
+ max_records = @record_length > 0 ? buf.bytesize / @record_length : 0
20
+ count = @record_count < max_records ? @record_count : max_records
21
+
17
22
  pos = 0
18
- @record_count.times do
23
+ count.times do
19
24
  if buf.getbyte(pos) == 0x2A
20
25
  yield nil
21
26
  else
@@ -29,7 +34,14 @@ module DBF
29
34
 
30
35
  def read_buffer
31
36
  @data.seek(@header_length)
32
- @data.read(@record_length * @record_count)
37
+
38
+ # Bound the allocation by the bytes actually available so a crafted
39
+ # header (huge record_length * record_count) cannot force a giant read
40
+ # from a tiny file.
41
+ requested = @record_length * @record_count
42
+ available = @data.size - @header_length
43
+ available = 0 if available.negative?
44
+ @data.read(requested < available ? requested : available)
33
45
  end
34
46
  end
35
47
  end
data/lib/dbf/schema.rb CHANGED
@@ -55,7 +55,7 @@ module DBF
55
55
 
56
56
  def activerecord_schema(*) # :nodoc:
57
57
  output = +"ActiveRecord::Schema.define do\n"
58
- output << " create_table \"#{name}\" do |t|\n"
58
+ output << " create_table #{name.to_s.inspect} do |t|\n"
59
59
  columns.each do |column|
60
60
  output << " t.column #{activerecord_schema_definition(column)}"
61
61
  end
@@ -66,7 +66,7 @@ module DBF
66
66
  def sequel_schema(table_only: false) # :nodoc:
67
67
  output = +''
68
68
  output << "Sequel.migration do\n change do\n " unless table_only
69
- output << " create_table(:#{name}) do\n"
69
+ output << " create_table(#{name.to_s.to_sym.inspect}) do\n"
70
70
  columns.each do |column|
71
71
  output << " column #{sequel_schema_definition(column)}"
72
72
  end
@@ -84,7 +84,7 @@ module DBF
84
84
  # @param column [DBF::Column]
85
85
  # @return [String]
86
86
  def activerecord_schema_definition(column)
87
- "\"#{column.underscored_name}\", #{schema_data_type(column, :activerecord)}\n"
87
+ "#{column.underscored_name.inspect}, #{schema_data_type(column, :activerecord)}\n"
88
88
  end
89
89
 
90
90
  # Sequel schema definition
@@ -92,7 +92,7 @@ module DBF
92
92
  # @param column [DBF::Column]
93
93
  # @return [String]
94
94
  def sequel_schema_definition(column)
95
- ":#{column.underscored_name}, #{schema_data_type(column, :sequel)}\n"
95
+ "#{column.underscored_name.to_sym.inspect}, #{schema_data_type(column, :sequel)}\n"
96
96
  end
97
97
 
98
98
  def schema_data_type(column, format = :activerecord) # :nodoc:
data/lib/dbf/table.rb CHANGED
@@ -7,6 +7,10 @@ module DBF
7
7
  class NoColumnsDefined < StandardError
8
8
  end
9
9
 
10
+ # Leading bytes that make a spreadsheet treat a CSV cell as a formula:
11
+ # "=", "+", "-", "@", tab, and carriage return.
12
+ CSV_FORMULA_TRIGGERS = [0x3D, 0x2B, 0x2D, 0x40, 0x09, 0x0D].freeze
13
+
10
14
  # DBF::Table is the primary interface to a single DBF file and provides
11
15
  # methods for enumerating and searching the records.
12
16
  class Table
@@ -132,6 +136,10 @@ module DBF
132
136
  return nil if deleted_record?
133
137
 
134
138
  record_data = @data.read(record_length)
139
+ # A file that ends immediately after the delete flag has no record body;
140
+ # treat it as absent rather than building a Record over nil data.
141
+ return nil unless record_data
142
+
135
143
  DBF::Record.new(record_data, record_context)
136
144
  end
137
145
 
@@ -140,11 +148,16 @@ module DBF
140
148
  # Dumps all records to a CSV file. If no filename is given then CSV is
141
149
  # output to STDOUT.
142
150
  #
143
- # @param [optional String] path Defaults to STDOUT
144
- def to_csv(path = nil)
145
- csv = CSV.new(path ? File.open(path, 'w') : $stdout, force_quotes: true)
146
- csv << column_names
147
- each { |record| csv << record.to_a }
151
+ # @param [optional String, IO] path_or_io String path, IO-like object, or nil for STDOUT
152
+ def to_csv(path_or_io = nil)
153
+ io = case path_or_io
154
+ when nil then $stdout
155
+ when String then File.open(path_or_io, 'w')
156
+ else path_or_io
157
+ end
158
+ csv = CSV.new(io, force_quotes: true)
159
+ csv << column_names.map { |name| csv_safe_value(name) }
160
+ each { |record| csv << record.to_a.map { |value| csv_safe_value(value) } }
148
161
  end
149
162
 
150
163
  # Human readable version description
@@ -159,7 +172,7 @@ module DBF
159
172
  # @param [String] string
160
173
  # @return [String]
161
174
  def encode_string(string) # :nodoc:
162
- string.force_encoding(@encoding).encode(Encoding.default_external, undef: :replace, invalid: :replace)
175
+ Encoder.encode(string, @encoding)
163
176
  end
164
177
 
165
178
  # Encoding specified in the file header
@@ -171,6 +184,31 @@ module DBF
171
184
 
172
185
  private
173
186
 
187
+ # Neutralizes spreadsheet formula injection (CWE-1236) on CSV export by
188
+ # prefixing a single quote to string cells that begin with a formula
189
+ # trigger character. Non-string values (numbers, dates, booleans) are
190
+ # returned unchanged. The leading byte is compared numerically so that a
191
+ # value whose bytes are invalid in its encoding cannot raise here.
192
+ def csv_safe_value(value) # :nodoc:
193
+ return value unless value.is_a?(::String)
194
+
195
+ value = csv_compatible(value)
196
+ return value unless CSV_FORMULA_TRIGGERS.include?(value.getbyte(0))
197
+
198
+ quote = +"'"
199
+ quote.force_encoding(value.encoding) + value
200
+ end
201
+
202
+ # A row is written as a single string, so a binary (General/OLE) or
203
+ # invalidly encoded cell would raise Encoding::CompatibilityError when
204
+ # combined with text cells. Represent those bytes instead of raising.
205
+ def csv_compatible(value) # :nodoc:
206
+ return value if value.ascii_only?
207
+ return value if value.valid_encoding? && value.encoding != Encoding::BINARY
208
+
209
+ value.dup.force_encoding(Encoding::UTF_8).scrub('?')
210
+ end
211
+
174
212
  def version_config
175
213
  @version_config ||= VersionConfig.new(version)
176
214
  end
data/lib/dbf/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DBF
4
- VERSION = '5.2.0'
4
+ VERSION = '5.4.0'
5
5
  end
@@ -68,11 +68,22 @@ module DBF
68
68
  end
69
69
  end
70
70
 
71
+ # Returns the Column.new arguments for the next column descriptor, or nil
72
+ # when the file ends mid-descriptor. Unpacking a short descriptor would
73
+ # otherwise raise ArgumentError ("x outside of string").
71
74
  def read_column_args(table, io)
75
+ size, format, defaults = column_layout
76
+ data = io.read(size)
77
+ return nil unless data && data.bytesize == size
78
+
79
+ [table, *data.unpack(format), *defaults]
80
+ end
81
+
82
+ def column_layout # :nodoc:
72
83
  case version
73
- when '02' then [table, *io.read(header_size * 2).unpack('A11 a C'), 0]
74
- when '04', '8c' then [table, *io.read(48).unpack('A32 a C C x13')]
75
- else [table, *io.read(header_size).unpack('A11 a x4 C2')]
84
+ when '02' then [header_size * 2, 'A11 a C', [0]]
85
+ when '04', '8c' then [48, 'A32 a C C x13', []]
86
+ else [header_size, 'A11 a x4 C2', []]
76
87
  end
77
88
  end
78
89
  end
data/lib/dbf.rb CHANGED
@@ -14,6 +14,7 @@ require 'dbf/record_context'
14
14
  require 'dbf/column_type'
15
15
  require 'dbf/column'
16
16
  require 'dbf/encodings'
17
+ require 'dbf/encoder'
17
18
  require 'dbf/header'
18
19
  require 'dbf/version_config'
19
20
  require 'dbf/file_handler'
@@ -25,3 +26,4 @@ require 'dbf/memo/dbase3'
25
26
  require 'dbf/memo/dbase4'
26
27
  require 'dbf/memo/foxpro'
27
28
  require 'dbf/database/foxpro'
29
+ require 'dbf/cli'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dbf
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.2.0
4
+ version: 5.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keith Morrison
@@ -37,10 +37,12 @@ files:
37
37
  - bin/dbf
38
38
  - dbf.gemspec
39
39
  - lib/dbf.rb
40
+ - lib/dbf/cli.rb
40
41
  - lib/dbf/column.rb
41
42
  - lib/dbf/column_builder.rb
42
43
  - lib/dbf/column_type.rb
43
44
  - lib/dbf/database/foxpro.rb
45
+ - lib/dbf/encoder.rb
44
46
  - lib/dbf/encodings.rb
45
47
  - lib/dbf/file_handler.rb
46
48
  - lib/dbf/find.rb
@@ -77,7 +79,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
77
79
  - !ruby/object:Gem::Version
78
80
  version: '0'
79
81
  requirements: []
80
- rubygems_version: 3.7.2
82
+ rubygems_version: 4.0.17
81
83
  specification_version: 4
82
84
  summary: Read xBase files
83
85
  test_files: []