Декодирование Base64 в Rust: полное руководство
В вашу программу на Rust приземляется строка: буквы и цифры, изредка плюс или слеш, да пара подозрительных =, повисших на хвосте. Это Base64, и это руководство о том, как вернуть исходные байты без сюрпризов. Домашняя страница этого сайта разбирает формат во всех деталях, поэтому здесь достаточно напомнить лишь форму обмена: четыре символа алфавита стоят за три входных байта, а хвост из одного-двух символов = помечает место, где закончились настоящие данные. Декодирование проворачивает этот обмен в обратную сторону, и всё ниже - о том, как делать это осознанно.
А вот единственный поворот, который отличает Rust от большинства языков: в стандартной библиотеке нет Base64 вообще. В std не притаился ни один base64_decode(), и ни одна use std::... не способна его призвать. Экосистема остановилась на единственном крейте с простым именем base64, и он стал несущей конструкцией: версия 0.23.1 вышла 4 августа 2026 года, с момента первого релиза в декабре 2015 года крейт опубликовал 45 версий, а его счётчик загрузок стоит у отметки в 1,5 миллиарда. Почти наверняка вы уже декодируете Base64 через этот крейт, напрямую или через что-то вроде jsonwebtoken, pem или serde_with, которые все от него зависят.
Один крейт и его круг
Если Rust ещё нет на машине, его даёт ваша операционная система: rustc и cargo на Debian и Ubuntu, пакет или установщик на macOS и Windows, либо официальный установщик, который настраивает rustup:
# Debian / Ubuntu
sudo apt install rustc cargo
# либо официальный установщик, который настраивает rustup и cargo
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Затем крейт, внутри любого cargo-проекта. Эта единственная строка и есть вся установка, и она подтягивает ровно ноль зависимостей:
cargo new my-app
cd my-app
cargo add base64
Три необязательные фичи формируют сборку. std включена по умолчанию и даёт потоковые типы std::io, стандартные реализации Error и аллокацию кучи. alloc предоставляет аллоцирующие API для встроенных no_std-сборок без полной стандартной библиотеки. simd-unsafe включена по умолчанию и управляет доступом к векторизованным движкам, с которыми вы ещё познакомитесь. Минимальная поддерживаемая версия Rust - 1.71.0, так что любой свежий Rust его потянет. Вокруг центрального крейта кружит маленький круг специалистов, каждый - за свой краевой случай, который центр намеренно оставляет вам:
| Крейт | Версия (2026) | Что он приносит | За что тянуться |
|---|---|---|---|
base64ct |
1.8 | Декодирование за постоянное время от проекта RustCrypto; API кучи прячутся за фичей alloc |
Декодируемые вами байты могут сливать информацию через время исполнения, например ключевой материал |
data-encoding |
2.11 | Base64 в компании base32, hex и друзей, с снисходительными MIME-вариантами и кодированием/декодированием на уровне срезов | Один компонент должен разобрать грязный, перенесённый на строки или мультипротоковый ввод |
base64-turbo |
0.3 | Помолодее кодек с пиком выше 100 ГиБ/с, с ядрами AVX512, AVX2 и NEON плюс безопасный скалярный запасной вариант | Пропускная способность - вот в чём вся суть, а стандартный движок не выжимает из процессора всего |
Ни один из них не заменяет повседневную работу. Для подавляющего большинства Rust-программ base64 в одиночку - правильный и полный ответ, и остальная часть этой статьи использует этот единственный крейт для самого декодирования, прибегая к специалистам круга лишь тогда, когда дело шире, чем base64.
Три строки до ваших байтов
Девяносто процентов жизни декодирования умещаются в три строки. Канонический дымовой тест использует знаменитую строку TWFu:
use base64::prelude::*;
fn main() {
let packed = "TWFu";
let bytes = BASE64_STANDARD.decode(packed).expect("valid base64");
println!("{}", String::from_utf8(bytes).expect("valid utf-8"));
}
На выходе будет Man, и в этом крошечном обряде стоит запомнить три вещи. Во-первых, decode() всегда отдаёт вам Vec<u8>, никогда не строку. Это фича, а не случайность: Base64 может нести фразу, JPEG или сертификат, и ни один из них до знакомства с содержимым не должен обрабатываться иначе. Во-вторых, прыжок от байтов к тексту - отдельный, намеренный шаг через String::from_utf8(), и именно на этом шаге живёт решение про кодировку. В-третьих, модуль prelude тихо выдаёт вам две вещи разом: движок BASE64_STANDARD и трейт Engine, методы которого вы вызываете. Если предпочитаете явные импорты, use base64::engine::general_purpose::STANDARD; в паре с use base64::Engine; - та же самая дверь, только с табличкой на ней.
А поскольку вы когда-нибудь обязательно будете декодировать то, что закодировали сами, вот круговой рейс, доказывающий, что оба направления сходятся. Кодирование имеет собственное полное руководство на сестринском сайте; здесь оно появляется лишь для производства тестовых данных:
use base64::prelude::*;
fn main() {
let packed = BASE64_STANDARD.encode("Hello, world!");
println!("{packed}"); // SGVsbG8sIHdvcmxkIQ==
let back = BASE64_STANDARD.decode(packed).unwrap();
println!("{}", String::from_utf8(back).unwrap()); // Hello, world!
}
Держите TWFu в запасном кармане как дымовой тест для любого пути декодирования, который вы пишете: если он превращает TWFu в Man, машина честная.
Четыре способа сказать нет
Этот раздел спасёт вас в два часа ночи, потому что когда боевая строка взрывается, вы хотите точно знать, на что именно ворчит крейт. Хорошая новость: он ворчит громко и точно. У DecodeError ровно четыре варианта, и вот как звучит каждый на одной семье типичных провинившихся - все они пропущены через строгий стандартный движок:
| Ввод | В чём дело | Точная ошибка |
|---|---|---|
"SGVs bG8s" |
пробел просочился внутрь | Invalid symbol 32, offset 4. |
"SGVs\nbG8s" |
перенос строки просочился внутрь | Invalid symbol 10, offset 4. |
"SG=VsbG8="" |
заполнитель посреди строки | Invalid symbol 61, offset 2. |
"SGVsbG8sIHdvcmxkIQ==xx" |
мусор за хвостом заполнителя | Invalid symbol 61, offset 18. |
"S" |
одного символа не хватает, чтобы составить байт | Invalid input length: 1 |
"SGV" |
три символа без заполнителя, который должен следовать за ними | Invalid padding |
"SGVs$bG8="" |
символ $ не входит в алфавит |
Invalid symbol 36, offset 4. |
Обратите внимание, как сообщение Invalid symbol сообщает вам и значение провинившегося байта, и его смещение, так что можно сразу прыгнуть к месту преступления. Вариант InvalidLength - самый придирчивый: с версии 0.22.0 он срабатывает именно тогда, когда количество валидных символов невозможно, а это длина на один больше кратного четырём числа, тогда как прочие плохие длины проступают как ошибки заполнителя. Вот полный match на те дни, когда вы хотите обрабатывать каждый сбой по-своему:
use base64::DecodeError;
use base64::prelude::*;
fn triage(dirty: &str) {
match BASE64_STANDARD.decode(dirty) {
Ok(_) => println!("{dirty:?} sailed through"),
Err(DecodeError::InvalidByte(offset, byte)) =>
println!("{dirty:?}: symbol {byte} at {offset} is not in the alphabet"),
Err(DecodeError::InvalidLength(symbols)) =>
println!("{dirty:?}: {symbols} valid symbols is impossible"),
Err(DecodeError::InvalidLastSymbol { offset, .. }) =>
println!("{dirty:?}: trailing bits at {offset} suggest truncation"),
Err(DecodeError::InvalidPadding) =>
println!("{dirty:?}: padding is wrong or missing"),
}
}
Намеренная строгость уходит корнями в сам стандарт. Раздел 12 RFC 4648 предупреждает, что символы вне алфавита могут использоваться как скрытый канал, чтобы проконтрабандировать побочную информацию или выуживать баги в небрежных парсерах, и рекомендует декодерам отвергать их. Спецификация MIME - знаменитое исключение: она прямо говорит декодерам игнорировать чужие символы, и именно такой вид ввода нижеследующий раздел о почте научит вас укрощать.
Три позиции по заполнителю
Каждая Base64-строка в дикой природе молча даёт обещание про заполнитель, и версия 0.23 позволяет выбрать, какое обещание исполнять, через перечисление DecodePaddingMode. Режимов три, и поведенческая разница стоит того, чтобы запомнить её. Вот сводная таблица для Zm8 - это слово fo без его =:
| Режим | "Zm8", без заполнителя |
"Zm8=", с заполнителем |
Когда применять |
|---|---|---|---|
RequireCanonical, по умолчанию |
Err(Invalid padding) |
Ok([102, 111]) |
Вы сами производите и потребляете данные |
Indifferent |
Ok([102, 111]) |
Ok([102, 111]) |
Принимаете данные из смешанных источников |
RequireNone |
Ok([102, 111]) |
Err(Invalid padding) |
Работаете с протоколом, где заполнителя нет |
use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig, STANDARD_PAD_INDIFFERENT};
use base64::engine::DecodePaddingMode;
use base64::prelude::*;
let strict = BASE64_STANDARD; // по умолчанию RequireCanonical
let flexible = STANDARD_PAD_INDIFFERENT;
let bare = GeneralPurpose::new(
&base64::alphabet::STANDARD,
GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::RequireNone),
);
println!("{:?}", strict.decode("Zm8")); // Err(Invalid padding)
println!("{:?}", flexible.decode("Zm8")); // Ok([102, 111])
println!("{:?}", flexible.decode("Zm8=")); // Ok([102, 111])
println!("{:?}", bare.decode("Zm8=")); // Err(Invalid padding)
Значения по умолчанию следуют за стандартом: раздел 3.2 RFC 4648 говорит, что реализации должны включать подходящие символы заполнения в конце закодированных данных, если окружающая спецификация не распоряжается об ином, - вот почему стандартный STANDARD-движок требует их. И выбор важен для безопасности, а не только для педантизма. Принимая и заполненную, и незаполненную записи одних и тех же данных, вы делаете Base64 податливым: одна и та же логическая нагрузка может записываться двумя способами, и любой код, предполагающий единую каноническую запись значения, может получить сюрприз. Статья 2022 года «Маллиабельность Base64 на практике» (Chatzigiannis и Chalkias, ePrint 2022/361) документирует реальные последствия, и документация самого крейта ссылается на неё. Практическое правило: выбирайте один режим на протокол и будьте строги на каждой границе, где вы не контролируете производителя.
Скрытые биты последнего символа
Вот повреждение, которое выживает после любой проверки символов. Каждый символ Base64 несёт 6 бит, и 3 входных байта (24 бита) становятся ровно 4 символами. Когда вход - всего 1 или 2 байта, у последнего символа оказываются неиспользуемые биты, и RFC это чётко проговаривает: соответствующие кодировщики обязаны обнулять эти свободные биты. Багговитый или злонамеренный кодировщик вместо этого может оставить там мусор, и результат всё равно проходит проверку алфавита, проверку длины и проверку заполнителя, тихо неся при этом повреждённый хвост. Строгий движок подстрахует - с уникально подробной ошибкой, которая даже показывает вам подозрительные биты:
use base64::engine::general_purpose::{GeneralPurpose, GeneralPurposeConfig};
use base64::prelude::*;
println!("{:?}", BASE64_STANDARD.decode("MT=="));
// Err(Invalid last symbol 0x54 ('T') at offset 1, decoded as 0b00010011.)
let lenient = GeneralPurpose::new(
&base64::alphabet::STANDARD,
GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true),
);
println!("{}", String::from_utf8_lossy(&lenient.decode("MT==").unwrap()));
// 1
Это 0b00010011 в ошибке - декодированное значение провинившегося символа вместе с незаконными старшими битами, и версия 0.23.0 вынесла эту деталь на свет именно потому, что иначе её так трудно отлаживать. Если вы знаете, что ваши производители небрежны, with_decode_allow_trailing_bits(true) проглотит мусор вместо того, чтобы отвергнуть его. Браузеры сделали ставку на противоположное: алгоритм forgiving-base64 от WHATWG, тот, что стоит за atob() в JavaScript, намеренно снисходителен к хвостовым битам, тогда как поведение Rust по умолчанию - судебный эксперт. Знайте, за какой стороной стола вы сидите.
Base64url: алфавит, который путешествует
Стандартный Base64 тратит последние две ячейки своего алфавита на + и /, а это ровно те символы, которых URL не хотят видеть: в строке запроса плюс означает пробел, слеш начинает новый сегмент пути, а висящий = читается как разделитель. Поэтому раздел 5 RFC 4648 определяет безопасный для URL и имён файлов алфавит: он меняет двух бунтарей на - и _ и, поскольку длину обычно можно восстановить, как правило отбрасывает и заполнитель. RFC даже предупреждает, что это кодирование не стоит считать тем же, что и стандартный Base64, и имена движков с этим согласны:
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let packed = URL_SAFE_NO_PAD.encode(b"\xfb\xef\xbe");
println!("{packed}"); // ----
let back = URL_SAFE_NO_PAD.decode(packed).unwrap();
println!("{back:02x?}"); // [fb, ef, be]
// стандартный движок отказывается от того же входа,
// потому что дефиса в его алфавите нет вовсе
println!("{:?}", base64::prelude::BASE64_STANDARD.decode("----"));
// Err(Invalid symbol 45, offset 0.)
Теперь о том, почему большинство разработчиков вообще сталкивается с base64url: JSON Web Tokens. JWT состоит из трёх base64url-частей, скреплённых точками, и подсмотреть, что внутри, - дело пяти строк:
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
let parts: Vec<&str> = jwt.split('.').collect();
let header = String::from_utf8(URL_SAFE_NO_PAD.decode(parts[0]).unwrap()).unwrap();
let payload = String::from_utf8(URL_SAFE_NO_PAD.decode(parts[1]).unwrap()).unwrap();
println!("{header}");
println!("{payload}");
// {"alg":"HS256","typ":"JWT"}
// {"sub":"1234567890","name":"John Doe","iat":1516239022}
Две честные оговорки. Декодировать JWT - значит подсмотреть, а не доверять: третья часть - это подпись, и она ничего не значит, пока не будет проверена по ключу, а это работа крейта jsonwebtoken (версия 11 на 2026 год). У версии 11 есть один острый край: ей нужна ровно одна из фич rust_crypto или aws_lc_rs, включённая в Cargo.toml, иначе она упанится в первый же момент, когда вы подпишете или проверите токен, а её конструктор Validation по умолчанию считает клейм exp обязательным, так что токены, чеканные для других библиотек, могут потребовать подправленной валидации. И именно на декодировании выбор алфавита кусается: подайте URL-безопасную строку в BASE64_STANDARD, или наоборот, и получите отказ, потому что -, _ и отсутствующий заполнитель для другого движка всё невалидно. Связывайте движок с протоколом, каждый раз.
Байты - не слова
Каждый декодер в этой статье намеренно останавливается на байтах, и в Rust это проще, чем в большинстве языков, потому что нет скрытого шага с кодировкой, который мог бы пойти не так. Base64 - это байтовый формат, точка. Вопрос «какой это был текст?» - ваш, и ответ по умолчанию для современного веба - UTF-8, который вы пропускаете через одну строку стандартной библиотеки:
use base64::prelude::*;
let packed = "Y2Fmw6k="; // слово cafe с акцентом, упакованное
let bytes = BASE64_STANDARD.decode(packed).unwrap();
match std::str::from_utf8(&bytes) {
Ok(text) => println!("{text}"),
Err(_) => eprintln!("not utf-8: {bytes:02x?}"),
}
Счастливый путь мультбайтовых символов покрывает всё, что вы встретите в кабеле:
| Исходный текст | Base64 | Декодируется обратно |
|---|---|---|
café |
Y2Fmw6k= |
да |
日本語 |
5pel5pys6Kqe |
да |
naïve résumé |
bmHDr3ZlIHLDqXN1bcOp |
да |
😀 |
8J+YgA== |
да |
π ≈ 3.14159 |
z4Ag4omIIDMuMTQxNTk= |
да |
А когда данные - вовсе не текст, тот же код просто получает другой финал. Вот магическое число файла PNG: четыре байта 89 50 4E 47 плюс пара CRLF, следующая за ними:
use base64::prelude::*;
let packed = "iVBORw0KGgo=";
let bytes = BASE64_STANDARD.decode(packed).unwrap();
println!("{bytes:02x?}"); // [89, 50, 4e, 47, 0d, 0a, 1a, 0a]
assert!(std::str::from_utf8(&bytes).is_err());
std::fs::write("sprite.png", &bytes).unwrap(); // на выходе байты, а не текст
Правило-ориентир короткое: предполагайте UTF-8, проверяйте через std::str::from_utf8() и относите всё, что не прошло проверку, к байтовым нагрузкам для fs::write, блоба в базе данных или к тому стóку, откуда данные пришли. Единственный случай, когда тянутся за настоящей кодировкой, - устаревшие данные, которые так и не перенесли. Крейт encoding_rs (версия 0.8) знает старые кодировки по именам и конвертирует:
use base64::prelude::*;
use encoding_rs::Encoding;
let packed = "Y2Fm6Q=="; // cafe с акцентом, упакованное из байтов Latin-1
let bytes = BASE64_STANDARD.decode(packed).unwrap();
let (text, _, _) = Encoding::for_label(b"windows-1252").unwrap().decode(&bytes);
println!("{text}"); // cafe с акцентом, в виде UTF-8
В крейте base64 нет режима «декодировать как Latin-1», в котором можно было бы ошибиться, потому что он никогда не гадает за вас. Это та дисциплина, которую вы держите: крейт выдаёт вам байты, а вы решаете, что они значат.
Ввод, переживший почту
Base64, переживший почтовую систему, носит с собой переносы строк: MIME переносит на 76 символов в строку (PEM-блоки - на 64), а спецификация MIME прямо говорит надлежащим декодерам игнорировать символы вне алфавита, включая переносы строк. Наш движок - антипод MIME-совместимости: он отвергает самый первый перенос строки, а потоковый читатель докладывает об отказе как об ошибке ввода-вывода, обёртывающей тот же самый точный DecodeError:
use std::io::Read;
use base64::prelude::*;
use base64::read::DecoderReader;
let wrapped_in = "SGVs\nbG8s";
let mut reader = DecoderReader::new(wrapped_in.as_bytes(), &BASE64_STANDARD);
let mut out = Vec::new();
println!("{:?}", reader.read_to_end(&mut out).map(|_| out));
// Err(Custom { kind: InvalidData, error: Invalid symbol 10, offset 4. })
Обе позиции ведут к одному и тому же RFC, который оставляет выбор окружающей спецификации, и крейт base64 выбрал быть строгим. Это не первый раз, когда он передумал: версия 0.5.0 вышла со встроенным MIME-переносом строк и обработкой пробельных символов, а версия 0.10.0 убрала и то и другое, рассудив, что перенос строк - слишком категоричная позиция для общей библиотеки, да ещё и усложняет историю с no_std. Так что рецепт для реального перенесённого ввода - тот же, что советует и документация самого крейта: сначала отбросить символы вне алфавита, затем декодировать. Для строки в памяти это один фильтр:
use base64::prelude::*;
fn strip_non_b64(input: &[u8]) -> Vec<u8> {
input.iter().copied().filter(|b| !b" \n\r\t\x0b\x0c".contains(b)).collect()
}
fn main() {
let wrapped = "SGVs\nbG8s\r\nIHN0\nYW5kYXJk";
let clean = strip_non_b64(wrapped.as_bytes());
let bytes = BASE64_STANDARD.decode(clean).unwrap();
println!("{}", String::from_utf8_lossy(&bytes)); // Hello, standard
}
Если хочется декодер, который смотрит прямо через переносы, константа BASE64_MIME_PERMISSIVE из крейта data-encoding - это ровно то: она декодирует "SGVsbG8s\r\nd29ybGQh\r\n" в Hello,world!, не требуя от вас ни единой строки. Для потоков, где весь ввод не удержать, FAQ крейта указывает на крейт iter_read для фильтрации байтового потока, либо на написание крошечной обёртки Read, которая выбрасывает нежелательные байты по мере их прихода. Одно предупреждение, прежде чем строить «снисходительный декодер» вручную: тихое игнорирование символов вне алфавита - это ровно то поведение, которое раздел 12 RFC 4648 помечает как скрытый канал, так что отбрасывайте только те пробельные символы, которых ждёте, и отвергайте всё остальное.
Когда всё сообщение перед вами, base64 делает за вас почтовый парсер. Крейт mail-parser (версия 0.11) декодирует каждую часть Content-Transfer-Encoding: base64 прямо по ходу разбора, так что вложения возвращаются сырыми байтами, уже распелёнутыми и декодированными:
use mail_parser::MessageParser;
let email = br#"From: art@vandelay.com
To: jane@example.com
Subject: gift
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="festivus"
--festivus
Content-Type: text/plain; charset="us-ascii"
Content-Transfer-Encoding: base64
SGVsbG8gZnJvbSBlbWFpbA==
--festivus
Content-Type: image/gif
Content-Transfer-Encoding: Base64
Content-Disposition: attachment; filename="tiny.gif"
R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7
--festivus--
"#;
let message = MessageParser::default().parse(email).unwrap();
for part in message.attachments() {
let name = part
.headers()
.iter()
.find(|h| h.name().eq_ignore_ascii_case("content-disposition"))
.and_then(|h| h.value().clone().unwrap_content_type()
.attribute("filename").map(|n| n.to_string()));
let bytes = part.contents().to_vec();
println!("{name:?}: {} bytes", bytes.len());
}
// Some("tiny.gif"): 42 bytes
То GIF-вложение декодируется в 42 байта, начиная с четырёх байтов 47 49 46 38 - букв ASCII GIF8. Вы и строки кода Base64 не написали, и в этом весь смысл использования парсера: детали кодирования - проблема библиотеки.
Крупная нагрузка
Строки - легко; файлы - вот где Base64 окупает себя, и крейт отвечает той же потоковой философией, что и остальной io в Rust. read::DecoderReader оборачивает любой читатель и прозрачно выдаёт декодированные байты по мере чтения, так что многогигабайтный закодированный файл никогда не обязан целиком поместиться в памяти. Для примера ниже сохраните строку dGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZw== в обычный текстовый файл под именем fox.b64:
use std::io::Read;
use base64::prelude::*;
use base64::read::DecoderReader;
fn main() {
let packed = std::fs::read("fox.b64").unwrap();
let mut decoder = DecoderReader::new(&packed[..], &BASE64_STANDARD);
let mut plain = Vec::new();
decoder.read_to_end(&mut plain).unwrap();
println!("{}", String::from_utf8_lossy(&plain));
// the quick brown fox jumps over the lazy dog
}
Та же идея сжимается в одну строку с io::copy: соберите вокруг файла DecoderReader и скопируйте его в любой писатель, и декодирование произойдёт по пути. Есть ещё чудесный трюк из официальной документации для проверки нагрузки в постоянном объёме памяти, со статически заданным по размеру буфером и вообще без аллокации декодированных данных:
use std::io::Cursor;
use std::io::Read;
use base64::prelude::*;
use base64::read::DecoderReader;
fn is_valid_base64(input: &str) -> bool {
let mut cursor = Cursor::new(input.as_bytes());
let mut decoder = DecoderReader::new(&mut cursor, &BASE64_STANDARD);
let mut buf = [0u8; 128];
loop {
match decoder.read(&mut buf) {
Ok(0) => return true, // дочитали до конца без ошибки
Ok(_) => continue,
Err(_) => return false, // что-то не было base64
}
}
}
fn main() {
println!("{}", is_valid_base64("SGVsbG8sIHdvcmxkIQ==")); // true
println!("{}", is_valid_base64("dt==")); // false
}
Для нагрузок, которые большие, но всё ещё помещаются в управляемый вами буфер, срезовое API - вариант без сюрпризов: base64::decoded_len_estimate(len) даёт консервативный максимальный размер декодирования для len символов, а decode_slice() пишет прямо в ваш заранее выделенный буфер, возвращая ровно столько байтов, сколько записало:
use base64::prelude::*;
let packed = "SGVsbG8sIHdvcmxkIQ==";
let cap = base64::decoded_len_estimate(packed.len()); // 15, консервативный максимум
let mut buf = vec![0u8; cap];
let written = BASE64_STANDARD.decode_slice(packed, &mut buf).unwrap();
buf.truncate(written);
println!("{}", std::str::from_utf8(&buf).unwrap()); // Hello, world!
Если ваш буфер слишком мал, вместо паники вы получите чистый DecodeSliceError::OutputSliceTooSmall, а есть вариант decode_slice_unchecked(), который панит по замыслу, - для тех мест, где «слишком мал» - это ошибка программиста, и на ней лучше разбиться, чем таскать её по коду. С версии 0.22.0 срезовая проверка консервативна в вашу пользу: она срабатывает только когда вывод действительно не помещается, так что буфер точно в размер работает.
Куда уходят декодированные байты
Base64 встречается в Rust-проектах гораздо чаще, чем подсказывает фраза «какая-то случайная строка»:
- Ответы API и вебхуки, встраивающие бинарные данные или вложенный JSON в виде Base64-текста в своих нагрузках, - классический паттерн «загрузка файла как JSON».
- Осмотр JWT: декодируете заголовок и нагрузку, чтобы подсмотреть клеймы, а подпись передаёте
jsonwebtoken- на ту часть, которая что-то значит по-настоящему. - Данные почтового облика: MIME-вложения и всё, что прошло через почтовую систему, - вот ради чего вообще существует раздел о пробельных символах.
- PEM-блоки в сертификатах и ключах, те самые секции
-----BEGIN CERTIFICATE-----, которые жуют все TLS-стеки; крейтpemпарсит их за вас и, что уместно, внутри построен на крейтеbase64. - Data URI, прячущиеся в HTML и CSS, которые вы скреипите или рендерите, - те, в роде
data:image/png;base64,.... - Заголовки HTTP Basic-авторизации, где
Basic TWFuOnBhc3M=- это простоMan:passв маскировке. - Базы данных и файлы конфигурации, где кому-то понадобился бинарный внутри текстовой колонки или переменной окружения.
- Меязыковая передача: служба на Python упаковывает блоб, Rust распаковывает, и обе стороны по определению говорят на одном алфавите.
Для JSON-случая есть шорткат, о котором стоит знать: крейт serde_with (версия 3) умеет размечать поле структуры так, чтобы serde сам обрабатывал Base64 в обоих направлениях, кодируя поля Vec<u8> в текст при выводе и декодируя обратно при вводе, а URL-безопасный вариант - за одним параметром:
use serde::{Deserialize, Serialize};
use serde_with::serde_as;
#[serde_as]
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Config {
#[serde_as(as = "serde_with::base64::Base64")]
blob: Vec<u8>,
}
let cfg = Config { blob: b"stored in a database".to_vec() };
let json = serde_json::to_string(&cfg).unwrap();
// {"blob":"c3RvcmVkIGluIGEgZGF0YWJhc2U="}
let back: Config = serde_json::from_str(&json).unwrap();
assert_eq!(back, cfg);
Data URI - это двухшаговая операция со строкой, за которой следует обычное декодирование: найдите запятую, оставьте всё, что после неё, и проверьте, что метаданные заканчиваются словом base64:
use base64::prelude::*;
let uri = "data:image/png;base64,iVBORw0KGgo=";
let comma = uri.find(',').unwrap();
let meta = &uri[..comma];
let payload = &uri[comma + 1..];
let is_b64 = meta.rsplit(';').next().unwrap() == "base64";
let bytes = BASE64_STANDARD.decode(payload).unwrap();
println!("{is_b64}: {} bytes from {meta}", bytes.len());
// true: 8 bytes from data:image/png;base64
И правило, которому подчиняется всё это, - стоит повторить, потому что оно по-прежнему ловит людей с поличным: Base64 - упаковочный скотч, а не замок. Это не шифрование и не сжатие - это противоположность сжатия, - и любой, у кого есть эта статья, может развернуть всё, что он делает. Декодируйте свободно, доверяйте выборочно.
Десятилетие аккуратных шагов
Собственная история крейта читается как медленное подкручивание гаек. Он впервые появился на crates.io в декабре 2015 года, и версия 0.5.0 с гордостью добавила поддержку MIME с настраиваемыми символами переноса и переносом строк. Затем версия 0.10.0 в 2018 году убрала и перенос, и обработку пробельных символов - библиотека рассудила, что общий крейт должен декодировать и оставить поэзию прикладному слою; тот же релиз добавил потоковый кодировщик и обнаружение невалидных хвостовых символов. Версия 0.20.0 в 2022 году ввела абстракцию движка и перевернула значение по умолчанию для заполнителя, так что стандартный движок теперь требует канонического заполнителя; 0.21.0 объявила устаревшими старые свободные функции вроде base64::decode() в пользу методов движка, с пометкой компилятора «Use Engine::decode» (они всё ещё работают, поэтому много легаси-кода компилируется с довольным лицом). В 2024 году версия 0.22.0 отточила семантику ошибок, уточнила, что значит InvalidLength, и ускорила декодирование на 5-10 процентов. А в июле 2026 года пришла версия 0.23.0 с SIMD-движками, пользовательскими символами заполнения, более ясным сообщением InvalidLastSymbol и поднятием MSRV до 1.71, а патч 0.23.1 от 4 августа починил тестовую базу для не-SIMD-архитектур. Десятилетие маленьких, аккуратных шагов, и крейт, начавшийся с «Это же base64. Чего ещё можно пожелать?», теперь отгружает векторизованные ядра.
Формату больше, чем вебу, - вот почему строгость ощущается как личная. В 1987 году протоколу Privacy-Enhanced Mail (RFC 989) нужно было нести бинарные данные по 7-битным почтовым каналам, и он стандартизировал это кодирование со строками в 64 символа; каждый блок -----BEGIN CERTIFICATE-----, которому когда-либо доверял ваш TLS-стек, - прямой потомок того решения. В 1996 году спецификация MIME (RFC 2045) приняла схему, назвала её «base64» в честь 64-символьного алфавита и установила длину строки в 76 символов, которой до сих пор переносятся ваши почтовые вложения. В 2006 году RFC 4648 стал стандартом, на который ссылаются все: таблицы алфавитов, вариант base64url в разделе 5 и правила строгости, которые этот крейт реализует с такой видимой радостью.
Весёлые факты
Потому что полное руководство должно закончиться улыбкой:
- Слово «base64» кодируется в
YmFzZTY0. Формат, описывающий сам себя, - технический эквивалент зеркала, которое говорит на морзе. - Пустая строка декодируется в ноль байтов, но
"AA=="декодируется в один байт: NUL. В Base64 «ничто» и «ноль» - разные существа. - Каждый Base64-кодированный PNG, который вы когда-либо видели, начинается с
iVBORw0K. Это магическое число PNG в маскировке, и один из самых узнаваемых префиксов в интернете. - ID видео на YouTube - это base64url без заполнителя: 8-байтовое значение даёт двенадцать символов Base64, и если отбросить хвостовой заполнитель, остаётся знакомый одиннадцатисимвольный ID, который можно вставить в любое место URL. Одно из самых зримых применений режима без заполнителя на всём интернете.
- Bash считает в системе с основанием 64 уже много лет: арифметический литерал
$((64#...))принимает цифры в порядке0-9,a-z,A-Z, а в конце@и_для значений 62 и 63, так что ваш шелл носит 64-символьный алфавит при всех. - Старые хеши паролей
crypt(3)использовали вариант Base64, чей алфавит начинается с./, и у него есть чудесное свойство: сортировка закодированных строк даёт тот же порядок, что и сортировка исходных байтов. Файлы генеалогии использовали тот же алфавит для встроенных мультимедиа (GEDCOM 5.5; ревизия 5.5.1 отбросила это), а крейтbase64отгружает его какalphabet::CRYPT. - MIME-математика, как её до сих пор считает старое правило-ориентир: перенесённая на строки почтовая нагрузка обходится примерно в 1.37 исходного размера, плюс порядка нескольких сотен байтов заголовков. Почтовая инфраструктура 1990-х правда взимала ту пошлину с каждого вложения.
- Весь крейт -
#![forbid(unsafe_code)], кроме включённой по умолчанию фичиsimd-unsafe, из которой надо выходить, а не входить. Одно слово - «unsafe» - и это имя флага фичи. - Base64 податлив так, что это пугает исследователей безопасности: одни и те же байты можно записать с заполнителем или без, с мусором в хвостовых битах, и снисходительные декодеры этого не заметят. Статья 2022 года продемонстрировала реальные последствия, и поэтому строгое значение по умолчанию в этом крейте ощущается как телохранитель.
- Base64 - не шифрование. Если бы оно им было, вы не смогли бы прочесть вывод любого примера в этой статье. Это место у окна, а не сейф.
Итоги
Выбирайте движок по компании, которую вы собираете: BASE64_STANDARD для всего, что вы производите и контролируете, STANDARD_PAD_INDIFFERENT для границы смешанных источников, URL_SAFE_NO_PAD для токенов и URL, а собранный вручную GeneralPurpose, когда протокол требует собственных правил. Позвольте четырём вариантам DecodeError ворчать со всей точностью, пропускайте байты через std::str::from_utf8(), прежде чем называть их текстом, гоняйте большие вещи потоком через DecoderReader, отбрасывайте только те пробельные символы, которых ждёте, и тянитесь за base64ct, когда время - угроза. Декодируйте всё, доверяйте только то, что верифицируется. А если однажды понадобится пойти в обратную сторону - не распаковывать, а упаковать байты в строку в дорогу, - сестринская статья разбирает кодирование в Rust, от арифметики размера до потокового финала.
Последнее обновление: 2026-09-08
Связанная статья: Кодирование Base64 в Rust: полное руководство