Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
147 lines
4.5 KiB
TypeScript
147 lines
4.5 KiB
TypeScript
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
|
||
}
|