diff --git a/UPGRADING b/UPGRADING index 49d1ebc8258c..7d36fdc08063 100644 --- a/UPGRADING +++ b/UPGRADING @@ -174,6 +174,9 @@ PHP 8.7 UPGRADE NOTES the last one. - Standard: + . Improved performance of base64_decode() on short input and on input with + whitespace or line breaks: the character loop now decodes whole groups of + four. . Improved performance of strcspn() and strpbrk() when searching longer strings with a single-byte character mask. . Improved performance of array_chunk() when not preserving keys. diff --git a/ext/standard/base64.c b/ext/standard/base64.c index 5c3f876ef2b9..795ce424621c 100644 --- a/ext/standard/base64.c +++ b/ext/standard/base64.c @@ -245,9 +245,82 @@ static zend_always_inline size_t neon_base64_decode(const unsigned char *in, siz } #endif /* defined(__aarch64__) || defined(_M_ARM64) */ +/* Decodes whole groups of four alphabet characters and stops before a group that holds + * anything else; for a caller at a group boundary with no padding seen yet */ +static zend_always_inline void php_base64_decode_quads(const unsigned char **in, size_t *inl, unsigned char *out, size_t *outl) +{ + const unsigned char *c = *in; + unsigned char *o = out + *outl; + size_t n = *inl; + + while (n >= 4) { + int a = base64_reverse_table[c[0]], b = base64_reverse_table[c[1]]; + int d = base64_reverse_table[c[2]], e = base64_reverse_table[c[3]]; + if ((a | b | d | e) < 0) { + break; + } + uint32_t v = ((uint32_t) a << 18) | ((uint32_t) b << 12) | ((uint32_t) d << 6) | (uint32_t) e; + o[0] = v >> 16; + o[1] = v >> 8; + o[2] = v; + o += 3; + c += 4; + n -= 4; + } + *outl = o - out; + *in = c; + *inl = n; +} + +/* Decodes one character. Only i % 4 is read — the position in the current four-character group; + * whole groups decoded by php_base64_decode_quads() keep it aligned. padding counts the '=' seen. + * Returns false when strict mode rejects the character. */ +static zend_always_inline bool php_base64_decode_char(int ch, unsigned char *out, size_t *i, size_t *outl, size_t *padding, bool strict) +{ + if (ch == base64_pad) { + (*padding)++; + return true; + } + + ch = base64_reverse_table[ch]; + if (!strict) { + /* skip unknown characters and whitespace */ + if (ch < 0) { + return true; + } + } else { + /* skip whitespace */ + if (ch == -1) { + return true; + } + /* fail on bad characters or if any data follows padding */ + if (ch == -2 || *padding) { + return false; + } + } + + switch (*i % 4) { + case 0: + out[*outl] = ch << 2; + break; + case 1: + out[(*outl)++] |= ch >> 4; + out[*outl] = (ch & 0x0f) << 4; + break; + case 2: + out[(*outl)++] |= ch >>2; + out[*outl] = (ch & 0x03) << 6; + break; + case 3: + out[(*outl)++] |= ch; + break; + } + (*i)++; + return true; +} + static zend_always_inline int php_base64_decode_impl(const unsigned char *in, size_t inl, unsigned char *out, size_t *outl, bool strict) /* {{{ */ { - int ch; size_t i = 0, padding = 0, j = *outl; #if defined(__aarch64__) || defined(_M_ARM64) @@ -260,48 +333,19 @@ static zend_always_inline int php_base64_decode_impl(const unsigned char *in, si } #endif - /* run through the whole string, converting as we go */ - while (inl-- > 0) { - ch = *in++; - if (ch == base64_pad) { - padding++; - continue; - } - - ch = base64_reverse_table[ch]; - if (!strict) { - /* skip unknown characters and whitespace */ - if (ch < 0) { - continue; - } - } else { - /* skip whitespace */ - if (ch == -1) { - continue; - } - /* fail on bad characters or if any data follows padding */ - if (ch == -2 || padding) { - goto fail; + /* whole groups of four while the input is clean; one character at a time around + * whitespace, padding and invalid bytes, and for the last partial group */ + while (inl > 0) { + if (i % 4 == 0 && !padding) { + php_base64_decode_quads(&in, &inl, out, &j); + if (!inl) { + break; } } - - switch (i % 4) { - case 0: - out[j] = ch << 2; - break; - case 1: - out[j++] |= ch >> 4; - out[j] = (ch & 0x0f) << 4; - break; - case 2: - out[j++] |= ch >>2; - out[j] = (ch & 0x03) << 6; - break; - case 3: - out[j++] |= ch; - break; + if (!php_base64_decode_char(*in++, out, &i, &j, &padding, strict)) { + goto fail; } - i++; + inl--; } /* fail if the input is truncated (only one char in last group) */ diff --git a/ext/standard/tests/url/base64_decode_whitespace.phpt b/ext/standard/tests/url/base64_decode_whitespace.phpt new file mode 100644 index 000000000000..23f759a672f2 --- /dev/null +++ b/ext/standard/tests/url/base64_decode_whitespace.phpt @@ -0,0 +1,93 @@ +--TEST-- +base64_decode() with whitespace, padding and invalid bytes inside the input, in both modes +--FILE-- + $clean, + 'leading and trailing whitespace' => " \t\r\n" . $clean . "\n\n", + 'space inside a group' => substr($clean, 0, 2) . ' ' . substr($clean, 2), + 'space between groups' => substr($clean, 0, 4) . ' ' . substr($clean, 4), + 'a space after every character' => implode(' ', str_split($clean)), + 'MIME lines' => chunk_split($clean, 76, "\r\n"), + 'PEM lines' => chunk_split($clean, 64, "\n"), + 'short lines' => chunk_split($clean, 7, "\n"), + 'line break before the padding' => substr($clean, 0, -2) . "\n" . substr($clean, -2), + 'line break inside the padding' => substr($clean, 0, -1) . "\n" . substr($clean, -1), + 'line break after the padding' => $clean . "\n", + 'no padding' => rtrim($clean, '='), + 'no padding, trailing whitespace' => rtrim($clean, '=') . " \n", + 'data after the padding' => $clean . 'QUJD', + 'padding in the middle' => substr($clean, 0, 8) . '==' . substr($clean, 8), + 'invalid byte inside a group' => substr($clean, 0, 2) . '*' . substr($clean, 2), + 'invalid byte between groups' => substr($clean, 0, 4) . '-' . substr($clean, 4), + 'high byte' => substr($clean, 0, 4) . "\xC3\xA9" . substr($clean, 4), + 'one character' => 'Q', + 'two characters' => 'QU', + 'three characters' => 'QUJ', + 'five characters' => 'QUJDR', + 'only whitespace' => " \n\t", + 'only padding' => '====', + 'empty' => '', +]; + +function show($result) { + if ($result === false) { + return 'false'; + } + $plain = ' .,' . implode('', range('0', '9')) . implode('', range('a', 'z')) . implode('', range('A', 'Z')); + return strspn($result, $plain) === strlen($result) ? "'$result'" : 'bytes ' . bin2hex($result); +} + +foreach ($inputs as $name => $input) { + printf("%-34s %s | %s\n", $name, show(base64_decode($input)), show(base64_decode($input, true))); +} + +// every length around the group and vector block boundaries, with a line break at every position +$ok = true; +for ($bytes = 0; $bytes <= 100; $bytes++) { + $raw = $bytes ? random_bytes($bytes) : ""; + $encoded = base64_encode($raw); + for ($at = 0; $at <= strlen($encoded); $at++) { + $broken = substr($encoded, 0, $at) . "\r\n" . substr($encoded, $at); + if (base64_decode($broken) !== $raw || base64_decode($broken, true) !== $raw) { + echo "mismatch: $bytes bytes, line break at $at\n"; + $ok = false; + } + } + $mime = chunk_split($encoded, 76, "\r\n"); + if (base64_decode($mime) !== $raw || base64_decode($mime, true) !== $raw) { + echo "mismatch: $bytes bytes, MIME\n"; + $ok = false; + } +} +var_dump($ok); +?> +--EXPECT-- +clean 'The quick brown fox jumps over the lazy dog, 0123456789.' | 'The quick brown fox jumps over the lazy dog, 0123456789.' +leading and trailing whitespace 'The quick brown fox jumps over the lazy dog, 0123456789.' | 'The quick brown fox jumps over the lazy dog, 0123456789.' +space inside a group 'The quick brown fox jumps over the lazy dog, 0123456789.' | 'The quick brown fox jumps over the lazy dog, 0123456789.' +space between groups 'The quick brown fox jumps over the lazy dog, 0123456789.' | 'The quick brown fox jumps over the lazy dog, 0123456789.' +a space after every character 'The quick brown fox jumps over the lazy dog, 0123456789.' | 'The quick brown fox jumps over the lazy dog, 0123456789.' +MIME lines 'The quick brown fox jumps over the lazy dog, 0123456789.' | 'The quick brown fox jumps over the lazy dog, 0123456789.' +PEM lines 'The quick brown fox jumps over the lazy dog, 0123456789.' | 'The quick brown fox jumps over the lazy dog, 0123456789.' +short lines 'The quick brown fox jumps over the lazy dog, 0123456789.' | 'The quick brown fox jumps over the lazy dog, 0123456789.' +line break before the padding 'The quick brown fox jumps over the lazy dog, 0123456789.' | 'The quick brown fox jumps over the lazy dog, 0123456789.' +line break inside the padding 'The quick brown fox jumps over the lazy dog, 0123456789.' | 'The quick brown fox jumps over the lazy dog, 0123456789.' +line break after the padding 'The quick brown fox jumps over the lazy dog, 0123456789.' | 'The quick brown fox jumps over the lazy dog, 0123456789.' +no padding 'The quick brown fox jumps over the lazy dog, 0123456789.' | 'The quick brown fox jumps over the lazy dog, 0123456789.' +no padding, trailing whitespace 'The quick brown fox jumps over the lazy dog, 0123456789.' | 'The quick brown fox jumps over the lazy dog, 0123456789.' +data after the padding bytes 54686520717569636b2062726f776e20666f78206a756d7073206f76657220746865206c617a7920646f672c20303132333435363738392e105090 | false +padding in the middle 'The quick brown fox jumps over the lazy dog, 0123456789.' | false +invalid byte inside a group 'The quick brown fox jumps over the lazy dog, 0123456789.' | false +invalid byte between groups 'The quick brown fox jumps over the lazy dog, 0123456789.' | false +high byte 'The quick brown fox jumps over the lazy dog, 0123456789.' | false +one character '' | false +two characters 'A' | 'A' +three characters 'AB' | 'AB' +five characters 'ABC' | false +only whitespace '' | '' +only padding '' | false +empty '' | '' +bool(true)