From da8fd96dc0b3c642ed14f9d481b332de5f13485a Mon Sep 17 00:00:00 2001 From: Juhef <117518034+juheff@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:53:58 +0530 Subject: [PATCH] Decode stream bodies once instead of per chunk streamToString called toString() on every chunk, so a UTF-8 character split across a chunk boundary decoded as replacement characters. Buffer the chunks and decode once at the end. --- CHANGELOG.md | 1 + src/streamUtils.ts | 9 ++++++--- src/utils.spec.ts | 14 ++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e69de29bb2d..f8ab39a627a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -0,0 +1 @@ +- Fixed `streamToString` corrupting multi-byte UTF-8 characters that span a stream chunk boundary. diff --git a/src/streamUtils.ts b/src/streamUtils.ts index 69d05964970..585b32e4034 100644 --- a/src/streamUtils.ts +++ b/src/streamUtils.ts @@ -25,11 +25,14 @@ export function streamToString(s?: NodeJS.ReadableStream | null): Promise { - let b = ""; + const chunks: Buffer[] = []; s.on("error", reject); s.on("data", (d: Buffer | string) => { - b += d.toString(); + // Buffer the raw chunks and decode once at the end. Decoding each chunk + // on its own corrupts any multi-byte character that straddles a chunk + // boundary, turning it into replacement characters. + chunks.push(Buffer.isBuffer(d) ? d : Buffer.from(String(d), "utf8")); }); - s.once("end", () => resolve(b)); + s.once("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); }); } diff --git a/src/utils.spec.ts b/src/utils.spec.ts index 783709570e3..2e07d6197fd 100644 --- a/src/utils.spec.ts +++ b/src/utils.spec.ts @@ -4,6 +4,7 @@ import * as sinon from "sinon"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; +import { Readable } from "stream"; import * as utils from "./utils"; import { FirebaseError } from "./error"; @@ -294,6 +295,19 @@ describe("utils", () => { await expect(utils.streamToString(undefined)).to.eventually.equal(""); await expect(utils.streamToString(null)).to.eventually.equal(""); }); + + it("should decode a multi-byte character split across chunks", async () => { + // "café 中文 😀" holds 2-, 3- and 4-byte UTF-8 characters. + const text = "caf\u00e9 \u4e2d\u6587 \u{1F600}"; + const buf = Buffer.from(text, "utf8"); + const cut = buf.indexOf(Buffer.from("\u00e9", "utf8")) + 1; + const stream = new Readable(); + stream.push(buf.subarray(0, cut)); + stream.push(buf.subarray(cut)); + stream.push(null); + + await expect(utils.streamToString(stream)).to.eventually.equal(text); + }); }); describe("allSettled", () => {