Skip to content

Avoid undefined behavior on non-ASCII bytes in the text date and timestamp decoders - #761

Merged
larskanis merged 5 commits into
ged:masterfrom
czj:fix/isdigit-unsigned-char
Oct 26, 2026
Merged

larskanis merged 5 commits into
ged:masterfrom
czj:fix/isdigit-unsigned-char

Conversation

@czj

@czj czj commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Another one spotted while working on #759, after #760: the text date and timestamp decoders call isdigit() on plain chars. isdigit() only accepts values representable as unsigned char (or EOF), and a byte >= 0x80 is negative wherever char is signed, which is undefined behavior. glibc and macOS happen to return false, but the Windows debug CRT asserts on it, and other C libraries are free to do anything. The date decoder I added in #758 has the same problem.

This switches the 28 calls to rb_isdigit(), which ruby.h provides as an inline that compares against '0'..'9' and accepts any int, in the same spirit as the local array_isspace(). <ctype.h> isn't needed anymore in that file.

PostgreSQL never sends such bytes in a date or timestamp, but they can reach these decoders through a direct decode call, or a type map applying them to other data, e.g. CopyRow with escaped bytes or FromBase64. The specs now substitute ² at every position of the existing invalid characters loop, both in UTF-8 (\xC2\xB2) and as a lone \xB2 byte (² in Latin-1 and CP1252, which a locale aware isdigit() might take for a digit), and add 10-byte date samples with both where digits are expected. On Linux and macOS they pass with or without this change, since those libcs tolerate negative values. To check they'd catch a regression, I built master with an isdigit() wrapper that raises outside -1..255, as the Windows debug CRT asserts: both examples fail there and pass on this branch. Since no CI job uses a debug CRT, and #758 brought isdigit() back without anything noticing, a last spec in pg_spec.rb scans ext/*.[ch] for ctype.h calls outside comments. It fails on every platform on master, listing the 28 calls, and passes here. Too much for your taste? I'm happy to drop it.

It doesn't conflict with #759, which touches the same function.


🤖 Generated with Claude Code, but prompted, read, and verified by @czj, a caring human :)

czj added 5 commits October 26, 2026 18:51
isdigit() takes an int that must be representable as unsigned char or EOF.
A plain char >= 0x80 is negative on most platforms, which is undefined
behavior: the Windows debug CRT asserts and its release CRT reads before
its ctype table. rb_isdigit() compares against '0'..'9' and accepts any int.
Substitute ² at every position of the invalid characters loop, and keep the
date samples 10 bytes long so they reach the digit checks.
That's ² in Latin-1 and CP1252, where a locale aware isdigit() may see a digit.
The decoder specs can only catch these calls on a Windows debug CRT, which
no CI job uses, and ged#758 brought isdigit() back without notice. Scanning the
sources fails on every platform.
pg.c and pg_tuple.c hold UTF-8 bytes, which File.readlines rejects
where the default external encoding is US-ASCII, as in the fedora:39
container without a locale.
@larskanis
larskanis merged commit 4ce839e into ged:master Oct 26, 2026
34 of 35 checks passed
@czj
czj deleted the fix/isdigit-unsigned-char branch October 26, 2026 18:38
@czj

czj commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @larskanis !

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants