dbf 5.4.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: f296a6fc75892231410af21b2ee94490666f740f425f671078b5c28ded1e2977
4
- data.tar.gz: eed4dfd84582cdf9c8e125ca2ddb1e882ebe75147321469db26166f7777a1ea1
3
+ metadata.gz: e9d4250b08de3a7a96f24fff3f55e5457524288951b9d0ca8ffc8127aae2c790
4
+ data.tar.gz: 78d946bd48943b8d6527aac3e982017133480d8263bc5025d2c8d3a85407185e
5
5
  SHA512:
6
- metadata.gz: 578f0f6cc7c88a205c38fd2ef93af2d8e5c924483018af4b96fb349dd12f40386c13547dfc67cd6fbdd47e191a0d04ef46bf0e1c38f9baec6057197661cd6945
7
- data.tar.gz: 6960aba5a5b253cc6041c0822a504714ab5a511fee4ea1ff84f43cde10c8cf7db4d74d46f84e670d49f3a4d7110525a3196ea2f76cbcc37174a657e56a755fae
6
+ metadata.gz: b2e613d19239abb6df53c941c952da129bb77ff6cfbcc7b91cdeac10d9f8ad28724102c20ad6f53533959e0adc1f0f159ffadd0811f4997b623633d5c9fa2edf
7
+ data.tar.gz: 5964675e521e61be0d3da141c642cfc13c0e71b232ae2a4070053d0764b3e3ee1ca653a786ef70f3001b98f24b4eaf05eee9c4a81a86fd14b2506a939ea2eba3
data/CHANGELOG.md CHANGED
@@ -1,5 +1,82 @@
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
+
35
+ ## 5.5.0
36
+
37
+ ### Added
38
+
39
+ - `DBF::Table.new` accepts any IO-like object responding to `#read` and `#seek` (`File`, `Tempfile`, …) in addition to a path or `StringIO`, matching what the README already documented; IO inputs are switched to binary mode, and memo auto-discovery works from the IO's path when it has one
40
+ - `DBF::Error` base class: all library errors (`FileNotFoundError`, `NoColumnsDefined`, `Column::LengthError`, `Column::InvalidNameError`) now inherit from it, so callers can `rescue DBF::Error` to catch anything the library raises
41
+ - `Table#filename` now works for any IO input with a path, not only `File`
42
+ - `DBF::Table.open`: same arguments as `.new`, but the block form yields the table, closes it when the block returns, and returns the block's value — the same contract as `File.open`
43
+ - CI: lint job running RuboCop and Reek on every push and pull request
44
+ - CI: test on Windows and macOS (Ruby 3.4) in addition to Linux, since much real-world DBF data originates on Windows/FoxPro systems
45
+ - CI: observe-only (non-blocking) test jobs for JRuby, TruffleRuby, and Ruby head
46
+ - CI: enforce a 99% minimum test coverage floor via SimpleCov
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
48
+ - Dependabot updates for GitHub Actions and gems (weekly), a security policy (SECURITY.md) with private vulnerability reporting, and contributor documentation (CONTRIBUTING.md)
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
50
+ - Ractor safety: all library constants are verified Ractor-shareable and reading/exporting tables inside non-main Ractors is covered by specs
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
52
+
53
+ ### Fixed
54
+
55
+ - `Table#to_csv(path)` closes the file it opens, so the CSV is fully flushed to disk when the method returns (previously the data could stay buffered until garbage collection)
56
+ - Visual FoxPro: a `.dbc` container supplying fewer long names than the table has columns no longer crashes column building; missing long names fall back to the table's own column names. Column rebuilding is also no longer O(n²)
57
+ - `Table#schema` validates the format against the documented list up front; a `NoMethodError` raised inside a valid schema generator is no longer misreported as "not a valid schema"
58
+ - FoxPro memo files shorter than their 512-byte header no longer crash memo reads (they return nil like other truncations)
59
+ - RuboCop config: restore the default `vendor/**/*` exclusion (overriding `Exclude` replaces the defaults), fixing a CI lint crash where RuboCop descended into cached vendored gems and tried to load plugins from their configs
60
+ - CLI: close the table when finished, so the DBF file can be deleted or replaced immediately afterwards on Windows (an open handle blocks deletion there)
61
+
62
+ ### Removed
63
+
64
+ - CodeClimate config and README badges: the service is sunset, and coverage is now enforced directly in CI
65
+
66
+ ### Changed
67
+
68
+ - Streaming reads: `Table#each` now reads records in 4 MB chunks of whole records instead of loading the entire record section into memory, so enumerating multi-gigabyte files (for example shapefile `.dbf` sidecars) uses bounded memory. On a 190 MB file, peak process memory drops from ~222 MB to ~35 MB with identical results. No API change; all malformed-file bounds are preserved
69
+ - Internal: `DBF::RecordContext` is now an immutable `Data` class instead of a `Struct`
70
+ - Internal: remove a redundant `Memo::Foxpro#initialize` and the duplicate `Header::HEADER_SIZE` constant (now sourced from `VersionConfig`)
71
+ - README: replace the stack of version-support notes with a compatibility table, point API docs at dbf.infused.org, and refer to LICENSE instead of inlining the full MIT text; copyright years updated through 2026
72
+ - Gemspec: add `homepage_uri`, `bug_tracker_uri`, `documentation_uri`, and `funding_uri` metadata (shown on the rubygems.org sidebar), and constrain the `csv` runtime dependency to `~> 3.3`
73
+ - `DBF::Column::NameError` is renamed to `DBF::Column::InvalidNameError` (it shadowed Ruby's `::NameError` without being one); the old constant remains as a deprecated alias
74
+ - Rescues in date/datetime decoding and FoxPro memo reads are narrowed to the specific expected errors, so genuine programmer errors surface instead of silently returning nil
75
+ - Declare `reek` and `simplecov` as direct development dependencies instead of relying on rubycritic's transitive dependencies
76
+ - Restrict `debug` and `ruby-lsp` to MRI so `bundle install` succeeds on JRuby/TruffleRuby
77
+ - Test matrix no longer fails fast, and a Ruby head regression no longer fails the build
78
+ - Update pinned `ruby/setup-ruby` action to v1.321.0
79
+
3
80
  ## 5.4.0
4
81
 
5
82
  - 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
data/LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright (c) 2006-2024 Keith Morrison <keithm@infused.org>
1
+ Copyright (c) 2006-2026 Keith Morrison <keithm@infused.org>
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
data/README.md CHANGED
@@ -2,36 +2,37 @@
2
2
 
3
3
  [![Version](https://img.shields.io/gem/v/dbf.svg?style=flat)](https://rubygems.org/gems/dbf)
4
4
  [![Build Status](https://github.com/infused/dbf/actions/workflows/build.yml/badge.svg)](https://github.com/infused/dbf/actions/workflows/build.yml)
5
- [![Code Quality](https://img.shields.io/codeclimate/maintainability/infused/dbf.svg?style=flat)](https://codeclimate.com/github/infused/dbf)
6
- [![Code Coverage](https://img.shields.io/codeclimate/c/infused/dbf.svg?style=flat)](https://codeclimate.com/github/infused/dbf)
7
5
  [![Total Downloads](https://img.shields.io/gem/dt/dbf.svg)](https://rubygems.org/gems/dbf/)
8
6
  [![License](https://img.shields.io/github/license/infused/dbf.svg)](https://github.com/infused/dbf)
9
7
 
10
8
  DBF is a small, fast Ruby library for reading dBase, xBase, Clipper, and FoxPro database files.
11
9
 
12
10
  * Project page: <https://github.com/infused/dbf>
13
- * API Documentation: <https://rdoc.info/gems/dbf>
11
+ * API Documentation: <https://dbf.infused.org>
14
12
  * Report bugs: <https://github.com/infused/dbf/issues>
15
13
  * Questions: Email <mailto:keithm@infused.org> and put DBF somewhere in the
16
14
  subject line
17
15
  * Change log: <https://github.com/infused/dbf/blob/main/CHANGELOG.md>
18
16
 
19
- NOTE: Beginning with version 5.2 we have dropped support for Ruby 3.2 and earlier.
20
-
21
- NOTE: Beginning with version 4.3 we have dropped support for Ruby 3.0 and earlier.
22
-
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/dbf/tree/3_stable>)
25
-
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
- please use 2.0.x (<https://github.com/infused/dbf/tree/2_stable>)
28
-
29
17
  ## Compatibility
30
18
 
31
19
  DBF is tested to work with the following versions of Ruby:
32
20
 
33
21
  * Ruby 3.3.x, 3.4.x, 4.0.x
34
22
 
23
+ Older Rubies are supported by older DBF release lines:
24
+
25
+ | DBF version | Ruby support |
26
+ | ----------- | ------------ |
27
+ | 5.2+ | 3.3+ |
28
+ | 4.3 – 5.1 | 3.1+ |
29
+ | 4.0 – 4.2 | 2.4+ |
30
+ | 3.x ([3_stable](https://github.com/infused/dbf/tree/3_stable)) | 2.0 – 2.3 |
31
+ | 2.x ([2_stable](https://github.com/infused/dbf/tree/2_stable)) | 1.8 – 1.9 |
32
+
33
+ DBF is Ractor-friendly: all library constants are deeply frozen, so tables
34
+ can be opened, enumerated, and exported inside non-main Ractors.
35
+
35
36
  ## Installation
36
37
 
37
38
  Install the gem manually:
@@ -58,16 +59,35 @@ widgets = DBF::Table.new("widgets.dbf")
58
59
  Open a DBF file using an IO object:
59
60
 
60
61
  ```ruby
61
- data = File.open('widgets.dbf')
62
+ data = File.open('widgets.dbf', 'rb')
62
63
  widgets = DBF::Table.new(data)
63
64
  ```
64
65
 
66
+ All errors raised by the library inherit from DBF::Error, so you can rescue
67
+ the library as a unit:
68
+
69
+ ```ruby
70
+ begin
71
+ widgets = DBF::Table.new("widgets.dbf")
72
+ rescue DBF::Error => e
73
+ puts "Unable to read DBF file: #{e.message}"
74
+ end
75
+ ```
76
+
65
77
  Open a DBF by passing in raw data (wrap the raw data with StringIO):
66
78
 
67
79
  ```ruby
68
80
  widgets = DBF::Table.new(StringIO.new('raw binary data'))
69
81
  ```
70
82
 
83
+ Use the block form to close the table automatically, like File.open:
84
+
85
+ ```ruby
86
+ DBF::Table.open('widgets.dbf') do |table|
87
+ table.each { |record| puts record.name }
88
+ end
89
+ ```
90
+
71
91
  Enumerate all records
72
92
 
73
93
  ```ruby
@@ -287,13 +307,15 @@ table.name = 'my_table_name'
287
307
  A small command-line utility called dbf is installed with the gem.
288
308
 
289
309
  $ dbf -h
290
- usage: dbf [-h|-s|-a] filename
310
+ usage: dbf [-h|-s|-a|-c|-r|-j|-J] filename
291
311
  -h = print this message
292
- -v = print the version number
312
+ -v = print the DBF gem version
293
313
  -s = print summary information
294
314
  -a = create an ActiveRecord::Schema
295
- -r = create a Sequel Migration
315
+ -r = create a Sequel migration
296
316
  -c = export as CSV
317
+ -j = export as a JSON array
318
+ -J = export as JSON Lines (one record per line)
297
319
 
298
320
  Create an executable ActiveRecord schema:
299
321
 
@@ -307,6 +329,15 @@ Dump all records to a CSV file:
307
329
 
308
330
  dbf -c books.dbf > books.csv
309
331
 
332
+ Dump all records as JSON or JSON Lines:
333
+
334
+ dbf -j books.dbf > books.json
335
+ dbf -J books.dbf > books.jsonl
336
+
337
+ JSON Lines output is streamed record by record, so it works well for very
338
+ large files and pipelines (for example `dbf -J books.dbf | jq` or importing
339
+ into DuckDB).
340
+
310
341
  ## Reading a Visual Foxpro database (v8, v9)
311
342
 
312
343
  A special Database::Foxpro class is available to read Visual Foxpro container
@@ -367,6 +398,46 @@ Data type descriptions
367
398
  * O = Double
368
399
  * + = Autoincrement
369
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
+
370
441
  ## Limitations
371
442
 
372
443
  * DBF is read-only
@@ -374,25 +445,6 @@ Data type descriptions
374
445
 
375
446
  ## License
376
447
 
377
- Copyright (c) 2006-2024 Keith Morrison <<keithm@infused.org>>
378
-
379
- Permission is hereby granted, free of charge, to any person
380
- obtaining a copy of this software and associated documentation
381
- files (the "Software"), to deal in the Software without
382
- restriction, including without limitation the rights to use,
383
- copy, modify, merge, publish, distribute, sublicense, and/or sell
384
- copies of the Software, and to permit persons to whom the
385
- Software is furnished to do so, subject to the following
386
- conditions:
387
-
388
- The above copyright notice and this permission notice shall be
389
- included in all copies or substantial portions of the Software.
390
-
391
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
392
- EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
393
- OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
394
- NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
395
- HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
396
- WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
397
- FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
398
- OTHER DEALINGS IN THE SOFTWARE.
448
+ Copyright (c) 2006-2026 Keith Morrison <<keithm@infused.org>>
449
+
450
+ Released under the MIT License. See [LICENSE](LICENSE) for the full text.
data/dbf.gemspec CHANGED
@@ -15,7 +15,11 @@ Gem::Specification.new do |s|
15
15
  s.require_paths = ['lib']
16
16
  s.required_ruby_version = '>= 3.3.0'
17
17
  s.metadata['rubygems_mfa_required'] = 'true'
18
+ s.metadata['homepage_uri'] = 'https://github.com/infused/dbf'
18
19
  s.metadata['source_code_uri'] = 'https://github.com/infused/dbf'
19
20
  s.metadata['changelog_uri'] = 'https://github.com/infused/dbf/blob/main/CHANGELOG.md'
20
- s.add_dependency 'csv'
21
+ s.metadata['bug_tracker_uri'] = 'https://github.com/infused/dbf/issues'
22
+ s.metadata['documentation_uri'] = 'https://dbf.infused.org'
23
+ s.metadata['funding_uri'] = 'https://github.com/sponsors/infused'
24
+ s.add_dependency 'csv', '~> 3.3'
21
25
  end
data/lib/dbf/cli.rb CHANGED
@@ -5,13 +5,15 @@ require 'optparse'
5
5
  module DBF
6
6
  class CLI
7
7
  USAGE = <<~HELP
8
- usage: dbf [-h|-s|-a|-c|-r] filename
8
+ usage: dbf [-h|-s|-a|-c|-r|-j|-J] filename
9
9
  -h = print this message
10
10
  -v = print the DBF gem version
11
11
  -s = print summary information
12
12
  -a = create an ActiveRecord::Schema
13
13
  -r = create a Sequel migration
14
14
  -c = export as CSV
15
+ -j = export as a JSON array
16
+ -J = export as JSON Lines (one record per line)
15
17
  HELP
16
18
 
17
19
  # Bytes a terminal interprets as control or escape sequences. A crafted
@@ -20,6 +22,9 @@ module DBF
20
22
  CONTROL_BYTES = /[\x00-\x1F\x7F]/n
21
23
  # The same, but keeping CR and LF so CSV row separators survive.
22
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
23
28
 
24
29
  # Replaces terminal control bytes. Substitution happens on a byte copy so
25
30
  # a value whose bytes are invalid in its encoding cannot raise here.
@@ -29,7 +34,10 @@ module DBF
29
34
  # @return [String]
30
35
  def self.sanitize(value, pattern = CONTROL_BYTES)
31
36
  string = value.to_s
32
- 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)
33
41
  end
34
42
 
35
43
  # Wraps an IO so text written to an interactive terminal is stripped of
@@ -68,8 +76,17 @@ module DBF
68
76
  @stderr = stderr
69
77
  end
70
78
 
79
+ ACTIONS = {
80
+ 'a' => :print_ar_schema,
81
+ 'r' => :print_sequel_schema,
82
+ 's' => :print_summary,
83
+ 'c' => :print_csv,
84
+ 'j' => :print_json,
85
+ 'J' => :print_jsonl
86
+ }.freeze
87
+
71
88
  def run
72
- params = OptionParser.new.getopts(@argv, 'h', 's', 'a', 'c', 'r', 'v')
89
+ params = OptionParser.new.getopts(@argv, 'hsacrvjJ')
73
90
 
74
91
  if params['v']
75
92
  print_version
@@ -79,17 +96,12 @@ module DBF
79
96
  filename = @argv.shift
80
97
  return missing_filename unless filename
81
98
 
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
99
+ action = ACTIONS.find { |flag, _method| params[flag] }&.last
100
+ send(action, filename) if action
89
101
  end
90
102
  0
91
- rescue DBF::FileNotFoundError => e
92
- @stderr.puts "DBF::FileNotFoundError: #{e.message}"
103
+ rescue DBF::FileError => e
104
+ @stderr.puts "#{e.class}: #{e.message}"
93
105
  1
94
106
  end
95
107
 
@@ -108,16 +120,23 @@ module DBF
108
120
  1
109
121
  end
110
122
 
123
+ # Always close the table when done: an open handle prevents deleting or
124
+ # replacing the file on Windows.
125
+ def with_table(filename, &) = DBF::Table.open(filename, &)
126
+
111
127
  def print_ar_schema(filename)
112
- @stdout.puts terminal_safe(DBF::Table.new(filename).schema(:activerecord))
128
+ with_table(filename) { |table| @stdout.puts terminal_safe(table.schema(:activerecord)) }
113
129
  end
114
130
 
115
131
  def print_sequel_schema(filename)
116
- @stdout.puts terminal_safe(DBF::Table.new(filename).schema(:sequel))
132
+ with_table(filename) { |table| @stdout.puts terminal_safe(table.schema(:sequel)) }
117
133
  end
118
134
 
119
135
  def print_summary(filename)
120
- table = DBF::Table.new(filename)
136
+ with_table(filename) { |table| write_summary(filename, table) }
137
+ end
138
+
139
+ def write_summary(filename, table)
121
140
  @stdout.puts
122
141
  @stdout.puts "Database: #{filename}"
123
142
  @stdout.puts "Type: (#{table.version}) #{table.version_description}"
@@ -136,7 +155,54 @@ module DBF
136
155
  end
137
156
 
138
157
  def print_csv(filename)
139
- DBF::Table.new(filename).to_csv(interactive? ? TerminalFilter.new(@stdout) : @stdout)
158
+ with_table(filename) { |table| table.to_csv(interactive? ? TerminalFilter.new(@stdout) : @stdout) }
159
+ end
160
+
161
+ # Streams a JSON array without materializing all records in memory.
162
+ # JSON string escaping makes the output terminal-safe by construction:
163
+ # control bytes are emitted as \uXXXX escapes.
164
+ def print_json(filename)
165
+ with_table(filename) { |table| write_json(table) }
166
+ end
167
+
168
+ def write_json(table)
169
+ first = true
170
+ @stdout.write('[')
171
+ each_present_record(table) do |record|
172
+ @stdout.write(first ? "\n" : ",\n")
173
+ @stdout.write(json_record(record))
174
+ first = false
175
+ end
176
+ @stdout.write("\n]\n")
177
+ end
178
+
179
+ # One JSON object per line (JSON Lines). Combined with chunked record
180
+ # reading this exports arbitrarily large files in constant memory.
181
+ def print_jsonl(filename)
182
+ with_table(filename) { |table| write_jsonl(table) }
183
+ end
184
+
185
+ def write_jsonl(table)
186
+ each_present_record(table) { |record| @stdout.write("#{json_record(record)}\n") }
187
+ end
188
+
189
+ # Deleted records have no attributes, so JSON export skips them.
190
+ def each_present_record(table, &)
191
+ table.each { |record| yield record if record }
192
+ end
193
+
194
+ def json_record(record)
195
+ JSON.generate(record.attributes.to_h { |key, value| [json_safe(key), json_safe(value)] })
196
+ end
197
+
198
+ # JSON.generate raises on binary or invalidly encoded strings; represent
199
+ # their bytes instead of raising, mirroring the CSV export behavior.
200
+ def json_safe(value)
201
+ return value unless value.is_a?(::String)
202
+
203
+ value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: '?')
204
+ rescue Encoding::ConverterNotFoundError
205
+ value.dup.force_encoding(Encoding::UTF_8).scrub('?')
140
206
  end
141
207
 
142
208
  # Exported data is only filtered when it is going to a terminal, so
data/lib/dbf/column.rb CHANGED
@@ -2,15 +2,20 @@
2
2
 
3
3
  module DBF
4
4
  class Column
5
- class LengthError < StandardError
5
+ class LengthError < DBF::Error
6
6
  end
7
7
 
8
- class NameError < StandardError
8
+ class InvalidNameError < DBF::Error
9
9
  end
10
10
 
11
+ # Deprecated alias, kept for backward compatibility. It shadowed
12
+ # ::NameError (without being one), so it was renamed.
13
+ NameError = InvalidNameError
14
+ deprecate_constant :NameError
15
+
11
16
  attr_reader :name, :type, :length, :decimal
12
17
 
13
- # rubocop:disable Style/MutableConstant
18
+ # rubocop:disable-next Style/MutableConstant
14
19
  TYPE_CAST_CLASS = {
15
20
  N: ColumnType::Number,
16
21
  I: ColumnType::SignedLong,
@@ -24,7 +29,6 @@ module DBF
24
29
  G: ColumnType::General,
25
30
  :+ => ColumnType::AutoIncrement
26
31
  }
27
- # rubocop:enable Style/MutableConstant
28
32
  TYPE_CAST_CLASS.default = ColumnType::String
29
33
  TYPE_CAST_CLASS.freeze
30
34
 
@@ -48,6 +52,17 @@ module DBF
48
52
 
49
53
  def encoding = @table.encoding
50
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
+
51
66
  # @param value [String]
52
67
  def type_cast(value)
53
68
  type_cast_class.type_cast(value)
@@ -79,6 +94,14 @@ module DBF
79
94
  @underscored_name ||= name.gsub(/([a-z\d])([A-Z])/, '\1_\2').tr('-', '_').downcase
80
95
  end
81
96
 
97
+ protected
98
+
99
+ def rename(name) # :nodoc:
100
+ @name = name
101
+ @underscored_name = nil
102
+ validate_name
103
+ end
104
+
82
105
  private
83
106
 
84
107
  def clean(value) # :nodoc:
@@ -105,7 +128,7 @@ module DBF
105
128
  end
106
129
 
107
130
  def validate_name # :nodoc:
108
- raise NameError, 'column name cannot be empty' if @name.empty?
131
+ raise InvalidNameError, 'column name cannot be empty' if @name.empty?
109
132
  end
110
133
  end
111
134
  end
@@ -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)
@@ -112,7 +112,7 @@ module DBF
112
112
  # @param value [String]
113
113
  def type_cast(value)
114
114
  value.match?(/\d{8}/) && ::Date.strptime(value, '%Y%m%d')
115
- rescue StandardError
115
+ rescue ArgumentError, RangeError, TypeError
116
116
  nil
117
117
  end
118
118
  end
@@ -121,11 +121,12 @@ 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
128
- rescue StandardError
129
+ rescue ArgumentError, RangeError, TypeError
129
130
  nil
130
131
  end
131
132
  end
@@ -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