[core] Replace base64 lookup tables with arithmetic mapping (#18454)

This commit is contained in:
J. Nick Koston
2026-08-17 16:14:06 -05:00
committed by GitHub
parent e45b4e4938
commit 7362c01c67
3 changed files with 91 additions and 16 deletions
+12 -4
View File
@@ -88,9 +88,17 @@ std::string str_sprintf(const char *fmt, ...) {
// --- Base64 helpers ---
static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
"0123456789+/";
// Map a 6-bit value (0-63) to its base64 character arithmetically.
// No lookup table: a table would occupy RAM on ESP8266 (.rodata lives in DRAM there).
static inline char base64_char(uint8_t index) {
if (index < 26)
return 'A' + index;
if (index < 52)
return 'a' + (index - 26);
if (index < 62)
return '0' + (index - 52);
return index == 62 ? '+' : '/';
}
// Encode 3 input bytes to 4 base64 characters, append 'count' to ret.
static inline void base64_encode_triple(const char *char_array_3, int count, std::string &ret) {
@@ -101,7 +109,7 @@ static inline void base64_encode_triple(const char *char_array_3, int count, std
char_array_4[3] = char_array_3[2] & 0x3f;
for (int j = 0; j < count; j++)
ret += BASE64_CHARS[static_cast<uint8_t>(char_array_4[j])];
ret += base64_char(static_cast<uint8_t>(char_array_4[j]));
}
std::string base64_encode(const std::vector<uint8_t> &buf) { return base64_encode(buf.data(), buf.size()); }
+12 -12
View File
@@ -579,13 +579,8 @@ int8_t step_to_accuracy_decimals(float step) {
return str.length() - dot_pos - 1;
}
// Use C-style string constant to store in ROM instead of RAM (saves 24 bytes)
static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
"abcdefghijklmnopqrstuvwxyz"
"0123456789+/";
// Helper function to find the index of a base64/base64url character in the lookup table.
// Returns the character's position (0-63) if found, or 0 if not found.
// Map a base64/base64url character to its 6-bit value (0-63) arithmetically.
// No lookup table: a table would occupy RAM on ESP8266 (.rodata lives in DRAM there).
// Supports both standard base64 (+/) and base64url (-_) alphabets.
// NOTE: This returns 0 for both 'A' (valid base64 char at index 0) and invalid characters.
// This is safe because is_base64() is ALWAYS checked before calling this function,
@@ -593,13 +588,18 @@ static constexpr const char *BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
// stops processing at the first invalid character due to the is_base64() check in its
// while loop condition, making this edge case harmless in practice.
static inline uint8_t base64_find_char(char c) {
// Handle base64url variants: '-' maps to '+' (index 62), '_' maps to '/' (index 63)
if (c == '-')
if (c >= 'A' && c <= 'Z')
return c - 'A';
if (c >= 'a' && c <= 'z')
return c - 'a' + 26;
if (c >= '0' && c <= '9')
return c - '0' + 52;
// base64url variants: '-' maps to '+' (index 62), '_' maps to '/' (index 63)
if (c == '+' || c == '-')
return 62;
if (c == '_')
if (c == '/' || c == '_')
return 63;
const char *pos = strchr(BASE64_CHARS, c);
return pos ? (pos - BASE64_CHARS) : 0;
return 0;
}
// Check if character is valid base64 or base64url
+67
View File
@@ -1,6 +1,7 @@
#include <gtest/gtest.h>
#include <cstring>
#include "esphome/core/alloc_helpers.h"
#include "esphome/core/helpers.h"
namespace esphome::core::testing {
@@ -213,4 +214,70 @@ TEST(BufAppendSepStr, Truncation) {
EXPECT_EQ(end - buf, 7);
}
// --- base64 encode/decode ---
static const char BASE64_ALPHABET[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
// Pack 6-bit indices 0..63 into 48 bytes so encoding yields the full alphabet in order
TEST(Base64, EncodeProducesCanonicalAlphabet) {
uint8_t bytes[48];
size_t n = 0;
for (uint8_t i = 0; i < 64; i += 4) {
bytes[n++] = (i << 2) | ((i + 1) >> 4);
bytes[n++] = ((i + 1) & 0x0F) << 4 | ((i + 2) >> 2);
bytes[n++] = ((i + 2) & 0x03) << 6 | (i + 3);
}
std::string encoded = base64_encode(bytes, sizeof(bytes)); // NOLINT(esphome-heap-allocation) - host test
EXPECT_EQ(encoded, BASE64_ALPHABET);
}
// Decode the alphabet then re-encode: locks the encode and decode mappings together
TEST(Base64, DecodeCanonicalAlphabetRoundTrip) {
uint8_t buf[48];
size_t len = base64_decode(std::string(BASE64_ALPHABET), buf, sizeof(buf));
EXPECT_EQ(len, 48u);
std::string reencoded = base64_encode(buf, len); // NOLINT(esphome-heap-allocation) - host test
EXPECT_EQ(reencoded, BASE64_ALPHABET);
}
TEST(Base64, DecodeBase64UrlMatchesStandard) {
std::string url = BASE64_ALPHABET;
for (char &c : url) {
if (c == '+')
c = '-';
if (c == '/')
c = '_';
}
uint8_t standard[48], urlsafe[48];
size_t len_standard = base64_decode(std::string(BASE64_ALPHABET), standard, sizeof(standard));
size_t len_url = base64_decode(url, urlsafe, sizeof(urlsafe));
EXPECT_EQ(len_standard, len_url);
EXPECT_EQ(memcmp(standard, urlsafe, len_standard), 0);
}
// RFC 4648 vectors cover both padding cases (len % 3 == 1 and len % 3 == 2)
TEST(Base64, Rfc4648Vectors) {
const struct {
const char *plain;
const char *encoded;
} vectors[] = {
{"", ""},
{"f", "Zg=="},
{"fo", "Zm8="},
{"foo", "Zm9v"},
{"foob", "Zm9vYg=="},
{"fooba", "Zm9vYmE="},
{"foobar", "Zm9vYmFy"},
};
for (const auto &v : vectors) {
const auto *plain = reinterpret_cast<const uint8_t *>(v.plain);
std::string encoded = base64_encode(plain, strlen(v.plain)); // NOLINT(esphome-heap-allocation) - host test
EXPECT_EQ(encoded, v.encoded);
uint8_t buf[8];
size_t len = base64_decode(reinterpret_cast<const uint8_t *>(v.encoded), strlen(v.encoded), buf, sizeof(buf));
EXPECT_EQ(len, strlen(v.plain));
EXPECT_EQ(memcmp(buf, v.plain, len), 0);
}
}
} // namespace esphome::core::testing