dbf 5.5.0 → 5.6.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: ff36c9c3d6b7a558cd5bf0f703f64f0cb7b22cbf6b4c52b843c855328a712a0a
4
- data.tar.gz: ac0f725043e7dc9102a32381f6041e54ebd852a67a264778ae58b2a59430b01e
3
+ metadata.gz: e9d4250b08de3a7a96f24fff3f55e5457524288951b9d0ca8ffc8127aae2c790
4
+ data.tar.gz: 78d946bd48943b8d6527aac3e982017133480d8263bc5025d2c8d3a85407185e
5
5
  SHA512:
6
- metadata.gz: 269035188f9741d80575efca78ae0bda153c4f27c79dbe3f3e6221fc714be22eb2fb9ec39899dcfc9d489f76d60bfaf1f892d6eeaaeea80fa406f5f401e909d7
7
- data.tar.gz: 6203ede51060bad9d193b15bb98d814aa870d99dd29d6eb8ddd4cb2b5e04f078f522fb7f3051befcbebe11be86a13307da38f9981bbcb704c5307ebdb16dd6a3
6
+ metadata.gz: b2e613d19239abb6df53c941c952da129bb77ff6cfbcc7b91cdeac10d9f8ad28724102c20ad6f53533959e0adc1f0f159ffadd0811f4997b623633d5c9fa2edf
7
+ data.tar.gz: 5964675e521e61be0d3da141c642cfc13c0e71b232ae2a4070053d0764b3e3ee1ca653a786ef70f3001b98f24b4eaf05eee9c4a81a86fd14b2506a939ea2eba3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.6.0
4
+
5
+ ### Fixed
6
+
7
+ - Security (CWE-200, CWE-400): memo auto-discovery now looks only for a memo with exactly the table's basename (`.fpt`/`.dbt`, any case), by scanning the directory rather than globbing. Previously `1.dbf` could pick up `12.fpt`, and glob metacharacters in an uploaded filename were expanded
8
+ - Security (CWE-400): column parsing stops at `header_length` and drops columns that do not fit within `record_length`, so a small crafted file can no longer declare thousands of overlapping columns and cause quadratic decoding work and output
9
+ - Security (CWE-838): string values that need no transcoding have invalid bytes replaced, as transcoded values already did, so they no longer raise in JSON generation, HTML escaping, or database inserts
10
+ - Security (CWE-248): a DateTime (`T`) column narrower than 8 bytes decodes to nil instead of raising `NoMethodError`
11
+ - Security (CWE-915): column names (and Visual FoxPro `.dbc` table names) such as `to_str`, `to_ary`, `to_hash`, `to_proc`, or `to_partial_path` are no longer exposed as methods, so a crafted file cannot hijack implicit conversions or Rails rendering; those columns stay readable via `#[]` and `#attributes`
12
+ - Security (CWE-129): `Table#record` returns nil for a negative index instead of reading header bytes as a record or raising `Errno::EINVAL`
13
+ - Security (CWE-150): CLI terminal output also replaces UTF-8 encoded C1 control characters (U+0080–U+009F, e.g. the single-character CSI U+009B)
14
+ - Security (CWE-400): a table declares no more columns than its records have bytes, so zero-length columns can no longer bypass the column bound (an 84 KB file previously produced 2,000 nil cells per one-byte record: a 120 MB CSV and 34 s of work)
15
+ - Security (CWE-248): a dBase III/IV memo pointer too large to seek to decodes to nil instead of raising `RangeError`
16
+ - Tables with the Macintosh Central European language driver (`0x97`) decode through a vendored code page table instead of raising `Encoding::ConverterNotFoundError`, since Ruby has no converter for `macCentEuro`
17
+ - dBase III memos end at the `0x1A` terminator; bytes left over after it in the same block are no longer appended to the memo
18
+ - Visual FoxPro long column names from a `.dbc` container are no longer decoded a second time with the table's code page, which garbled non-ASCII names (`Café` became `Café`)
19
+ - Security (CWE-248): `Table.new` raises only `DBF::Error` subclasses for a bad path or encoding, so rescuing `DBF::Error` is enough. A directory or unreadable file (data or memo) raises the new `DBF::FileAccessError` instead of `Errno::EISDIR`/`Errno::EACCES`. A missing memo path, or a path containing a null byte, raises `DBF::FileNotFoundError` instead of `Errno::ENOENT`/`ArgumentError`. An unknown encoding, or one Ruby cannot transcode such as UTF-7, raises the new `DBF::UnsupportedEncodingError` when the table is opened, rather than `ArgumentError` or `Encoding::ConverterNotFoundError` on the first read. The data file is closed if its memo cannot be opened
20
+ - Security (CWE-209): `DBF::FileNotFoundError` and `DBF::FileAccessError` messages name only the file (`file not found: data.dbf`), not its directory, so an app that shows error messages to users does not reveal server paths. The full path is available as `#path`. Both now inherit from the new `DBF::FileError`
21
+ - The `dbf` command reports a file that cannot be opened (such as a directory) as an error and exits 1, instead of printing a stack trace
22
+
23
+ ### Changed
24
+
25
+ - Code that rescued `ArgumentError`, `Errno::*` or `Encoding::ConverterNotFoundError` from `Table.new` should rescue `DBF::Error` (or the specific subclasses above) instead. Passing something other than a path or IO still raises `ArgumentError`
26
+ - File error messages no longer include the directory (`file not found: data.dbf` rather than `file not found: /srv/uploads/data.dbf`). Use `DBF::FileError#path` for the full path
27
+
28
+ ### Added
29
+
30
+ - Security (CWE-400): optional `max_memo_size:` for `DBF::Table.new`, `DBF::Table.open` and `DBF::Database::Foxpro.new`. Each memo is cut off after that many bytes, and no more than that is read from the memo file, so a small table whose records all point at one large (or unterminated) memo can no longer produce output far larger than the upload. Memos are read in full by default
31
+ - `rake fuzz`: coverage-guided mutation fuzzer for the read path (`spec/fuzz/fuzz.rb`), also run as a non-blocking CI job
32
+ - A warning when column descriptors are ignored because they do not fit within the header length or record length, so a file written with wrong length fields no longer loses columns silently. The warning holds no text from the file, and is silenced when `$VERBOSE` is nil
33
+ - README: "Processing untrusted files" section with guidance for accepting DBF uploads, including how to strip Unicode bidirectional controls and zero-width characters, which the library passes through
34
+
3
35
  ## 5.5.0
