Read 64-bit multi-page SAS catalog files and skip format default values - #370
Conversation
|
Sorry for the spam from the failed fuzzing. I'll look into running the fuzzer locally and/or on push before opening PRs. I've probably opened up the problem by removing the bound on the counter But I've added the missing checks to guard against buffer overflow now, and the fuzzing is successful. |
… page `XLSR` records on index pages after the first were looked for at a hardcoded offset 16. That is the page header's width on a 32-bit catalog; on a 64-bit one it is 32. So a 64-bit catalog big enough to spill past its first index page silently drops every format the later records point at. No error, no warning, just value labels that are not there. Ours came from porting ReadStat's, which has the same constant at `readstat_sas7bcat_read.c:482`. Open upstream as WizardMac/ReadStat#370, where hpoettker found it; the fix there is unmerged. The offset now comes from `IndexLayout`, next to the three other widths that already switch on `uses_u64_pointers`, and a unit test pins both values. **The 64-bit multi-page path is not covered by any file here.** The only 64-bit catalog in the corpus is three pages, and the loop this changes starts at page three, so it never runs on it; the two catalogs that do reach the loop are both 32-bit, where the offset stays 16 and behaviour is unchanged. Verified by parsing all three before and after: identical set and label counts. I tried to synthesise one with ReadStat's own catalog writer. It produced a file neither reader accepts, so the generator is wrong rather than the readers, and it is not worth shipping a fixture I cannot vouch for. Recorded rather than papered over: this is a correct-by-construction change with no end-to-end test, the second such on this branch.
eccf21b to
ddf4d67
Compare
|
Please post a Fable review for this PR. I'll then address anything that comes up. PR SummaryThe PR now also resolves #378, and I've rebased on dev. The PR still addresses the previously described problems when reading SAS catalog files:
TestingI've added a 64-bit mode to the sas7bcat writer and the test suite for sas7bcat files. For the long value strings in formats from #378, I've added a dedicated unit test. For the default values to be skipped I created a test resource with Linux 64bit and the following SAS code: FuzzerI had a few run-ins with the fuzzer. One problem was that in the OSS Fuzz build script, there is a That's why I retained the value I can also open a PR in the OSS Fuzz repo if needed. OutlookHandling format default values or informats in ReadStat properly would be nice but requires changes or additions to the APIs. I might come back to it later as I've already reverse-engineered the catalog file format for informats. On the value side, SAS formats can also have ranges instead of just fixed values, which would require further API changes/additions to accomodate. |
Code Review: PR #370 — Read 64-bit multi-page SAS catalog files and skip format default valuesReview of WizardMac/ReadStat#370 (head SummaryThe PR does three things to the The default-label fix is real and verified: on the new Correctness1. Writer copies the wrong half of
|
| build | bitness | label sets written | label sets read back |
|---|---|---|---|
| dev | 32 | 3 | 1 (AAA only) |
| PR | 32 | 3 | 1 (AAA only) |
| PR | 64 | 3 | 1 (AAA only) |
The fix is one line in the XLSR loop, block_off += block_header_size + blocks[i]->len (hoisting block_header_size above the page-1 code). This is pre-existing on dev, but the PR touches exactly these lines to add the 64-bit header size, so it is the natural moment to fix it. Every catalog entry in test_list.h has label_sets_count = 1, which is why the suite never catches it; a two-label-set catalog test would (the harness does compare the total value-label count).
3. The 32-bit header offsets and the string-offset field are verified only against ReadStat's own writer
src/sas/readstat_sas7bcat_read.c:207-214
The reader now trusts four new fields: default_label_pos at 66+pad (32-bit) / 74+pad (64-bit) and string_offset at 104+pad as 2 bytes (32-bit) / 124+pad as 4 bytes (64-bit). The only SAS-produced file in the PR is 64-bit and numeric, and I confirmed by hex-dumping its YESNO block that default_label_pos = 1, string_offset = 0, capacity = 2, used = 3, which is consistent with the reader's model. But:
- No 32-bit SAS-produced catalog is tested. The 32-bit round-trip tests pass by construction because the writer leaves both fields zero. If offset 66 or 104 is wrong for real 32-bit files, the effect is not "slightly wrong output" but
READSTAT_ERROR_PARSEon files that read fine today, via the newdefault_label_pos > label_count_usedcheck and the newlabel_pos == default_label_pos - 1check in pass 1. That is the one place this PR could regress users. - No SAS-produced catalog with a character (
$) format is tested in either bitness, sostring_offsetand the "string runs from22 + string_offsetto the end of the entry" layout are unverified against SAS output. The old reader took the last 16 bytes of the entry, which is a different rule whenever an entry is longer than 38 bytes. The two real files I have contain no$formats either, so I could not check this myself. - The 2-byte vs 4-byte read of
string_offsetbetween bitnesses is asymmetric with the other fields; worth a comment on where that came from.
Ask: which real files were used to derive the 32-bit offsets and the string layout, and can a small SAS-produced 32-bit catalog and a $-format catalog be added under resources/? Both would be cheap to check in and would make the offsets regression-proof.
4. Multi-page XLSR fix has no coverage
src/sas/readstat_sas7bcat_read.c:523
The change from page[16] to page[32] for 64-bit files is plausible (it matches the 64-bit page header size used by the sas7bdat reader), but nothing exercises it: the new resource has 4 pages with both XLSR records on page 1, the two real files here are the same, and the writer only ever emits XLSR records on page 1. If a real multi-page file is what motivated the fix, a trimmed copy would make a good resource; otherwise a note in the PR that it was verified manually would do.
Robustness
5. Labels no value entry points at are silently paired with the first value entry
src/sas/readstat_sas7bcat_read.c:94
Dropping i < label_count_capacity from the pass-2 loop is required by the fix (the resource has 3 labels but only 2 value entries), and it is memory-safe because value_offset is calloc'd and every offset is bounds-checked. But an unset slot is 0, so any non-default label index that no value entry referenced now reads its value from entry 0 and is emitted with that value. Initialising value_offset to UINT32_MAX and treating an unset non-default index as a parse error (or skipping it) would make corrupt or unexpected files fail loudly instead of emitting a duplicate value. Also, label_count_used and default_label_pos are uint64_t in the caller but int parameters here (pre-existing pattern); harmless today because readstat_calloc refuses absurd counts, but the truncation is silent.
6. int16_t value_entry_len can overflow for long string keys
src/sas/readstat_sas7bcat_write.c:79
Variable-length value entries are new in this PR. readstat_label_string_value places no limit on key length, so a key over about 32 KB wraps value_entry_len and the reader's value_entry_len < string_start check then rejects the block. Either clamp the key at write time or reject it with an error. Related and pre-existing: int16_t block_len and the silent break when a block does not fit the page mean an oversized label set is dropped without an error.
Questions and nits
- Informat skip (
readstat_sas7bcat_read.c:60): the$test runs before the@test. If SAS stores character informats with a leading$($@NAMEor similar), they would be parsed as string formats rather than skipped. Do you have an example of how a character informat is named in the catalog? int8_t offset = ctx->u64 ? 32 : 16;at:523— plainintis clearer and avoids the promotion inctx->page_size - offset.off_tfor offsets into a 32-byte stack array (:222-224) —size_torintis more conventional.test_read.c:44:"sas7bcat64"as the 64-bit extension is consistent withsas7bdat64, fine.
Verification log
make checkon the PR worktree:test_readstat,test_dta_days,test_sav_date,test_double_decimalsall pass.format_with_default.sas7bcat: 64-bit,pad1 = 4, little-endian, header 8192, page size 4096, 4 pages, both XLSR records on page 1.YESNOblock at file offset0x5040:capacity = 2,used = 3,default_label_pos = 1,string_offset = 0; labels stored in orderUnknown,Yes,No; value entries are 54 bytes each and reference label indices 1 and 2.- Dump harness,
devvs PR:~/Downloads/test/formats.sas7bcat(64-bit, 3 labels) identical;~/Downloads/test2/formats2.sas7bcat(64-bit, 302 labels, 3 formats, no$formats, no informats) identical; the new resource differs as described in the summary. - Writer harness with three label sets: see table under Compilation warnings #2.
The catalog reader read every string value as the fixed 16 bytes ending at value_entry_len (readstat_sas7bcat_read.c), which is only correct for formats whose values all fit in 16 bytes. SAS stores string values at a fixed offset of 22 from the entry start, shifted one byte further and NUL-padded once any value in the format exceeds 16 bytes; the shift is signalled by a per-format field in the block header. Long values were therefore read from the wrong offset and truncated. Read that header field (offset 104 in 32-bit files, 124 in 64-bit) and take the value from offset 22 (+ the shift) through the end of the entry, letting readstat_convert trim the space or NUL padding. The destination is now sized to the value length instead of a fixed 65-byte buffer, so a long non-ASCII key no longer overflows the conversion and aborts the parse. Bounds on the value offset and entry length are checked before the read. The writer is aligned to the same layout: string values move from offset 14 to offset 22, matching what SAS itself writes, so catalogs ReadStat produces round-trip through the corrected reader. Numeric value entries are unchanged. Writing string keys longer than 16 bytes (the long form) is still truncated, as before; that is a separate enhancement. This is a standalone fix; it does not depend on the multi-page and default-value reader work proposed in #370, which the reporter's own patch was built on. The round-trip test for SAS string value labels now covers 11- and 16-character keys, which the old reader mishandled. Fixes #378 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U4htYjraVxaKySqbkDWExX
a84ed1f to
82f1d71
Compare
82f1d71 to
3311812
Compare
|
Thanks for picking up parts of this PR already in the latest wave of commits on dev! The PR is now rebased on #394, which fixes the currently broken build. I've replaced the previous sample catalog file with a 32- and 64-bit version of a file that has been produced with the following SAS code: This way, it tests both the skipping of default values and long value strings. I have addressed the feedback from the review except points 4 and 6. They are both about larger catalog files. How they should be written by the writer is a topic of its own. I have validated the reader for larger files manually with real-world catalog files. I would refrain from committing larger binary sample files to the repository at this point as this comes with problems down the road. I can't promise anything but I'll revisit the test harness for catalog files if I get to reading (instead of skipping) the default values, informats, and value ranges. |
WizardMac/ReadStat#370 adds 64-bit sas7bcat files to the test harness. The seed corpus is generated by the test harness.
Summary
This PR addresses three issues regarding the reading of SAS catalog files:
XLSRrecords beyond the first page in 64 bit files are currently not found due to a wrong offset,I've tested the PR locally with 64 bit files on Linux and 32 bit files on Windows.
Offset for
XLSRrecord on later pagesThe current implementation always uses the page offset 16 when looking for
XLSRrecords on later pages. But the offset 16 is only correct for 32-bit files. For 64-bit files, it is 32.Currently, the
XLSRrecords on later pages are missed in 64-bit files, which leads to any formats that are referenced from those later records to be missed.Informats in catalog files
The current implementation is only prepared for formats, which map from numbers to either other numbers or strings. This leads to parsing errors when "informats" are encountered, which also map from strings.
The PR proposes to just skip informats, which can be identified in the catalog file by names starting with
@.I might contribute the code for the informat parsing in a follow-up PR. But I think that would require a discussion before-hand on how to integrate informats into the existing API. They are not value labels that should be used for outward presentation but rather mappings that should be used on input data to derive an internal representation. I don't know whether such a concept exists for SPSS or Stata files.
Default values in custom formats
The PR fixes an issue that occurs when reading custom formats from SAS catalog files whose default value is not saved as the last value in the physical catalog file.
Currently, ReadStat skips the default value correctly in a format created like this:
as SAS writes the labels in the order of encounter.
But for a format created like this:
which logically creates the same format, ReadStat currently reads the format to map
1toUnknown,2toYes,No.It would be nice to also expose the default value through the API. But that would require a discussion on the API change before-hand as the current handlers for value label do not accept default values as far as I can tell. I don't know whether the concept of default value labels exists for SPSS or Stata files.