Buffer 练习答案(三):35~48 题
适用环境:Node.js 22+、TypeScript。本册包括哈希、压缩、文件签名、HTTP 请求体和二进制日志。
35. SHA-256
import { createHash } from "node:crypto";
const body = Buffer.from("important content", "utf8");
const digest = createHash("sha256").update(body).digest("hex");
console.log(digest);
哈希用于完整性校验、去重或内容寻址。SHA-256 不是密码加密;不要把普通 SHA-256 当密码存储方案,密码应使用 scrypt、Argon2 或 bcrypt 一类专用 KDF。
36. HMAC:Webhook 签名
import { createHmac, timingSafeEqual } from "node:crypto";
function signWebhook(rawBody: Buffer, secret: string): string {
return createHmac("sha256", secret).update(rawBody).digest("hex");
}
function verifyWebhook(rawBody: Buffer, secret: string, receivedHex: string): boolean {
if (!/^[0-9a-f]{64}$/i.test(receivedHex)) return false;
const expected = Buffer.from(signWebhook(rawBody, secret), "hex");
const received = Buffer.from(receivedHex, "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}
验签必须使用原始请求体字节,不要先 JSON.parse() 再 JSON.stringify(),因为空白、字段顺序或编码变化会改变签名。timingSafeEqual 需要等长 Buffer,因此先校验格式和长度。
37. gzip 压缩与解压
import { gzip, gunzip } from "node:zlib";
import { promisify } from "node:util";
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
const original = Buffer.from("重复的文本。".repeat(100), "utf8");
const compressed = await gzipAsync(original);
const restored = await gunzipAsync(compressed);
console.log({ original: original.length, compressed: compressed.length });
console.log(restored.toString() === original.toString()); // true
压缩数据来自不可信来源时需限制压缩后/解压后大小和处理时间,防范压缩炸弹。大文件应使用 createGzip() + pipeline(),不要整文件读入内存。
38. 判断 PNG 文件签名
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
function hasPngSignature(content: Buffer): boolean {
return content.length >= PNG_SIGNATURE.length
&& content.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE);
}
文件名和 Content-Type 都可伪造,魔数检查是更好的一层验证,但它只说明“看起来像 PNG”,不是完整安全审查。上传仍需要大小限制、允许列表、存储隔离和必要时的格式解析/重编码。
39. 查找换行字节的位置
function findLineFeeds(buffer: Buffer): number[] {
const positions: number[] = [];
for (let index = 0; index < buffer.length; index += 1) {
if (buffer[index] === 0x0a) positions.push(index);
}
return positions;
}
console.log(findLineFeeds(Buffer.from("a\nb\nc"))); // [1, 3]
这寻找的是 LF。Windows CRLF 行尾会留下前一个 0x0d,文本解析时可只在行尾去掉一个 \r,而不要全局替换内容中的回车。
40. 从日志 Buffer 拆分行
function splitLogLines(data: Buffer): string[] {
const lines: string[] = [];
let start = 0;
for (let index = 0; index < data.length; index += 1) {
if (data[index] !== 0x0a) continue;
const end = index > start && data[index - 1] === 0x0d ? index - 1 : index;
lines.push(data.subarray(start, end).toString("utf8"));
start = index + 1;
}
if (start < data.length) {
lines.push(data.subarray(start).toString("utf8"));
}
return lines;
}
若文件以换行结束,最后不额外生成空行;若最后一行没有换行,仍保留它。超大日志应使用 createReadStream() 逐块处理,并维护跨 chunk 的残留字节,不能 readFile() 一次读完。
41. 长度前缀协议工具模块
const HEADER_BYTES = 4;
const MAX_MESSAGE_BYTES = 1024 * 1024;
export function encodeMessage(text: string): Buffer {
const body = Buffer.from(text, "utf8");
if (body.length > MAX_MESSAGE_BYTES) throw new RangeError("消息过大");
const output = Buffer.allocUnsafe(HEADER_BYTES + body.length);
output.writeUInt32BE(body.length, 0);
body.copy(output, HEADER_BYTES);
return output;
}
export function decodeMessage(buffer: Buffer): string {
if (buffer.length < HEADER_BYTES) throw new Error("消息头不足");
const bodyLength = buffer.readUInt32BE(0);
if (bodyLength > MAX_MESSAGE_BYTES) throw new RangeError("非法消息长度");
if (buffer.length !== HEADER_BYTES + bodyLength) throw new Error("消息不完整或包含额外数据");
return buffer.subarray(HEADER_BYTES).toString("utf8");
}
该 decodeMessage 的约定是一次只接收一帧。TCP 读取时要使用第 34 题的累积器处理多帧与半帧。
42. 用状态表达“数据不足”
type DecodeResult =
| { complete: false; neededAtLeast: number }
| { complete: true; text: string; consumedBytes: number };
function tryDecodeMessage(buffer: Buffer): DecodeResult {
if (buffer.length < 4) return { complete: false, neededAtLeast: 4 };
const bodyLength = buffer.readUInt32BE(0);
if (bodyLength > 1024 * 1024) throw new RangeError("非法消息长度");
const totalLength = 4 + bodyLength;
if (buffer.length < totalLength) return { complete: false, neededAtLeast: totalLength };
return {
complete: true,
text: buffer.subarray(4, totalLength).toString("utf8"),
consumedBytes: totalLength,
};
}
数据不足是正常网络状态,不应当作错误日志。consumedBytes 让调用方从累计 Buffer 中保留下一帧内容。
43. 上传文件校验
type UploadCheck = { ok: true } | { ok: false; reason: string };
function validateUpload(fileName: string, content: Buffer, maxBytes: number): UploadCheck {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) return { ok: false, reason: "maxBytes 配置错误" };
if (content.length === 0) return { ok: false, reason: "空文件不允许" };
if (content.length > maxBytes) return { ok: false, reason: "文件过大" };
if (fileName.includes("\0")) return { ok: false, reason: "文件名包含 NUL" };
const extension = fileName.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
if (!extension || !new Set(["png", "jpg", "jpeg", "webp"]).has(extension)) {
return { ok: false, reason: "不支持的扩展名" };
}
return { ok: true };
}
实际上传还要验证魔数/解析结果、生成服务器端文件名、避免使用原文件名作路径、限制总请求体和文件数。扩展名不能作为唯一安全依据。
44. 从 IncomingMessage 读取完整请求体
import type { IncomingMessage } from "node:http";
export async function readRequestBody(req: IncomingMessage, maxBytes = 1024 * 1024): Promise<Buffer> {
const chunks: Buffer[] = [];
let total = 0;
try {
for await (const chunk of req) {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
total += bytes.length;
if (total > maxBytes) {
req.destroy();
throw new RangeError("请求体过大");
}
chunks.push(bytes);
}
return Buffer.concat(chunks, total);
} catch (error) {
// aborted、socket error 等会从 async iterator 抛出;上层应转换为合适 HTTP 响应/日志。
throw error;
}
}
不要同时给同一个请求注册 data 监听器和使用 for await。生产 Express 项目一般使用 express.json() 等解析器;Webhook 验签等需要原始字节时,再用专门的 raw body 配置。
45. 二进制日志编码与解码
type BinaryLog = { timestampMs: bigint; level: number; message: string };
const LOG_HEADER_BYTES = 13;
function encodeLog(entry: BinaryLog): Buffer {
if (!Number.isInteger(entry.level) || entry.level < 0 || entry.level > 0xff) throw new RangeError("level");
const content = Buffer.from(entry.message, "utf8");
const out = Buffer.allocUnsafe(LOG_HEADER_BYTES + content.length);
out.writeBigInt64BE(entry.timestampMs, 0);
out.writeUInt8(entry.level, 8);
out.writeUInt32BE(content.length, 9);
content.copy(out, LOG_HEADER_BYTES);
return out;
}
function decodeLog(input: Buffer): BinaryLog {
if (input.length < LOG_HEADER_BYTES) throw new Error("日志头不足");
const length = input.readUInt32BE(9);
if (length > 1024 * 1024 || input.length !== LOG_HEADER_BYTES + length) throw new Error("日志长度错误");
return {
timestampMs: input.readBigInt64BE(0),
level: input.readUInt8(8),
message: input.subarray(LOG_HEADER_BYTES).toString("utf8"),
};
}
Date.now() 可转为 BigInt:BigInt(Date.now())。真实日志系统还需考虑文件分段、崩溃时写入半条记录、索引、权限和日志注入;JSON Lines 往往更便于运维检索。
46. 为什么二进制数据不应随意转字符串拼接
const left = Buffer.from([0xff, 0x00, 0xfe]);
const right = Buffer.from([0x80, 0x01]);
const incorrect = Buffer.from(left.toString("utf8") + right.toString("utf8"), "utf8");
const correct = Buffer.concat([left, right]);
console.log(incorrect.toString("hex")); // efbfbd00efbfbdefbfbd01:已被替换字符破坏
console.log(correct.toString("hex")); // ff00fe8001
UTF-8 文本解码会处理非法字节,无法保证可逆。图片、ZIP、加密数据、协议包始终应该以 Buffer 拼接;只有确认是完整文本时才转字符串。
47. 按编码展示 Buffer
type DisplayEncoding = "utf8" | "hex" | "base64";
function displayBuffer(buffer: Buffer, encoding: DisplayEncoding): string {
return buffer.toString(encoding);
}
const sample = Buffer.from("你好");
console.log(displayBuffer(sample, "utf8"));
console.log(displayBuffer(sample, "hex"));
console.log(displayBuffer(sample, "base64"));
utf8 仅适合确定是 UTF-8 文本的内容;调试未知二进制时优先 hex/base64,避免替换字符掩盖原始字节。
48. inspectBuffer() 调试函数
function inspectBuffer(buffer: Buffer): void {
const preview = [...buffer.subarray(0, 10)];
console.log({
length: buffer.length,
hex: buffer.toString("hex"),
utf8: buffer.toString("utf8"),
firstTenDecimalBytes: preview,
});
}
inspectBuffer(Buffer.from("hello中文"));
真实文件可能很大或含敏感数据,调试函数应限制 hex/utf8 的输出长度,必要时脱敏,不要把完整请求体、Token、密码或私钥写进生产日志。