4
36
 
5
37
  ### Added
@@ -15,7 +47,6 @@
15
47
  - Docs site (dbf.infused.org): YARD docs are now built and deployed by CI instead of being committed to the repository; the Pages workflow's deprecated v2/v3 actions (whose artifact backend was shut down in early 2025) are updated to current versions, repairing the broken deploy
16
48
  - Dependabot updates for GitHub Actions and gems (weekly), a security policy (SECURITY.md) with private vulnerability reporting, and contributor documentation (CONTRIBUTING.md)
17
49
  - Automated releases: pushing a `vX.Y.Z` tag now builds and publishes the gem to RubyGems.org from CI via Trusted Publishing (OIDC) — no long-lived API key. `bundler/gem_tasks` is loaded in the Rakefile, providing `rake build` / `rake release`. Git tags resume at v5.4.0; versions 4.0.0–5.3.0 were released without tags
18
-
19
50
  - Ractor safety: all library constants are verified Ractor-shareable and reading/exporting tables inside non-main Ractors is covered by specs
20
51
  - CLI: `-j` exports records as a JSON array and `-J` as JSON Lines (one record per line, streamed in constant memory); deleted records are skipped, control bytes are escaped by JSON itself, and binary cells are scrubbed instead of raising
21
52
 
data/README.md CHANGED
@@ -398,6 +398,46 @@ Data type descriptions
398
398
  * O = Double
399
399
  * + = Autoincrement
400
400
 
401
+ ## Processing untrusted files
402
+
403
+ The parser is hardened against malformed files: crafted headers cannot force
404
+ huge allocations, unbounded loops, path traversal, or crashes from truncated
405
+ data. If you accept DBF uploads (for example in a web application), also:
406
+
407
+ * Treat every column name and value as untrusted text. HTML-escape it before
408
+ rendering it, and escape JSON before embedding it in a `<script>` tag.
409
+ * Pass the upload as a `StringIO` (with an explicit memo `StringIO` if there is
410
+ one) rather than as a path, so memo files are never looked up in a shared
411
+ upload directory.
412
+ * Limit upload size and processing time. Many records can point at the same
413
+ large memo, so exported output can be far larger than the uploaded files.
414
+ Pass `max_memo_size:` to cut off each memo after that many bytes:
415
+
416
+ ```ruby
417
+ DBF::Table.new(upload, memo_upload, max_memo_size: 64 * 1024)
418
+ ```
419
+
420
+ `DBF::Database::Foxpro.new` takes the same option.
421
+ * Rescue `DBF::Error`, and also `StandardError` if a malformed file must never
422
+ fail a request.
423
+ * `Table#to_csv` prefixes a `'` to string cells that begin with `=`, `+`, `-`,
424
+ `@`, tab, or carriage return so spreadsheets do not run them as formulas.
425
+ * Text is passed through as it is, including Unicode bidirectional controls
426
+ (such as U+202E RIGHT-TO-LEFT OVERRIDE) and zero-width characters. These
427
+ make displayed text differ from the underlying data (in a web page, a CSV
428
+ export, or `dbf` terminal output), but some real data needs them, so the
429
+ library does not remove them. Strip or flag them yourself where that
430
+ matters:
431
+
432
+ ```ruby
433
+ value.gsub(/[​-‏‪-‮⁠-⁤⁦-⁩]/, '')
434
+ ```
435
+
436
+ * Column descriptors that do not fit within the header length or record
437
+ length are ignored, and the library prints a warning (silenced when
438
+ `$VERBOSE` is nil). If a file you trust loses columns, its header has wrong
439
+ length fields.
440
+
401
441
  ## Limitations
402
442
 
403
443
  * DBF is read-only
data/lib/dbf/cli.rb CHANGED
@@ -22,6 +22,9 @@ module DBF
22
22
  CONTROL_BYTES = /[\x00-\x1F\x7F]/n
23
23
  # The same, but keeping CR and LF so CSV row separators survive.
24
24
  CONTROL_BYTES_KEEPING_NEWLINES = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/n
25
+ # C1 controls (U+0080..U+009F) as UTF-8 bytes. Some terminals honor
26
+ # these, e.g. U+009B as a single-character CSI.
27
+ UTF8_C1_CONTROLS = /\xC2[\x80-\x9F]/n
25
28
 
