Главная / Статьи / Почему декодирование кусков буфера в виде текста нарушает загрузку файлов

Почему декодирование кусков буфера в виде текста нарушает загрузку файлов

Объясняется, как обработка данных бинарного буфера как текста в формате UTF-8 тайно повреждает загруженные файлы, и показано правильное обращение с данными на уровне байтов для предотвращения этого.

1375 слов

Эндпоинт загрузки файлов может пройти любые ручные тесты, которые вы над ним проведёте. Маленькие изображения, PDF-файлы, файлы простого текста — всё проходит без проблем. Но вдруг, без предупреждения, клиент загружает файл, который возвращается повреждённым: изображение с рассеянными неверными пикселями или данные, которые не удаётся распарсить как JSON, хотя ранее они были корректными у клиента. Никто не трогал файл в процессе передачи. Ущерб возник незаметно, в коде, который на первый взгляд кажется совершенно разумным, а корневая причина — одна из самых частых ошибок в Node.js: обращение с двоичными данными так, как будто это текст.

Что на самом деле такое Buffer

Buffer — это просто способ Node хранения последовательности необработанных байт в памяти. Он не несёт никакого встроенного смысла и не имеет кодировки символов; это просто числовые значения от 0 до 255, хранящиеся последовательно:

const buf = Buffer.from([72, 101, 108, 108, 111]);
console.log(buf); // <Buffer 48 65 6c 6c 6f>
console.log(buf.toString("utf8")); // "Hello"

Эти пять значений байт превращаются в читаемую строку "Hello" только тогда, когда вы намеренно интерпретируете их с использованием определённой кодировки — в данном примере UTF-8. Сами по себе байты не являются текстом; это просто байты. Класс Buffer существует именно для того, чтобы позволить работать с двоичными данными до или вовсе без решения о том, следует ли читать их как символы. Именно эта пропасть между «сырыми байтами» и «текстом в выбранной кодировке» является причиной возникновения всей этой категории ошибок.

Ошибка: декодирование двоичных данных как текста

Схема, вызывающая эту проблему, выглядит крайне обычно:

app.post("/upload", (req, res) => {
  let body = "";
  req.on("data", (chunk) => {
    body += chunk.toString("utf8"); // corrupting the file, one chunk at a time
  });
  req.on("end", () => {
    fs.writeFileSync("upload.png", body, "utf8"); // and corrupting it again here
  });
});

Байты изображения не являются текстом. Они представляют собой произвольный двоичный поток, содержащий значения пикселей, таблицы сжатия и метаданные, причем ни один из этих элементов не предназначался для чтения как символы UTF-8. Вызов метода .toString("utf8") для обработки этого двоичного данных заставляет среду выполнения интерпретировать байты, которые часто не соответствуют никакой допустимой последовательности UTF-8. Вместо того чтобы выдать ошибку, декодер тихо заменяет байты, которые невозможно декодировать, на символ замены Unicode (, U+FFFD). Исходные байты полностью исчезают, уступая место заменителю, не позволяющему восстановить первоначальное значение. Именно поэтому возникающие повреждения кажутся разрозненными и случайными: искажаются только те последовательности байт, которые не являются допустимыми в формате UTF-8, а для двоичных форматов, таких как изображения, это происходит постоянно.

app.post("/upload", (req, res) => {
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk)); // keep raw bytes, don't decode anything
  req.on("end", () => {
    const fileBuffer = Buffer.concat(chunks);
    fs.writeFileSync("upload.png", fileBuffer); // write raw bytes, no string conversion involved
  });
});

При условии, что тело запроса содержит сырые байты файла напрямую, а не данные в формате multipart/form-data, этот подход сохраняет файл точно таким, как он был загружен. Если вы работаете с многокомпонентными загрузками, сначала пропустите данные через соответствующий парсер формата multipart, чтобы извлечь часть с файлом. Основное решение простое: никогда не преобразовывайте двоичные данные в строку. Вместо этого собирайте поступающие фрагменты типа Buffer в том виде, в котором они пришли, объединяйте их на уровне байтов и записывайте эти байты непосредственно на диск или в хранилище.

Та же ошибка, но более мелкая и коварная: многобайтовые символы, разделенные на фрагменты

Даже при работе с текстом декодирование фрагментов по отдельности, а не все сразу, может привести к связанной, но более тонкой форме этой ошибки, особенно актуальной при пошаговой обработке потоковых данных:

readStream.on("data", (chunk) => {
  process.stdout.write(chunk.toString("utf8")); // can corrupt multi-byte characters
});

