Skip to content

Commit 2762db8

Browse files
committed
Fix a bug that Prism.parse_stream may write too much data to internal buffer
`pm_source_stream_read()` has 4096 bytes internal buffer to read a line: https://github.com/ruby/prism/blob/39fb41f06e5f4ccbe1783122fa8a4470e09dfcfe/src/source.c#L368-L369 It uses `gets(4095)` to read the next chunk to the buffer: https://github.com/ruby/prism/blob/39fb41f06e5f4ccbe1783122fa8a4470e09dfcfe/ext/prism/extension.c#L1076 But `gets(4095)` may read 4095 or more data when the character at 4095 is a multi-byte character. In the case, `parse_stream_fgets()` writes 4097 or more data to the buffer. We can avoid it by increasing internal buffer size to `4096 + 6` where `6` is the max character length in all available encodings. We can reduce it by using `stream.external_encoding` and `rb_enc_mbmaxlen()` but it may slow down. So I used constant `6` in this change. This also changes `pm_source_stream_fgets_t`'s return type to `int` (read bytes) from `char *` (read data) to reduce needless length check by caller.
1 parent ab9412c commit 2762db8

5 files changed

Lines changed: 56 additions & 22 deletions

File tree

ext/prism/extension.c

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1069,22 +1069,21 @@ parse_stream_eof(void *stream) {
10691069
/**
10701070
* An implementation of fgets that is suitable for use with Ruby IO objects.
10711071
*/
1072-
static char *
1072+
static int
10731073
parse_stream_fgets(char *string, int size, void *stream) {
10741074
RUBY_ASSERT(size > 0);
10751075

10761076
VALUE line = rb_funcall((VALUE) stream, rb_intern("gets"), 1, INT2FIX(size - 1));
10771077
if (NIL_P(line)) {
1078-
return NULL;
1078+
return 0;
10791079
}
10801080

10811081
const char *cstr = RSTRING_PTR(line);
10821082
long length = RSTRING_LEN(line);
10831083

10841084
memcpy(string, cstr, length);
1085-
string[length] = '\0';
10861085

1087-
return string;
1086+
return length;
10881087
}
10891088

10901089
/**

include/prism/source.h

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ typedef struct pm_source_t pm_source_t;
2525
* mirrors that of fgets so that fgets can be used as the default
2626
* implementation.
2727
*/
28-
typedef char * (pm_source_stream_fgets_t)(char *string, int size, void *stream);
28+
typedef int (pm_source_stream_fgets_t)(char *string, int size, void *stream);
2929

3030
/**
3131
* This function is used to check whether a stream is at EOF. It closely mirrors

lib/prism/ffi.rb

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def self.load_exported_functions_from(header, *functions, callbacks)
8888
raise "Could not find functions #{functions.inspect}" unless functions.empty?
8989
end
9090

91-
callback :pm_source_stream_fgets_t, [:pointer, :int, :pointer], :pointer
91+
callback :pm_source_stream_fgets_t, [:pointer, :int, :pointer], :int
9292
callback :pm_source_stream_feof_t, [:pointer], :int
9393
pm_source_init_result_values = %i[PM_SOURCE_INIT_SUCCESS PM_SOURCE_INIT_ERROR_GENERIC PM_SOURCE_INIT_ERROR_DIRECTORY PM_SOURCE_INIT_ERROR_NON_REGULAR]
9494
enum :pm_source_init_result_t, pm_source_init_result_values
@@ -298,9 +298,14 @@ def parse_stream(stream, **options)
298298
callback = -> (string, size, _) {
299299
raise "Expected size to be >= 0, got: #{size}" if size <= 0
300300

301-
if !(line = stream.gets(size - 1)).nil?
301+
line = stream.gets(size)
302+
if line.nil?
303+
0
304+
else
302305
source << line
303-
string.write_string("#{line}\x00", line.bytesize + 1)
306+
size = line.bytesize
307+
string.write_string(line, size)
308+
size
304309
end
305310
}
306311

src/source.c

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -365,28 +365,42 @@ bool
365365
pm_source_stream_read(pm_source_t *source) {
366366
pm_buffer_t *buffer = source->stream.buffer;
367367

368+
/*
369+
* We should use rb_enc_mbmaxlen() of stream.external_encoding for
370+
* the max character length but we don't want to do it here for
371+
* performance. We can detect the max character length in all
372+
* available encodings by the following command line in the Ruby
373+
* source directory:
374+
*
375+
* grep -Erh "max (enc|byte) length" enc |
376+
* grep -Eo '[0-9]+' |
377+
* sort |
378+
* tail -n1 # => 6
379+
*/
380+
#define MAX_ENC_LEN 6
381+
368382
#define LINE_SIZE 4096
369-
char line[LINE_SIZE];
370383

371-
while (memset(line, '\n', LINE_SIZE), source->stream.fgets(line, LINE_SIZE, source->stream.stream) != NULL) {
372-
size_t length = LINE_SIZE;
373-
while (length > 0 && line[length - 1] == '\n') length--;
384+
/*
385+
* io.gets(size) may read size or more bytes when the last
386+
* character is a multi-byte character. "+ MAX_ENC_LEN" is for the
387+
* case.
388+
*/
389+
char line[LINE_SIZE + MAX_ENC_LEN];
390+
int length;
391+
392+
while ((length = source->stream.fgets(line, LINE_SIZE, source->stream.stream)) > 0) {
393+
/* Append the line to the buffer. */
394+
pm_buffer_append_string(buffer, line, (size_t)length);
374395

375-
if (length == LINE_SIZE) {
396+
if (line[length - 1] != '\n') {
376397
/*
377-
* If we read a line that is the maximum size and it doesn't end
378-
* with a newline, then we'll just append it to the buffer and
379-
* continue reading.
398+
* If it doesn't end with a newline, then we'll continue
399+
* reading.
380400
*/
381-
length--;
382-
pm_buffer_append_string(buffer, line, length);
383401
continue;
384402
}
385403

386-
/* Append the line to the buffer. */
387-
length--;
388-
pm_buffer_append_string(buffer, line, length);
389-
390404
/*
391405
* Check if the line matches the __END__ marker. If it does, then stop
392406
* reading and return false. In most circumstances, this means we should
@@ -428,6 +442,8 @@ pm_source_stream_read(pm_source_t *source) {
428442

429443
#undef LINE_SIZE
430444

445+
#undef MAX_ENC_LEN
446+
431447
source->stream.eof = true;
432448
source->source = (const uint8_t *) pm_buffer_value(buffer);
433449
source->length = pm_buffer_length(buffer);

test/prism/api/parse_stream_test.rb

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,5 +114,19 @@ def test_nul_bytes
114114
assert result.success?
115115
assert_equal 3, result.value.statements.body.length
116116
end
117+
118+
def test_long_line
119+
# The important point of this test is that the character at 4095
120+
# is a multi-byte character (U+3042 HIRAGANA LETTER A) in this
121+
# case. gets(4095) may return 4095 or more bytes if the last
122+
# character is a multi-byte character. For example,
123+
# StringIO.new("\u3042").gets(1).bytesize is 3 not 1.
124+
long_string = "\"" + ("a" * 4093) + "\u3042" + "\""
125+
io = StringIO.new(long_string)
126+
result = Prism.parse_stream(io)
127+
128+
assert result.success?
129+
assert_equal long_string[1..-2], result.value.statements.body[0].content
130+
end
117131
end
118132
end

0 commit comments

Comments
 (0)