26
29
  # Replaces terminal control bytes. Substitution happens on a byte copy so
27
30
  # a value whose bytes are invalid in its encoding cannot raise here.
@@ -31,7 +34,10 @@ module DBF
31
34
  # @return [String]
32
35
  def self.sanitize(value, pattern = CONTROL_BYTES)
33
36
  string = value.to_s
34
- string.b.gsub(pattern, '?').force_encoding(string.encoding)
37
+ encoding = string.encoding
38
+ bytes = string.b.gsub(pattern, '?')
39
+ bytes = bytes.gsub(UTF8_C1_CONTROLS, '?') if encoding == Encoding::UTF_8
40
+ bytes.force_encoding(encoding)
35
41
  end
36
42
 
37
43
  # Wraps an IO so text written to an interactive terminal is stripped of
@@ -94,8 +100,8 @@ module DBF
94
100
  send(action, filename) if action
95
101
  end
96
102
  0
97
- rescue DBF::FileNotFoundError => e
98
- @stderr.puts "DBF::FileNotFoundError: #{e.message}"
103
+ rescue DBF::FileError => e
104
+ @stderr.puts "#{e.class}: #{e.message}"
99
105
  1
100
106
  end
101
107
 
data/lib/dbf/column.rb CHANGED
@@ -15,7 +15,7 @@ module DBF
15
15
 
16
16
  attr_reader :name, :type, :length, :decimal
17
17
 
18
- # rubocop:disable Style/MutableConstant
18
+ # rubocop:disable-next Style/MutableConstant
19
19
  TYPE_CAST_CLASS = {
20
20
  N: ColumnType::Number,
21
21
  I: ColumnType::SignedLong,
@@ -29,7 +29,6 @@ module DBF
29
29
  G: ColumnType::General,
30
30
  :+ => ColumnType::AutoIncrement
31
31
  }
32
- # rubocop:enable Style/MutableConstant
33
32
  TYPE_CAST_CLASS.default = ColumnType::String
34
33
  TYPE_CAST_CLASS.freeze
35
34
 
@@ -53,6 +52,17 @@ module DBF
53
52
 
54
53
  def encoding = @table.encoding
55
54
 
55
+ # Returns a copy of the column with a different name. The name is used
56
+ # as given rather than decoded from the table's code page, so it must
57
+ # already be a decoded string, such as a Visual FoxPro long name read
58
+ # from the .dbc container.
59
+ #
60
+ # @param name [String]
61
+ # @return [DBF::Column]
62
+ def with_name(name) # :nodoc:
63
+ dup.tap { |column| column.rename(name) }
64
+ end
65
+
56
66
  # @param value [String]
57
67
  def type_cast(value)
58
68
  type_cast_class.type_cast(value)
@@ -84,6 +94,14 @@ module DBF
84
94
  @underscored_name ||= name.gsub(/([a-z\d])([A-Z])/, '\1_\2').tr('-', '_').downcase
85
95
  end
86
96
 
97
+ protected
98
+
99
+ def rename(name) # :nodoc:
100
+ @name = name
101
+ @underscored_name = nil
102
+ validate_name
103
+ end
104
+
87
105
  private
88
106
 
89
107
  def clean(value) # :nodoc:
@@ -2,22 +2,46 @@
2
2
 
3
3
  module DBF
4
4
  class ColumnBuilder
5
- def initialize(table, data, version_config)
5
+ def initialize(table, data, version_config, header_length:, record_length:)
6
6
  @table = table
7
7
  @data = data
8
8
  @version_config = version_config
9
+ @header_length = header_length
10
+ # One byte of every record is the deletion flag
11
+ @max_width = record_length - 1
9
12
  end
10
13
 
11
14
  def build
12
15
  safe_seek do
13
16
  @data.seek(@version_config.header_size)
17
+ width = 0
14
18
  [].tap do |columns|
15
19
  until end_of_record?
20
+ count = columns.size
21
+ # Descriptors live inside the header. Stop at header_length so a
22
+ # crafted file cannot declare thousands of columns over its
23
+ # record area.
24
+ if past_header?
25
+ warn_ignored(count, "header length of #{@header_length}")
26
+ break
27
+ end
28
+
16
29
  args = @version_config.read_column_args(@table, @data)
17
30
  # A descriptor truncated by EOF is returned as nil, or unpacks to
18
31
  # a nil length; stop rather than constructing an invalid column
19
32
  # (which would crash on nil < 0).
20
- break if args.nil? || args[3].nil?
33
+ length = args&.[](3)
34
+ break if length.nil?
35
+
36
+ # Columns longer than the record would read into the following
37
+ # records; stop so a crafted file cannot multiply decoding work.
38
+ # Zero-length columns take no record bytes, so also allow at most
39
+ # one column per record byte.
40
+ width += length
41
+ if width > @max_width || count >= @max_width
42
+ warn_ignored(count, "record length of #{@max_width + 1}")
43
+ break
44
+ end
21
45
 
22
46
  columns << Column.new(*args)
23
47
  end
@@ -27,6 +51,18 @@ module DBF
27
51
 
28
52
  private
29
53
 