Одно эмодзи или буква с диакритическими знаками в формате UTF-8 может занимать несколько байт, и границы блоков данных из сетевого соединения или потока файла не содержат информации о том, где проходят эти многобайтовые границы. Если блок данных заканчивается посередине символа, декодирование этого блока отдельно приводит к обрыву символа, который незаметно заменяется на знак-заменитель, хотя полная и корректная последовательность байт всегда присутствовала, просто она была разделена на два отдельных вызова метода .toString(), каждый из которых видел лишь половину этой последовательности.

const decoder = new (require("string_decoder").StringDecoder)("utf8");

readStream.on("data", (chunk) => {
  process.stdout.write(decoder.write(chunk)); // holds incomplete multi-byte sequences until the rest arrives
});

readStream.on("end", () => {
  process.stdout.write(decoder.end());
});

Встроенный в Node класс StringDecoder разработан именно для таких ситуаций: он не декодирует неполные многобайтовые последовательности, находящиеся в конце блока данных, а вместо этого ждет появления оставшихся байтов в следующем блоке перед завершением обработки символа. Это существенно отличается от способа объединения буферов с помощью Buffer.concat; первый метод подходит тогда, когда необходимо безопасно декодировать текст по мере его поступления, в отличие от метода, при котором сначала формируется полный бинарный файл, а затем уже производится любая конвертация.

Несоответствия при кодировании: запись одной кодировки, чтение другой

Существует еще одна подобная проблема, которая проявляется так же незаметно — выбор несовместимых кодировок при записи и чтении данных.

const token = crypto.randomBytes(32); // raw binary
const encoded = token.toString("base64"); // encode once, deliberately, for safe transport

// later, elsewhere in the codebase
const decoded = Buffer.from(encoded, "hex"); // wrong encoding — does not recover the original bytes

Buffer.from считывает строку в соответствии с указанным аргументом кодировки. Если эта кодировка не совпадает с той, которая изначально использовалась для создания строки, вы не сможете надежным образом восстановить исходные байты. В зависимости от используемой кодировки и структуры входных данных Node может декодировать совершенно другие значения байт или молча игнорировать те части строки, которые не соответствуют правилам данной кодировки, вместо того чтобы выдать ошибку, которую можно было бы сразу заметить.

Buffer.alloc против Buffer.allocUnsafe: разница, связанная с безопасностью, а не только с производительностью

Есть еще одно важное отличие, которое стоит запомнить; оно имеет особое значение здесь, потому что неправильное использование приводит не только к багу, но и к потенциальной утечке конфиденциальных данных:

const safeBuf = Buffer.alloc(16);       // zero-filled, always
const fastBuf = Buffer.allocUnsafe(16); // NOT zero-filled — may contain old memory contents

Buffer.allocUnsafe пропускает этап сброса к нулю выделяемой памяти, что действительно делает процесс быстрее, но это означает, что буфер может по-прежнему содержать любые байты, которые ранее находились в этой области памяти — возможно, остатки данных от предыдущего запроса, фрагмент токена сессии другого пользователя или что-то ещё, что ранее там хранилось. Если вы выделяете буфер таким образом и записываете в него только часть данных перед отправкой, будь то по сетевому соединению или в файл на диске, вы рискуете обнаружить информацию, не связанную с текущей операцией. Buffer.alloc требует небольших, предсказуемых затрат на предварительный сброс памяти к нулю, и именно это должно быть вашим стандартным вариантом. Обращайтесь к allocUnsafe только в том узком случае, когда вы абсолютно уверены, что сами перезапишете весь буфер до того, как к нему обратится кто-либо ещё.

Фактический урок

Каждая из этих проблем сводится к одной основной путанице: рассматривать необработанную последовательность байт как текст, хотя на самом деле «текст» существует лишь после того, как принято определенное решение о кодировке для интерпретации этих байт, причем это решение может быть принято неправильно, слишком рано или в неверный момент в работе системы. Более безопасным подходом является сохранение двоичных данных в их первоначальном виде как можно дольше, объединение и преобразование их в виде необработанных байт, а преобразование в строку — только тогда, когда действительно требуется использовать их в качестве текста, причем при этом следует применять правильную, заранее выбранную кодировку. Без такой дисциплины повреждение данных не проявляется в виде очевидной ошибки; оно просто незаметно заменяет те байты, которые не удалось интерпретировать, и проблема обнаруживается позже, обычно когда пользователь сообщает о том, что что-то не работает.

Связанные статьи