gongxiangp2/lib/bounded-file-reader.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

147 lines
4.5 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { lstat, open } from 'fs/promises'
export interface BoundedFileReadOptions {
maxBytes: number
expectedBytes?: number
label?: string
}
interface ValidatedRegularFile {
size: number
}
function assertSafeByteLimit(value: number, name: string): void {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${name}无效`)
}
}
function validateRegularFileSize(
size: number,
options: BoundedFileReadOptions,
): ValidatedRegularFile {
const label = options.label || '文件'
assertSafeByteLimit(options.maxBytes, `${label}大小上限`)
if (options.expectedBytes !== undefined) {
assertSafeByteLimit(options.expectedBytes, `${label}申报大小`)
}
if (!Number.isSafeInteger(size) || size <= 0) {
throw new Error(`${label}为空或大小无效`)
}
if (size > options.maxBytes) {
throw new Error(`${label}超过 ${Math.ceil(options.maxBytes / 1024 / 1024)}MB 限制`)
}
if (options.expectedBytes !== undefined && size !== options.expectedBytes) {
throw new Error(`${label}实际大小与上传记录不一致,请重新上传`)
}
return { size }
}
/**
* 在真正分配 Buffer 前检查路径与实际文件大小。调用方不能只信浏览器传入的
* fileSize;上传会话最高可到数 GiB,直接 readFile 会让 Node 进程被 OOM 杀死。
*/
export async function inspectBoundedRegularFile(
filePath: string,
options: BoundedFileReadOptions,
): Promise<ValidatedRegularFile> {
const label = options.label || '文件'
const pathStats = await lstat(filePath)
if (pathStats.isSymbolicLink() || !pathStats.isFile()) {
throw new Error(`${label}不是可读取的普通文件`)
}
return validateRegularFileSize(pathStats.size, options)
}
/**
* 通过已打开的 FileHandle 读取,打开后再次 fstat,避免检查路径后文件被替换
* 或扩大的窗口。读取完成后还会核对字节数,任何不确定状态都失败关闭。
*/
export async function readBoundedRegularFile(
filePath: string,
options: BoundedFileReadOptions,
): Promise<Buffer> {
await inspectBoundedRegularFile(filePath, options)
const label = options.label || '文件'
const handle = await open(filePath, 'r')
try {
const openedStats = await handle.stat()
if (!openedStats.isFile()) {
throw new Error(`${label}不是可读取的普通文件`)
}
const validated = validateRegularFileSize(openedStats.size, options)
const buffer = await handle.readFile()
if (buffer.length !== validated.size) {
throw new Error(`${label}读取期间发生变化,请重新上传`)
}
return buffer
} finally {
await handle.close()
}
}
/**
* 受限读取 fetch 响应。先核对 Content-Length,再按声明大小预分配单个 Buffer,
* 不使用 arrayBuffer/Buffer.concat,防止未知长度响应造成无上限内存增长。
*/
export async function readBoundedResponseBuffer(
response: Response,
options: BoundedFileReadOptions,
): Promise<Buffer> {
const label = options.label || '远程文件'
assertSafeByteLimit(options.maxBytes, `${label}大小上限`)
if (options.expectedBytes === undefined) {
throw new Error(`${label}缺少可信大小,拒绝载入内存`)
}
assertSafeByteLimit(options.expectedBytes, `${label}申报大小`)
if (options.expectedBytes > options.maxBytes) {
throw new Error(`${label}超过 ${Math.ceil(options.maxBytes / 1024 / 1024)}MB 限制`)
}
const rawContentLength = response.headers.get('content-length')
if (rawContentLength !== null) {
const contentLength = Number(rawContentLength)
if (!Number.isSafeInteger(contentLength) || contentLength !== options.expectedBytes) {
throw new Error(`${label}响应大小与上传记录不一致`)
}
}
if (!response.body) {
throw new Error(`${label}响应内容为空`)
}
const output = Buffer.allocUnsafe(options.expectedBytes)
const reader = response.body.getReader()
let offset = 0
try {
while (true) {
const { done, value } = await reader.read()
if (done) {
break
}
if (!value || value.byteLength === 0) {
continue
}
if (offset + value.byteLength > options.expectedBytes) {
throw new Error(`${label}响应超过申报大小`)
}
output.set(value, offset)
offset += value.byteLength
}
} catch (error) {
await reader.cancel(error).catch(() => undefined)
throw error
} finally {
reader.releaseLock()
}
if (offset !== options.expectedBytes) {
throw new Error(`${label}响应不完整`)
}
return output
}