54
+ def past_header?
55
+ @data.pos + @version_config.column_layout.first > @header_length
56
+ end
57
+
58
+ # Columns that do not fit are dropped rather than read, so say so: a file
59
+ # written with a wrong header or record length would otherwise lose
60
+ # columns silently. The message holds no text from the file, so a
61
+ # crafted column name cannot inject lines into a log.
62
+ def warn_ignored(kept, limit)
63
+ warn "DBF: ignored the columns after the first #{kept}: they do not fit within the #{limit} bytes"
64
+ end
65
+
30
66
  def end_of_record?
31
67
  safe_seek do
32
68
  byte = @data.read(1)
@@ -121,7 +121,8 @@ module DBF
121
121
  # @param value [String]
122
122
  def type_cast(value)
123
123
  days, msecs = value.unpack('l2')
124
- return nil if days.nil? || days.zero?
124
+ # A column narrower than 8 bytes leaves msecs nil
125
+ return nil if days.nil? || msecs.nil? || days.zero?
125
126
 
126
127
  secs = (msecs / 1000).to_i
127
128
  ::DateTime.jd(days, (secs / 3600).to_i, (secs / 60).to_i % 60, secs % 60).to_time
@@ -170,7 +171,13 @@ module DBF
170
171
  private
171
172
 
172
173
  def encode(value)
173
- return value.force_encoding(encoding) unless @needs_encode
174
+ unless @needs_encode
175
+ # No transcoding happens here, so replace invalid bytes the same
176
+ # way Encoder.encode does rather than handing callers a string that
177
+ # raises in JSON generation, HTML escaping, or a database insert.
178
+ value.force_encoding(encoding)
179
+ return value.valid_encoding? ? value : value.scrub
180
+ end
174
181
 
175
182
  Encoder.encode(value, encoding, @target_encoding)
176
183
  end
@@ -20,13 +20,16 @@ module DBF
20
20
  # contacts = db.contacts.record(0)
21
21
  #
22
22
  # @param path [String]
23
- def initialize(path)
23
+ # @param max_memo_size [optional Integer] Bytes to read from each memo
24
+ # of the container and its tables; see DBF::Table.new
25
+ def initialize(path, max_memo_size: nil)
24
26
  @path = path
25
27
  @dirname = File.dirname(@path)
26
- @db = DBF::Table.new(@path)
28
+ @max_memo_size = max_memo_size
29
+ @db = DBF::Table.new(@path, max_memo_size:)
27
30
  @tables = extract_dbc_data
28
31
  rescue Errno::ENOENT
29
- raise DBF::FileNotFoundError, "file not found: #{path}"
32
+ raise FileHandler.not_found(path)
30
33
  end
31
34
 
32
35
  def table_names
@@ -38,7 +41,7 @@ module DBF
38
41
  # @param name [String]
39
42
  # @return [DBF::Table]
40
43
  def table(name)
41
- Table.new(table_path(name), long_names: @tables[name])
44
+ Table.new(table_path(name), long_names: @tables[name], max_memo_size: @max_memo_size)
42
45
  end
43
46
 
44
47
  # Searches the database directory for the table's dbf file
@@ -71,15 +74,21 @@ module DBF
71
74
 
72
75
  def method_missing(method, *args) # :nodoc:
73
76
  name = method.to_s
74
- table_names.index(name) ? table(name) : super
77
+ table_method?(name) ? table(name) : super
75
78
  end
76
79
 
77
80
  def respond_to_missing?(method, *)
78
- table_names.index(method.to_s) || super
81
+ table_method?(method.to_s) || super
79
82
  end
80
83
 
81
84
  private
82
85
 
86
+ # Table names come from the container file, so implicit-conversion
87
+ # method names are never dispatched to a table (see Record).
88
+ def table_method?(name) # :nodoc:
89
+ !Record::RESERVED_METHODS.include?(name) && table_names.include?(name)
90
+ end
91
+
83
92
  # This method extracts the data from the database container. This is
84
93
  # just an ordinary table with a treelike structure. Field definitions
85
94
  # are in the same order as in the linked tables but only the long name
@@ -108,17 +117,18 @@ module DBF
108
117
  class Table < DBF::Table
109
118
  attr_reader :long_names
110
119
 
111
- def initialize(path, long_names:)
120
+ def initialize(path, long_names:, max_memo_size: nil)
112
121
  @long_names = long_names
113
- super(path)
122
+ super(path, max_memo_size:)
114
123
  end
115
124
 
125
+ # Replaces each column's short name with its long name from the
126
+ # container. Long names were decoded when the .dbc was read, so they
127
+ # are not decoded again with this table's code page.
116
128
  def build_columns # :nodoc:
117
- # modify the column definitions to use the long names as the
118
- # columnname property is readonly, recreate the column definitions
119
129
  super.each_with_index.map do |column, index|
120
- long_name = long_names[index] || column.name
121
- Column.new(self, long_name, column.type, column.length, column.decimal)
130
+ long_name = long_names[index]
131
+ long_name ? column.with_name(long_name) : column
122
132
  end
123
133
  end
124
134
  end
data/lib/dbf/encoder.rb CHANGED
@@ -4,12 +4,15 @@ module DBF
4
4
  # Transcodes strings from a source encoding to a target encoding.
5
5
  #
6
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.
7
+ # with String#encode. A few (Mazovia cp620, Kamenický cp895, Macintosh
8
+ # Central European) have no Ruby converter and are decoded through
9
+ # vendored byte-to-Unicode tables.
9
10
  module Encoder
10
11
  # 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
