import { constants } from 'fs' import { lstat, open } from 'fs/promises' import path from 'path' import { isPathFreeOfSymbolicLinks } from './path-security' import { getUploadRootDir, resolveStoredUploadPath } from './upload-paths' export type CanonicalMaterialSourceValidationCode = | 'CANONICAL_SOURCE_PATH_UNTRUSTED' | 'CANONICAL_SOURCE_STAT_FAILED' | 'CANONICAL_SOURCE_NOT_REGULAR' | 'CANONICAL_SOURCE_EMPTY' | 'CANONICAL_SOURCE_SIZE_INVALID' | 'CANONICAL_SOURCE_SIZE_MISMATCH' export class CanonicalMaterialSourceValidationError extends Error { readonly code: CanonicalMaterialSourceValidationCode readonly cause?: unknown constructor( code: CanonicalMaterialSourceValidationCode, message: string, cause?: unknown, ) { super(message) this.name = 'CanonicalMaterialSourceValidationError' this.code = code this.cause = cause } } function parseExpectedSize(value: bigint | number | string): bigint { try { const parsed = typeof value === 'bigint' ? value : BigInt(value) if (parsed <= BigInt(0)) { throw new Error('expected size must be positive') } return parsed } catch (error) { throw new CanonicalMaterialSourceValidationError( 'CANONICAL_SOURCE_SIZE_INVALID', 'canonical 源文件的已知大小无效', error, ) } } function resolveTrustedCanonicalSourcePath(storedPath: string): { uploadRoot: string sourcePath: string } { const uploadRoot = path.resolve(getUploadRootDir()) const sourcePath = path.resolve(resolveStoredUploadPath(String(storedPath || '').trim())) const relativePath = path.relative(uploadRoot, sourcePath) if ( !String(storedPath || '').trim() || !relativePath || relativePath === '..' || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath) ) { throw new CanonicalMaterialSourceValidationError( 'CANONICAL_SOURCE_PATH_UNTRUSTED', 'canonical 源文件不在受信任的 uploads 根目录内', ) } return { uploadRoot, sourcePath } } /** * 在 Material 提交前及成功响应前验证本地 canonical 原件。 * * 校验同时使用 lstat 与只读文件描述符 fstat:前者拒绝符号链接和越界路径, * 后者确认实际打开的对象仍是同一个非空普通文件,降低检查后被替换的风险。 */ export async function validateCanonicalMaterialSource(input: { storedPath: string expectedSize: bigint | number | string }): Promise<{ sourcePath: string; size: bigint }> { const expectedSize = parseExpectedSize(input.expectedSize) const { uploadRoot, sourcePath } = resolveTrustedCanonicalSourcePath(input.storedPath) let pathStats try { pathStats = await lstat(sourcePath, { bigint: true }) } catch (error) { throw new CanonicalMaterialSourceValidationError( 'CANONICAL_SOURCE_STAT_FAILED', '无法读取 canonical 源文件状态', error, ) } if (pathStats.isSymbolicLink() || !pathStats.isFile()) { throw new CanonicalMaterialSourceValidationError( 'CANONICAL_SOURCE_NOT_REGULAR', 'canonical 源文件不是普通非符号链接文件', ) } if (!(await isPathFreeOfSymbolicLinks(uploadRoot, sourcePath))) { throw new CanonicalMaterialSourceValidationError( 'CANONICAL_SOURCE_PATH_UNTRUSTED', 'canonical 源文件路径包含符号链接或已离开受信任根目录', ) } if (pathStats.size <= BigInt(0)) { throw new CanonicalMaterialSourceValidationError( 'CANONICAL_SOURCE_EMPTY', 'canonical 源文件为空', ) } if (pathStats.size !== expectedSize) { throw new CanonicalMaterialSourceValidationError( 'CANONICAL_SOURCE_SIZE_MISMATCH', 'canonical 源文件实际大小与已知大小不一致', ) } let handle try { handle = await open( sourcePath, constants.O_RDONLY | (constants.O_NOFOLLOW || 0), ) const openedStats = await handle.stat({ bigint: true }) if (!openedStats.isFile()) { throw new CanonicalMaterialSourceValidationError( 'CANONICAL_SOURCE_NOT_REGULAR', '实际打开的 canonical 源对象不是普通文件', ) } if (openedStats.size <= BigInt(0)) { throw new CanonicalMaterialSourceValidationError( 'CANONICAL_SOURCE_EMPTY', '实际打开的 canonical 源文件为空', ) } if (openedStats.size !== expectedSize || openedStats.size !== pathStats.size) { throw new CanonicalMaterialSourceValidationError( 'CANONICAL_SOURCE_SIZE_MISMATCH', '实际打开的 canonical 源文件大小已发生变化', ) } } catch (error) { if (error instanceof CanonicalMaterialSourceValidationError) { throw error } throw new CanonicalMaterialSourceValidationError( 'CANONICAL_SOURCE_STAT_FAILED', '无法安全打开 canonical 源文件', error, ) } finally { await handle?.close().catch(() => undefined) } return { sourcePath, size: expectedSize } }