From 527f18f70b192a3dbee2ac850e32d0c779ef9011 Mon Sep 17 00:00:00 2001 From: Artem Ukrainskiy Date: Wed, 7 Oct 2026 03:35:40 +0300 Subject: [PATCH] ext/standard: Decode base64 four characters at a time in the character loop The character loop of base64_decode() handled one character per iteration with a switch on its position in the group, and it is the whole decoder for input shorter than 45 bytes and for everything after the first whitespace or line break, where the vector loops stop. Decode whole groups of four alphabet characters at once while the input is clean, and keep the one-character step for whitespace, padding, invalid bytes and the last partial group. Output is unchanged. Short input runs with about 40-50% fewer instructions, base64 with MIME line breaks about 70% fewer. --- UPGRADING | 3 + ext/standard/base64.c | 124 ++++++++++++------ .../tests/url/base64_decode_whitespace.phpt | 93 +++++++++++++ 3 files changed, 180 insertions(+), 40 deletions(-) create mode 100644 ext/standard/tests/url/base64_decode_whitespace.phpt 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)