12
+ # https://github.com/SheetJS/js-codepage/blob/master/codepages/620.TBL,
13
+ # https://github.com/SheetJS/js-codepage/blob/master/codepages/895.TBL and
14
+ # (Mac Central European) Python's mac_latin2 codec, which matches iconv's
15
+ # MACCENTRALEUROPE
13
16
 
14
17
  # Mazovia (Polish) MS-DOS
15
18
  CP620_TO_UNICODE = [
@@ -83,9 +86,46 @@ module DBF
83
86
  0x00B0, 0x2219, 0x00B7, 0x221A, 0x207F, 0x00B2, 0x25A0, 0x00A0
84
87
  ].freeze
85
88
 
89
+ # Macintosh Central European (Ruby has the encoding but no converter)
90
+ MACCENTEURO_TO_UNICODE = [
91
+ 0x0000, 0x0001, 0x0002, 0x0003, 0x0004, 0x0005, 0x0006, 0x0007,
92
+ 0x0008, 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, 0x000E, 0x000F,
93
+ 0x0010, 0x0011, 0x0012, 0x0013, 0x0014, 0x0015, 0x0016, 0x0017,
94
+ 0x0018, 0x0019, 0x001A, 0x001B, 0x001C, 0x001D, 0x001E, 0x001F,
95
+ 0x0020, 0x0021, 0x0022, 0x0023, 0x0024, 0x0025, 0x0026, 0x0027,
96
+ 0x0028, 0x0029, 0x002A, 0x002B, 0x002C, 0x002D, 0x002E, 0x002F,
97
+ 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0035, 0x0036, 0x0037,
98
+ 0x0038, 0x0039, 0x003A, 0x003B, 0x003C, 0x003D, 0x003E, 0x003F,
99
+ 0x0040, 0x0041, 0x0042, 0x0043, 0x0044, 0x0045, 0x0046, 0x0047,
100
+ 0x0048, 0x0049, 0x004A, 0x004B, 0x004C, 0x004D, 0x004E, 0x004F,
101
+ 0x0050, 0x0051, 0x0052, 0x0053, 0x0054, 0x0055, 0x0056, 0x0057,
102
+ 0x0058, 0x0059, 0x005A, 0x005B, 0x005C, 0x005D, 0x005E, 0x005F,
103
+ 0x0060, 0x0061, 0x0062, 0x0063, 0x0064, 0x0065, 0x0066, 0x0067,
104
+ 0x0068, 0x0069, 0x006A, 0x006B, 0x006C, 0x006D, 0x006E, 0x006F,
105
+ 0x0070, 0x0071, 0x0072, 0x0073, 0x0074, 0x0075, 0x0076, 0x0077,
106
+ 0x0078, 0x0079, 0x007A, 0x007B, 0x007C, 0x007D, 0x007E, 0x007F,
107
+ 0x00C4, 0x0100, 0x0101, 0x00C9, 0x0104, 0x00D6, 0x00DC, 0x00E1,
108
+ 0x0105, 0x010C, 0x00E4, 0x010D, 0x0106, 0x0107, 0x00E9, 0x0179,
109
+ 0x017A, 0x010E, 0x00ED, 0x010F, 0x0112, 0x0113, 0x0116, 0x00F3,
110
+ 0x0117, 0x00F4, 0x00F6, 0x00F5, 0x00FA, 0x011A, 0x011B, 0x00FC,
111
+ 0x2020, 0x00B0, 0x0118, 0x00A3, 0x00A7, 0x2022, 0x00B6, 0x00DF,
112
+ 0x00AE, 0x00A9, 0x2122, 0x0119, 0x00A8, 0x2260, 0x0123, 0x012E,
113
+ 0x012F, 0x012A, 0x2264, 0x2265, 0x012B, 0x0136, 0x2202, 0x2211,
114
+ 0x0142, 0x013B, 0x013C, 0x013D, 0x013E, 0x0139, 0x013A, 0x0145,
115
+ 0x0146, 0x0143, 0x00AC, 0x221A, 0x0144, 0x0147, 0x2206, 0x00AB,
116
+ 0x00BB, 0x2026, 0x00A0, 0x0148, 0x0150, 0x00D5, 0x0151, 0x014C,
117
+ 0x2013, 0x2014, 0x201C, 0x201D, 0x2018, 0x2019, 0x00F7, 0x25CA,
118
+ 0x014D, 0x0154, 0x0155, 0x0158, 0x2039, 0x203A, 0x0159, 0x0156,
119
+ 0x0157, 0x0160, 0x201A, 0x201E, 0x0161, 0x015A, 0x015B, 0x00C1,
120
+ 0x0164, 0x0165, 0x00CD, 0x017D, 0x017E, 0x016A, 0x00D3, 0x00D4,
121
+ 0x016B, 0x016E, 0x00DA, 0x016F, 0x0170, 0x0171, 0x0172, 0x0173,
122
+ 0x00DD, 0x00FD, 0x0137, 0x017B, 0x0141, 0x017C, 0x0122, 0x02C7
123
+ ].freeze
124
+
86
125
  CUSTOM_TABLES = {
87
126
  'cp620' => CP620_TO_UNICODE,
88
- 'cp895' => CP895_TO_UNICODE
127
+ 'cp895' => CP895_TO_UNICODE,
128
+ 'maccenteuro' => MACCENTEURO_TO_UNICODE
89
129
  }.freeze
90
130
 
91
131
  # Is the encoding a code page without a Ruby Encoding, decoded through
