From 3bb879d3fbd0ca504c38b141c12838075c1c73ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= Date: Mon, 26 Oct 2026 18:51:34 +0300 Subject: [PATCH 1/5] Check digits with rb_isdigit() in the text date and timestamp decoders 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. --- ext/pg_text_decoder.c | 31 +++++++++++++++---------------- spec/pg/type_spec.rb | 10 +++++++++- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/ext/pg_text_decoder.c b/ext/pg_text_decoder.c index d0ca3d418..f764c955a 100644 --- a/ext/pg_text_decoder.c +++ b/ext/pg_text_decoder.c @@ -34,7 +34,6 @@ #ifdef HAVE_INTTYPES_H #include #endif -#include #include #if !defined(_WIN32) #include @@ -614,7 +613,7 @@ static int parse_year(const char **str) { int i; const char * p = *str; - for(i = 0; isdigit(*p) && i < 7; i++, p++) { + for(i = 0; rb_isdigit(*p) && i < 7; i++, p++) { year = 10 * year + char_to_digit(*p); } @@ -656,11 +655,11 @@ static VALUE pg_text_dec_timestamp(t_pg_coder *conv, const char *val, int len, i year = parse_year(&str); if ( year > 0 - && str[0] == '-' && isdigit(str[1]) && isdigit(str[2]) - && str[3] == '-' && isdigit(str[4]) && isdigit(str[5]) - && str[6] == ' ' && isdigit(str[7]) && isdigit(str[8]) - && str[9] == ':' && isdigit(str[10]) && isdigit(str[11]) - && str[12] == ':' && isdigit(str[13]) && isdigit(str[14]) + && str[0] == '-' && rb_isdigit(str[1]) && rb_isdigit(str[2]) + && str[3] == '-' && rb_isdigit(str[4]) && rb_isdigit(str[5]) + && str[6] == ' ' && rb_isdigit(str[7]) && rb_isdigit(str[8]) + && str[9] == ':' && rb_isdigit(str[10]) && rb_isdigit(str[11]) + && str[12] == ':' && rb_isdigit(str[13]) && rb_isdigit(str[14]) ) { mon = str2_to_int(str+1); str += 3; @@ -669,7 +668,7 @@ static VALUE pg_text_dec_timestamp(t_pg_coder *conv, const char *val, int len, i min = str2_to_int(str+1); str += 3; sec = str2_to_int(str+1); str += 3; - if (str[0] == '.' && isdigit(str[1])) { + if (str[0] == '.' && rb_isdigit(str[1])) { /* nano second part, up to 9 digits */ static const int coef[9] = { 100000000, 10000000, 1000000, @@ -678,23 +677,23 @@ static VALUE pg_text_dec_timestamp(t_pg_coder *conv, const char *val, int len, i int i; str++; - for (i = 0; i < 9 && isdigit(*str); i++) + for (i = 0; i < 9 && rb_isdigit(*str); i++) { nsec += coef[i] * char_to_digit(*str++); } /* consume digits smaller than nsec */ - while(isdigit(*str)) str++; + while(rb_isdigit(*str)) str++; } - if ((str[0] == '+' || str[0] == '-') && isdigit(str[1]) && isdigit(str[2])) { + if ((str[0] == '+' || str[0] == '-') && rb_isdigit(str[1]) && rb_isdigit(str[2])) { tz_given = str[0] == '-' ? TZ_NEG : TZ_POS; tz_hour = str2_to_int(str+1); str += 3; - if (str[0] == ':' && isdigit(str[1]) && isdigit(str[2])) + if (str[0] == ':' && rb_isdigit(str[1]) && rb_isdigit(str[2])) { tz_min = str2_to_int(str+1); str += 3; } - if (str[0] == ':' && isdigit(str[1]) && isdigit(str[2])) + if (str[0] == ':' && rb_isdigit(str[1]) && rb_isdigit(str[2])) { tz_sec = str2_to_int(str+1); str += 3; } @@ -954,9 +953,9 @@ static VALUE pg_text_dec_date(t_pg_coder *conv, const char *val, int len, int tuple, int field, int enc_idx) { if (len == 10 - && isdigit(val[0]) && isdigit(val[1]) && isdigit(val[2]) && isdigit(val[3]) - && val[4] == '-' && isdigit(val[5]) && isdigit(val[6]) - && val[7] == '-' && isdigit(val[8]) && isdigit(val[9]) + && rb_isdigit(val[0]) && rb_isdigit(val[1]) && rb_isdigit(val[2]) && rb_isdigit(val[3]) + && val[4] == '-' && rb_isdigit(val[5]) && rb_isdigit(val[6]) + && val[7] == '-' && rb_isdigit(val[8]) && rb_isdigit(val[9]) ) { int year = str2_to_int(val) * 100 + str2_to_int(val + 2); int mon = str2_to_int(val + 5); diff --git a/spec/pg/type_spec.rb b/spec/pg/type_spec.rb index e8d952838..9360191fb 100644 --- a/spec/pg/type_spec.rb +++ b/spec/pg/type_spec.rb @@ -207,6 +207,14 @@ def textdec_timestamptz_decode_should_fail(str) textdec_timestamptz_decode_should_fail('2016-01-02 23:23:59.123456+00:2:21') textdec_timestamptz_decode_should_fail('2016-01-02 23:23:59.123456+00:25:2') end + it 'fails when the timestamp contains non-ASCII characters where digits are expected' do + textdec_timestamptz_decode_should_fail('²016-01-02 23:23:59.123456+00:25:21') + textdec_timestamptz_decode_should_fail('2016-0²-02 23:23:59.123456+00:25:21') + textdec_timestamptz_decode_should_fail('2016-01-02 23:23:59.²23456+00:25:21') + textdec_timestamptz_decode_should_fail('2016-01-02 23:23:59.123456+0²:25:21') + textdec_timestamptz_decode_should_fail('2016-01-02 23:23:59.123456+00:²5:21') + textdec_timestamptz_decode_should_fail('2016-01-02 23:23:59.123456+00:25:²1') + end it 'fails when the timestamp contains values with more digits than expected' do textdec_timestamptz_decode_should_fail('2016-011-02 23:23:59.123456+00:25:21') textdec_timestamptz_decode_should_fail('2016-01-022 23:23:59.123456+00:25:21') @@ -252,7 +260,7 @@ def textdec_timestamptz_decode_should_fail(str) expect( textdec_date.decode('10000-01-01') ).to eq( '10000-01-01' ) end it 'returns dates in other formats as string' do - %w[01/02/2016 02.01.2016 2016-1-02 2016-01-2 2016/01/02 2016-01ª0].each do |value| + %w[01/02/2016 02.01.2016 2016-1-02 2016-01-2 2016/01/02 2016-01ª0 ²016-01-02 2016-0²-02].each do |value| expect( textdec_date.decode(value) ).to eq( value ) end expect( textdec_date.decode(' 2016-01-02') ).to eq( ' 2016-01-02' ) From 1beacd3bc80e3735fc83927c346bd9f8a0718ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= Date: Mon, 26 Oct 2026 19:23:55 +0300 Subject: [PATCH 2/5] Feed non-ASCII bytes to every digit check in the specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substitute ² at every position of the invalid characters loop, and keep the date samples 10 bytes long so they reach the digit checks. --- spec/pg/type_spec.rb | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/spec/pg/type_spec.rb b/spec/pg/type_spec.rb index 9360191fb..430546d44 100644 --- a/spec/pg/type_spec.rb +++ b/spec/pg/type_spec.rb @@ -207,14 +207,6 @@ def textdec_timestamptz_decode_should_fail(str) textdec_timestamptz_decode_should_fail('2016-01-02 23:23:59.123456+00:2:21') textdec_timestamptz_decode_should_fail('2016-01-02 23:23:59.123456+00:25:2') end - it 'fails when the timestamp contains non-ASCII characters where digits are expected' do - textdec_timestamptz_decode_should_fail('²016-01-02 23:23:59.123456+00:25:21') - textdec_timestamptz_decode_should_fail('2016-0²-02 23:23:59.123456+00:25:21') - textdec_timestamptz_decode_should_fail('2016-01-02 23:23:59.²23456+00:25:21') - textdec_timestamptz_decode_should_fail('2016-01-02 23:23:59.123456+0²:25:21') - textdec_timestamptz_decode_should_fail('2016-01-02 23:23:59.123456+00:²5:21') - textdec_timestamptz_decode_should_fail('2016-01-02 23:23:59.123456+00:25:²1') - end it 'fails when the timestamp contains values with more digits than expected' do textdec_timestamptz_decode_should_fail('2016-011-02 23:23:59.123456+00:25:21') textdec_timestamptz_decode_should_fail('2016-01-022 23:23:59.123456+00:25:21') @@ -227,8 +219,10 @@ def textdec_timestamptz_decode_should_fail(str) end it 'fails when the timestamp contains values with invalid characters' do str = '2013-01-02 23:23:59.123456+00:25:21' - str.length.times do |i| - textdec_timestamptz_decode_should_fail(str[0,i] + "x" + str[i+1..-1]) + %w[x ²].each do |invalid| + str.length.times do |i| + textdec_timestamptz_decode_should_fail(str[0,i] + invalid + str[i+1..-1]) + end end end it 'fails when the timestamp contains leading characters' do @@ -260,7 +254,7 @@ def textdec_timestamptz_decode_should_fail(str) expect( textdec_date.decode('10000-01-01') ).to eq( '10000-01-01' ) end it 'returns dates in other formats as string' do - %w[01/02/2016 02.01.2016 2016-1-02 2016-01-2 2016/01/02 2016-01ª0 ²016-01-02 2016-0²-02].each do |value| + %w[01/02/2016 02.01.2016 2016-1-02 2016-01-2 2016/01/02 2016-01ª0 ²16-01-02 2016-²-02 2016-01-²].each do |value| expect( textdec_date.decode(value) ).to eq( value ) end expect( textdec_date.decode(' 2016-01-02') ).to eq( ' 2016-01-02' ) From 4926fb92de1bb53aae4bad2da5189f365cc4f330 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= Date: Mon, 26 Oct 2026 19:28:30 +0300 Subject: [PATCH 3/5] Also feed a lone 0xB2 byte to the digit checks in the specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit That's ² in Latin-1 and CP1252, where a locale aware isdigit() may see a digit. --- spec/pg/type_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/pg/type_spec.rb b/spec/pg/type_spec.rb index 430546d44..82693891b 100644 --- a/spec/pg/type_spec.rb +++ b/spec/pg/type_spec.rb @@ -219,7 +219,7 @@ def textdec_timestamptz_decode_should_fail(str) end it 'fails when the timestamp contains values with invalid characters' do str = '2013-01-02 23:23:59.123456+00:25:21' - %w[x ²].each do |invalid| + ["x", "²", "\xB2".b].each do |invalid| str.length.times do |i| textdec_timestamptz_decode_should_fail(str[0,i] + invalid + str[i+1..-1]) end @@ -254,7 +254,7 @@ def textdec_timestamptz_decode_should_fail(str) expect( textdec_date.decode('10000-01-01') ).to eq( '10000-01-01' ) end it 'returns dates in other formats as string' do - %w[01/02/2016 02.01.2016 2016-1-02 2016-01-2 2016/01/02 2016-01ª0 ²16-01-02 2016-²-02 2016-01-²].each do |value| + %w[01/02/2016 02.01.2016 2016-1-02 2016-01-2 2016/01/02 2016-01ª0 ²16-01-02 2016-²-02 2016-01-²].push("2016-01-0\xB2".b).each do |value| expect( textdec_date.decode(value) ).to eq( value ) end expect( textdec_date.decode(' 2016-01-02') ).to eq( ' 2016-01-02' ) From de1a7a7b90d6cb4c5870c99a5d099528958a84ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Joubert?= Date: Mon, 26 Oct 2026 19:31:23 +0300 Subject: [PATCH 4/5] Fail the specs when the C-ext calls a ctype.h function The decoder specs can only catch these calls on a Windows debug CRT, which no CI job uses, and #758 brought isdigit() back without notice. Scanning the sources fails on every platform. --- spec/pg_spec.rb | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/spec/pg_spec.rb b/spec/pg_spec.rb index 0503fc13f..597946f07 100644 --- a/spec/pg_spec.rb +++ b/spec/pg_spec.rb @@ -78,6 +78,16 @@ expect(cext_text).not_to match(/home\//) # there should be no path to /home/ or so end + it "C-ext sources don't call ctype.h functions, which are undefined for bytes >= 0x80 in a plain char" do + calls = Dir[File.expand_path("../ext/*.[ch]", __dir__)].flat_map do |fname| + File.readlines(fname).each_with_index.filter_map do |line, index| + next if line =~ %r{\A\s*(/?\*|//)} + "#{File.basename(fname)}:#{index + 1}" if line =~ /(? Date: Mon, 26 Oct 2026 20:06:10 +0300 Subject: [PATCH 5/5] Read the C sources as binary in the ctype.h spec 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. --- spec/pg_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/pg_spec.rb b/spec/pg_spec.rb index 597946f07..8c0cb95c2 100644 --- a/spec/pg_spec.rb +++ b/spec/pg_spec.rb @@ -80,7 +80,7 @@ it "C-ext sources don't call ctype.h functions, which are undefined for bytes >= 0x80 in a plain char" do calls = Dir[File.expand_path("../ext/*.[ch]", __dir__)].flat_map do |fname| - File.readlines(fname).each_with_index.filter_map do |line, index| + File.binread(fname).lines.each_with_index.filter_map do |line, index| next if line =~ %r{\A\s*(/?\*|//)} "#{File.basename(fname)}:#{index + 1}" if line =~ /(?