@@ -97,6 +137,21 @@ module DBF
97
137
  encoding.is_a?(::String) && CUSTOM_TABLES.key?(encoding.downcase)
98
138
  end
99
139
 
140
+ # Raise unless strings in the encoding can be transcoded to Unicode, so
141
+ # an unknown name or a dummy encoding such as UTF-7 fails up front with
142
+ # a DBF::Error rather than with ArgumentError or
143
+ # Encoding::ConverterNotFoundError on the first read.
144
+ #
145
+ # @param encoding [String, Encoding]
146
+ # @raise [DBF::UnsupportedEncodingError]
147
+ def self.check(encoding)
148
+ return if custom?(encoding)
149
+
150
+ (+'').force_encoding(encoding).encode(Encoding::UTF_8)
151
+ rescue ArgumentError, TypeError, Encoding::ConverterNotFoundError
152
+ raise DBF::UnsupportedEncodingError, "unsupported encoding: #{encoding}"
153
+ end
154
+
100
155
  # Transcode a string from source_encoding to target_encoding
101
156
  #
102
157
  # @param string [String]
data/lib/dbf/errors.rb CHANGED
@@ -6,7 +6,30 @@ module DBF
6
6
  class Error < StandardError
7
7
  end
8
8
 
9
- class FileNotFoundError < Error
9
+ # A file cannot be opened. The message names only the file, not its
10
+ # directory, so an app that shows error messages to users does not reveal
11
+ # server paths; #path has the full path for logging.
12
+ class FileError < Error
13
+ # @return [String, nil] the full path of the file
14
+ attr_reader :path
15
+
16
+ def initialize(message = nil, path: nil)
17
+ super(message)
18
+ @path = path
19
+ end
20
+ end
21
+
22
+ class FileNotFoundError < FileError
23
+ end
24
+
25
+ # A file exists but cannot be opened, e.g. it is a directory or is not
26
+ # readable.
27
+ class FileAccessError < FileError
28
+ end
29
+
30
+ # The encoding passed to Table.new is unknown, or Ruby cannot transcode
31
+ # it to Unicode.
32
+ class UnsupportedEncodingError < Error
10
33
  end
11
34
 
12
35
  class NoColumnsDefined < Error
@@ -9,7 +9,7 @@ module DBF
9
9
  when StringIO
10
10
  data
11
11
  when String
12
- File.open(data, 'rb')
12
+ open_file(data)
13
13
  else
14
14
  raise ArgumentError, 'data must be a file path or an IO-like object responding to #read and #seek' unless data.respond_to?(:read) && data.respond_to?(:seek)
15
15
 
@@ -18,17 +18,41 @@ module DBF
18
18
  data.binmode if data.respond_to?(:binmode)
19
19
  data
20
20
  end
21
- rescue Errno::ENOENT
22
- raise DBF::FileNotFoundError, "file not found: #{data}"
23
21
  end
24
22
 
25
- def open_memo(data, memo, memo_class, version)
23
+ # Opens a file for binary reading, raising DBF errors instead of the
24
+ # system errors File.open raises, so callers need only rescue DBF::Error.
25
+ # A path containing a null byte cannot name a file, so it is not found.
26
+ # A directory opens without error but fails on the first read, so it is
27
+ # rejected here.
28
+ def open_file(path)
29
+ raise Errno::EISDIR if File.directory?(path)
30
+
31
+ File.open(path, 'rb')
32
+ rescue Errno::ENOENT, Errno::ENOTDIR, ArgumentError
33
+ raise not_found(path)
34
+ rescue SystemCallError => e
35
+ reason = SystemCallError.new(nil, e.errno).message
36
+ raise DBF::FileAccessError.new("cannot open file: #{display_name(path)} (#{reason})", path:)
37
+ end
38
+
39
+ def not_found(path)
40
+ DBF::FileNotFoundError.new("file not found: #{display_name(path)}", path:)
41
+ end
42
+
43
+ # The file name without its directory, for error messages. A null byte,
44
+ # which File.basename rejects, is shown as "?".
45
+ def display_name(path)
46
+ File.basename(path.to_s.tr("\x00", '?'))
47
+ end
48
+
49
+ def open_memo(data, memo, memo_class, version, max_size: nil)
26
50
  if memo
27
51
  meth = memo.is_a?(String) ? :open : :new
28
- memo_class.send(meth, memo, version)
52
+ memo_class.send(meth, memo, version, max_size:)
29
53
  elsif (path = data_path(data))
30
- found = Dir.glob(memo_search_path(path)).first
31
- found && memo_class.open(found, version)
54
+ found = find_memo(path)
55
+ found && memo_class.open(found, version, max_size:)
32
56
  end
33
57
  end
34
58
 
@@ -38,10 +62,23 @@ module DBF
38
62
  data.path if data.respond_to?(:path)
39
63
  end
40
64
 
41
- def memo_search_path(path)
65
+ MEMO_EXTENSIONS = %w[fpt dbt].freeze
66
+
67
+ # Finds the memo file sitting next to the data file: same basename, with
68
+ # an .fpt or .dbt extension in any case. The directory is scanned rather
69
+ # than globbed so glob metacharacters in an uploaded filename cannot be
70
+ # expanded, and the basename must match exactly so data.dbf never picks
71
+ # up an unrelated memo such as data2.fpt.
72
+ def find_memo(path)
42
73
  dirname = File.dirname(path)
43
74
  basename = File.basename(path, '.*')
44
- "#{dirname}/#{basename}*.{fpt,FPT,dbt,DBT}"
75
+ entries = Dir.children(dirname).sort.each_with_object({}) do |child, index|
76
+ index[child.downcase(:ascii)] ||= child
77
+ end
78
+ entry = MEMO_EXTENSIONS.filter_map { |ext| entries["#{basename}.#{ext}".downcase(:ascii)] }.first
79
+ entry && File.join(dirname, entry)
80
+ rescue SystemCallError
81
+ nil
45
82
  end
46
83
  end
47
84
  end
data/lib/dbf/memo/base.rb CHANGED
@@ -6,19 +6,25 @@ module DBF
6
6
  BLOCK_HEADER_SIZE = 8
7
7
  BLOCK_SIZE = 512
8
8
 
9
- def self.open(filename, version)
10
- new(File.open(filename, 'rb'), version)
9
+ def self.open(filename, version, max_size: nil)
10
+ new(FileHandler.open_file(filename), version, max_size:)
11
11
  end
12
12
 
13
- def initialize(data, version)
13
+ # @param max_size [Integer, nil] bytes to read from each memo; longer
14
+ # memos are cut off. nil reads every memo in full.
15
+ def initialize(data, version, max_size: nil)
14
16
  @data = data
15
17
  @version = version
18
+ @max_size = max_size
16
19
  end
17
20
 
18
21
  def get(start_block)
19
22
  return nil unless start_block > 0
20
23
 
21
24
  build_memo start_block
25
+ rescue RangeError
26
+ # A corrupt memo pointer too large to seek to has no memo
27
+ nil
22
28
  end
23
29
 
24
30
  def close
@@ -31,7 +37,12 @@ module DBF
31
37
 
32
38
  private
33
39
 
34
- attr_reader :data
40
+ attr_reader :data, :max_size
41
+
42
+ # Bounds a memo length by max_size, when one is set
43
+ def limit(length) # :nodoc:
44
+ max_size && length > max_size ? max_size : length
45
+ end
35
46
 
36
47
  def offset(start_block) # :nodoc:
37
48
  start_block * block_size
@@ -3,6 +3,9 @@
3
3
  module DBF
4
4
  module Memo
5
5
  class Dbase3 < Base
6
+ # Memo text up to, and whether it reached, the 0x1A terminator
7
+ TERMINATOR_PATTERN = /\A([^\x1A]*)(\x1A)?/n
8
+
6
9
  def build_memo(start_block) # :nodoc:
7
10
  data.seek offset(start_block)
8
11
  memo_string = +''
@@ -12,11 +15,22 @@ module DBF
12
15
  # than crashing on nil.gsub.
13
16
  break if block.nil?
14
17
 
15
- block = block.gsub(/(\000|\032)/, '')
16
- memo_string << block
17
- break if block.size < BLOCK_SIZE
18
+ # The memo ends at its 0x1A terminator; bytes after it in the same
19
+ # block are leftovers, not memo text.
20
+ text, terminated = TERMINATOR_PATTERN.match(block).captures
21
+ text = text.delete("\000")
22
+ memo_string << text
23
+ break if terminated || text.size < BLOCK_SIZE || full?(memo_string)
18
24
  end
19
- memo_string
25
+ memo_string.byteslice(0, limit(memo_string.bytesize))
26
+ end
27
+
28
+ private
29
+
30
+ # A memo with no terminator runs to the end of the file, so stop
31
+ # reading blocks once max_size bytes have been read.
32
+ def full?(memo_string) # :nodoc:
33
+ max_size && memo_string.bytesize >= max_size
20
34
  end
21
35
  end
22
36
  end
@@ -11,7 +11,7 @@ module DBF
11
11
  header = data.read(BLOCK_HEADER_SIZE)
12
12
  return nil unless header && header.bytesize == BLOCK_HEADER_SIZE
13
13
 
14
- length = header.unpack1('x4L')
14
+ length = limit(header.unpack1('x4L'))
15
15
 
16
16
  # Bound the read by the bytes remaining so a crafted 32-bit length
17
17
  # field cannot force a ~4 GiB allocation from a small memo file.
@@ -14,7 +14,7 @@ module DBF
14
14
  memo_type, memo_size, memo_string = block.unpack('NNa*')
15
15
  return nil unless memo_type == 1 && memo_size.to_i.positive?
16
16
 
17
- read_memo_content(memo_string, memo_size)
17
+ read_memo_content(memo_string, limit(memo_size))
18
18
  rescue IOError, SystemCallError, RangeError
19
19
  nil
20
20
  end
data/lib/dbf/record.rb CHANGED
@@ -3,6 +3,15 @@
3
3
  module DBF
4
4
  # An instance of DBF::Record represents a row in the DBF file
5
5
  class Record
6
+ # Column names come from the file, so they are never exposed as these
7
+ # methods: Ruby (and Rails) call them implicitly to convert or render an
8
+ # object, which would let a crafted file control how a record behaves.
9
+ # Such columns remain readable through #[] and #attributes.
10
+ RESERVED_METHODS = %w[
11
+ to_str to_ary to_hash to_proc to_int to_io to_path to_regexp to_open
12
+ to_model to_partial_path to_param to_key
13
+ ].freeze
14
+
6
15
  # Initialize a new DBF::Record
7
16
  #
8
17
  # @param data [String, StringIO] data
@@ -112,7 +121,7 @@ module DBF
112
121
 
113
122
  def method_missing(method, *args) # :nodoc:
114
123
  key = method.to_s
115
- if (index = underscored_column_names.index(key))
124
+ if (index = column_method_index(key))
116
125
  if @context.column_offsets && !@to_a
117
126
  column_value(index)
118
127
  else
@@ -124,7 +133,13 @@ module DBF
124
133
  end
125
134
 
126
135
  def respond_to_missing?(method, *) # :nodoc:
127
- underscored_column_names.include?(method.to_s) || super
136
+ !column_method_index(method.to_s).nil? || super
137
+ end
138
+
139
+ def column_method_index(key) # :nodoc:
140
+ return nil if RESERVED_METHODS.include?(key)
141
+
142
+ underscored_column_names.index(key)
128
143
  end
129
144
 
130
145
  def underscored_column_names # :nodoc:
data/lib/dbf/table.rb CHANGED
@@ -41,6 +41,9 @@ module DBF
41
41
  # table = DBF::Table.new 'data.dbf', nil, 'cp437'
42
42
  # table = DBF::Table.new 'data.dbf', 'memo.dbt', Encoding::US_ASCII
43
43
  #
44
+ # # reading at most the first 64 KB of each memo
45
+ # table = DBF::Table.new 'data.dbf', max_memo_size: 65_536
46
+ #
44
47
  # Opens a table like .new, but when given a block, yields the table,
45
48
  # closes it when the block returns, and returns the block's value —
46
49
  # the same contract as File.open.
@@ -50,8 +53,8 @@ module DBF
50
53
  # end
51
54
  #
52
55
  # Takes the same arguments as .new. Without a block, equivalent to .new.
53
- def self.open(data, memo = nil, encoding = nil, name: nil)
54
- table = new(data, memo, encoding, name: name)
56
+ def self.open(data, memo = nil, encoding = nil, name: nil, max_memo_size: nil)
57
+ table = new(data, memo, encoding, name:, max_memo_size:)
55
58
  return table unless block_given?
56
59
 
57
60
  begin
@@ -64,11 +67,15 @@ module DBF
64
67
  # @param data [String, StringIO, IO] data Path to the dbf file or an IO-like object
65
68
  # @param memo [optional String, StringIO, IO] memo Path to the memo file or an IO-like object
66
69
  # @param encoding [optional String, Encoding] encoding Name of the encoding or an Encoding object
67
- def initialize(data, memo = nil, encoding = nil, name: nil)
70
+ # @param max_memo_size [optional Integer] Bytes to read from each memo;
71
+ # longer memos are cut off. Every memo is read in full by default.
72
+ def initialize(data, memo = nil, encoding = nil, name: nil, max_memo_size: nil)
73
+ Encoder.check(encoding) if encoding
74
+ validate_max_memo_size(max_memo_size)
68
75
  @data = FileHandler.open_data(data)
69
76
  @user_encoding = encoding
70
77
  @encoding = determine_encoding
71
- @memo = FileHandler.open_memo(data, memo, version_config.memo_class, version)
78
+ @memo = open_memo(data, memo, max_memo_size)
72
79
  @name = name
73
80
  yield self if block_given?
74
81
  end
@@ -148,6 +155,9 @@ module DBF
148
155
  # @return [DBF::Record, NilClass]
149
156
  def record(index)
150
157
  raise DBF::NoColumnsDefined, 'The DBF file has no columns defined' if columns.empty?
158
+ # A negative index would read header bytes as a record (or raise
159
+ # Errno::EINVAL); treat it, like any index past the end, as absent.
160
+ return nil unless index >= 0 && index < record_count
151
161
 
152
162
  seek_to_record(index)
153
163
  return nil if deleted_record?
@@ -237,8 +247,23 @@ module DBF
237
247
  @user_encoding || header.encoding || Encoding.default_external
238
248
  end
239
249
 
250
+ # Close a data file opened from a path if its memo cannot be opened, so
251
+ # the failed Table does not leak a file handle.
252
+ def open_memo(data, memo, max_size) # :nodoc:
253
+ FileHandler.open_memo(data, memo, version_config.memo_class, version, max_size:)
254
+ rescue DBF::Error
255
+ @data.close if data.is_a?(String)
256
+ raise
257
+ end
258
+
259
+ def validate_max_memo_size(max_memo_size) # :nodoc:
260
+ return if max_memo_size.nil? || (max_memo_size.is_a?(Integer) && max_memo_size.positive?)
261
+
262
+ raise ArgumentError, 'max_memo_size must be a positive Integer or nil'
263
+ end
264
+
240
265
  def build_columns # :nodoc:
241
- ColumnBuilder.new(self, @data, version_config).build
266
+ ColumnBuilder.new(self, @data, version_config, header_length:, record_length:).build
242
267
  end
243
268
 
244
269
  def deleted_record? # :nodoc:
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.5.0'
4
+ VERSION = '5.6.0'
5
5
  end
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.5.0
4
+ version: 5.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keith Morrison
@@ -84,7 +84,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
84
84
  - !ruby/object:Gem::Version
85
85
  version: '0'
86
86
  requirements: []
87
- rubygems_version: 3.6.9
87
+ rubygems_version: 4.0.20
88
88
  specification_version: 4
89
89
  summary: Read xBase files
90
90
  test_files: []