From 98068a4209425aedf683f7808b0065913d022c4d Mon Sep 17 00:00:00 2001 From: chenpei Date: Thu, 13 Aug 2026 10:55:22 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Alpine=20=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E8=BD=AC=E7=A0=81=E7=A9=BA=E9=97=B4=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/__tests__/disk-space.test.ts | 236 ++++++++++++++----------------- lib/disk-space.ts | 128 +++++------------ 2 files changed, 138 insertions(+), 226 deletions(-) diff --git a/lib/__tests__/disk-space.test.ts b/lib/__tests__/disk-space.test.ts index 7cb0efd..2d0ecf8 100644 --- a/lib/__tests__/disk-space.test.ts +++ b/lib/__tests__/disk-space.test.ts @@ -3,31 +3,114 @@ * * 测试内容: * - formatBytes 函数 - * - getDiskUsageLevel 函数(逻辑验证) - * - hasEnoughDiskSpace 函数(逻辑验证) - * - ensureEnoughDiskSpace 函数(逻辑验证) + * - Node statfs 跨平台磁盘空间读取 + * - getDiskUsageLevel / hasEnoughDiskSpace / ensureEnoughDiskSpace 逻辑 */ -import { exec } from 'child_process' -import { promisify } from 'util' -import { formatBytes } from '../disk-space' +import { statfs } from 'fs/promises' +import { + checkDiskSpace, + ensureEnoughDiskSpace, + formatBytes, + getDiskUsageLevel, + hasEnoughDiskSpace, +} from '../disk-space' -const execAsync = promisify(exec) - -// Mock child_process.exec at module level -jest.mock('child_process', () => { - const originalModule = jest.requireActual('child_process') +jest.mock('fs/promises', () => { + const originalModule = jest.requireActual('fs/promises') return { ...originalModule, - exec: jest.fn(), + statfs: jest.fn(), } }) +const mockedStatfs = statfs as jest.MockedFunction +const GIBIBYTE = BigInt(1024) * BigInt(1024) * BigInt(1024) + +function mockSpace(total: bigint, free: bigint, available = free, blockSize = BigInt(1)) { + mockedStatfs.mockResolvedValueOnce({ + type: BigInt(0), + bsize: blockSize, + blocks: total / blockSize, + bfree: free / blockSize, + bavail: available / blockSize, + files: BigInt(0), + ffree: BigInt(0), + }) +} + describe('R38: disk-space', () => { beforeEach(() => { jest.clearAllMocks() }) + describe('checkDiskSpace', () => { + it('使用 Node statfs 读取字节数,不依赖系统 df 命令', async () => { + mockSpace(BigInt(100_000), BigInt(80_000), BigInt(75_000)) + + await expect(checkDiskSpace('/app/uploads/transcoded')).resolves.toEqual({ + total: 100_000, + used: 20_000, + available: 75_000, + usePercent: 22, + mountPoint: '/app/uploads/transcoded', + }) + expect(mockedStatfs).toHaveBeenCalledWith('/app/uploads/transcoded', { bigint: true }) + }) + + it('按块大小和非特权可用块计算并向上取整使用率', async () => { + mockSpace(BigInt(12_288), BigInt(8_192), BigInt(4_096), BigInt(4_096)) + + const result = await checkDiskSpace('/app/uploads') + + expect(result).toMatchObject({ + total: 12_288, + used: 4_096, + available: 4_096, + usePercent: 50, + }) + }) + + it('不把文件系统保留块算作 nextjs 可用空间', async () => { + mockSpace(BigInt(100), BigInt(20), BigInt(10)) + + await expect(checkDiskSpace('/app/uploads')).resolves.toMatchObject({ + total: 100, + used: 80, + available: 10, + usePercent: 89, + }) + }) + + it('超出安全整数范围时钳制数值,避免精度溢出绕过空间门禁', async () => { + const huge = BigInt(Number.MAX_SAFE_INTEGER) + BigInt(10_000) + mockSpace(huge, huge, huge) + + const result = await checkDiskSpace('/app/uploads') + + expect(result?.total).toBe(Number.MAX_SAFE_INTEGER) + expect(result?.available).toBe(Number.MAX_SAFE_INTEGER) + expect(result?.used).toBe(0) + }) + + it('空文件系统统计不会产生除零或非有限使用率', async () => { + mockSpace(BigInt(0), BigInt(0), BigInt(0)) + + await expect(checkDiskSpace('/app/uploads')).resolves.toMatchObject({ + total: 0, + used: 0, + available: 0, + usePercent: 0, + }) + }) + + it('statfs 失败时返回 null,让严格调用方保持 fail-closed', async () => { + mockedStatfs.mockRejectedValueOnce(Object.assign(new Error('stale mount'), { code: 'ESTALE' })) + + await expect(checkDiskSpace('/app/uploads')).resolves.toBeNull() + }) + }) + describe('formatBytes', () => { it('应该正确格式化字节数', () => { expect(formatBytes(0)).toBe('0.00 B') @@ -41,68 +124,29 @@ describe('R38: disk-space', () => { describe('getDiskUsageLevel - 逻辑验证', () => { it('使用率低于 70% 应该返回 ok', async () => { - const mockExec = exec as jest.MockedFunction - mockExec.mockImplementationOnce((command, options, callback) => { - if (typeof options === 'function') { - callback = options - } - // 模拟 PowerShell JSON 输出(60% 使用率) - const stdout = JSON.stringify({ Used: 60000000000, Free: 40000000000 }) - ;(callback as Function)(null, { stdout, stderr: '' }) - return {} as any - }) + mockSpace(BigInt(100_000), BigInt(40_000)) - const { getDiskUsageLevel } = require('../disk-space') const result = await getDiskUsageLevel() expect(result).toBe('ok') }) it('使用率在 70%-90% 之间应该返回 warning', async () => { - const mockExec = exec as jest.MockedFunction - mockExec.mockImplementationOnce((command, options, callback) => { - if (typeof options === 'function') { - callback = options - } - // 模拟 PowerShell JSON 输出(75% 使用率) - const stdout = JSON.stringify({ Used: 75000000000, Free: 25000000000 }) - ;(callback as Function)(null, { stdout, stderr: '' }) - return {} as any - }) + mockSpace(BigInt(100_000), BigInt(25_000)) - const { getDiskUsageLevel } = require('../disk-space') const result = await getDiskUsageLevel() expect(result).toBe('warning') }) it('使用率大于 90% 应该返回 critical', async () => { - const mockExec = exec as jest.MockedFunction - mockExec.mockImplementationOnce((command, options, callback) => { - if (typeof options === 'function') { - callback = options - } - // 模拟 PowerShell JSON 输出(95% 使用率) - const stdout = JSON.stringify({ Used: 95000000000, Free: 5000000000 }) - ;(callback as Function)(null, { stdout, stderr: '' }) - return {} as any - }) + mockSpace(BigInt(100_000), BigInt(5_000)) - const { getDiskUsageLevel } = require('../disk-space') const result = await getDiskUsageLevel() expect(result).toBe('critical') }) it('无法检查时应该返回 ok', async () => { - const mockExec = exec as jest.MockedFunction - mockExec.mockImplementationOnce((command, options, callback) => { - if (typeof options === 'function') { - callback = options - } - // 模拟命令失败 - ;(callback as Function)(new Error('Command failed'), null) - return {} as any - }) + mockedStatfs.mockRejectedValueOnce(new Error('statfs failed')) - const { getDiskUsageLevel } = require('../disk-space') const result = await getDiskUsageLevel() expect(result).toBe('ok') }) @@ -110,68 +154,29 @@ describe('R38: disk-space', () => { describe('hasEnoughDiskSpace - 逻辑验证', () => { it('当空间足够时应该返回 true', async () => { - const mockExec = exec as jest.MockedFunction - mockExec.mockImplementationOnce((command, options, callback) => { - if (typeof options === 'function') { - callback = options - } - // 模拟 PowerShell JSON 输出(20% 使用率,80GB 可用) - const stdout = JSON.stringify({ Used: 20000000000, Free: 80000000000 }) - ;(callback as Function)(null, { stdout, stderr: '' }) - return {} as any - }) + mockSpace(BigInt(100) * GIBIBYTE, BigInt(80) * GIBIBYTE) - const { hasEnoughDiskSpace } = require('../disk-space') const result = await hasEnoughDiskSpace(10 * 1024 * 1024 * 1024) expect(result).toBe(true) }) it('当可用空间不足时应该返回 false', async () => { - const mockExec = exec as jest.MockedFunction - mockExec.mockImplementationOnce((command, options, callback) => { - if (typeof options === 'function') { - callback = options - } - // 模拟 PowerShell JSON 输出(90% 使用率,10GB 可用) - const stdout = JSON.stringify({ Used: 90000000000, Free: 10000000000 }) - ;(callback as Function)(null, { stdout, stderr: '' }) - return {} as any - }) + mockSpace(BigInt(100) * GIBIBYTE, BigInt(10) * GIBIBYTE) - const { hasEnoughDiskSpace } = require('../disk-space') const result = await hasEnoughDiskSpace(20 * 1024 * 1024 * 1024) expect(result).toBe(false) }) it('当磁盘使用率过高时应该返回 false', async () => { - const mockExec = exec as jest.MockedFunction - mockExec.mockImplementationOnce((command, options, callback) => { - if (typeof options === 'function') { - callback = options - } - // 模拟 PowerShell JSON 输出(95% 使用率,5GB 可用) - const stdout = JSON.stringify({ Used: 95000000000, Free: 5000000000 }) - ;(callback as Function)(null, { stdout, stderr: '' }) - return {} as any - }) + mockSpace(BigInt(100) * GIBIBYTE, BigInt(5) * GIBIBYTE) - const { hasEnoughDiskSpace } = require('../disk-space') const result = await hasEnoughDiskSpace(100 * 1024 * 1024) expect(result).toBe(false) }) it('当无法检查时应该返回 true(保守处理)', async () => { - const mockExec = exec as jest.MockedFunction - mockExec.mockImplementationOnce((command, options, callback) => { - if (typeof options === 'function') { - callback = options - } - // 模拟命令失败 - ;(callback as Function)(new Error('Command failed'), null) - return {} as any - }) + mockedStatfs.mockRejectedValueOnce(new Error('statfs failed')) - const { hasEnoughDiskSpace } = require('../disk-space') const result = await hasEnoughDiskSpace(10 * 1024 * 1024 * 1024) expect(result).toBe(true) }) @@ -179,49 +184,20 @@ describe('R38: disk-space', () => { describe('ensureEnoughDiskSpace - 逻辑验证', () => { it('当空间足够时不应该抛出错误', async () => { - const mockExec = exec as jest.MockedFunction - mockExec.mockImplementationOnce((command, options, callback) => { - if (typeof options === 'function') { - callback = options - } - // 模拟 PowerShell JSON 输出(20% 使用率,80GB 可用) - const stdout = JSON.stringify({ Used: 20000000000, Free: 80000000000 }) - ;(callback as Function)(null, { stdout, stderr: '' }) - return {} as any - }) + mockSpace(BigInt(100) * GIBIBYTE, BigInt(80) * GIBIBYTE) - const { ensureEnoughDiskSpace } = require('../disk-space') await expect(ensureEnoughDiskSpace(10 * 1024 * 1024 * 1024, '测试操作')).resolves.not.toThrow() }) it('当空间不足时应该抛出错误', async () => { - const mockExec = exec as jest.MockedFunction - mockExec.mockImplementationOnce((command, options, callback) => { - if (typeof options === 'function') { - callback = options - } - // 模拟 PowerShell JSON 输出(90% 使用率,10GB 可用) - const stdout = JSON.stringify({ Used: 90000000000, Free: 10000000000 }) - ;(callback as Function)(null, { stdout, stderr: '' }) - return {} as any - }) + mockSpace(BigInt(100) * GIBIBYTE, BigInt(10) * GIBIBYTE) - const { ensureEnoughDiskSpace } = require('../disk-space') await expect(ensureEnoughDiskSpace(20 * 1024 * 1024 * 1024, '测试操作')).rejects.toThrow('磁盘空间不足') }) it('当无法检查时不应该抛出错误', async () => { - const mockExec = exec as jest.MockedFunction - mockExec.mockImplementationOnce((command, options, callback) => { - if (typeof options === 'function') { - callback = options - } - // 模拟命令失败 - ;(callback as Function)(new Error('Command failed'), null) - return {} as any - }) + mockedStatfs.mockRejectedValueOnce(new Error('statfs failed')) - const { ensureEnoughDiskSpace } = require('../disk-space') await expect(ensureEnoughDiskSpace(10 * 1024 * 1024 * 1024, '测试操作')).resolves.not.toThrow() }) }) diff --git a/lib/disk-space.ts b/lib/disk-space.ts index 61520ba..9a767de 100644 --- a/lib/disk-space.ts +++ b/lib/disk-space.ts @@ -3,16 +3,10 @@ * * 用途:在大文件操作前检查磁盘剩余空间,防止因磁盘写满导致服务崩溃 * - * 支持平台: - * - Linux: 使用 `df` 命令 - * - Windows (WSL): 使用 `df` 命令 - * - Windows (原生): 使用 PowerShell 的 Get-PSDrive + * 使用 Node.js 原生 statfs,不依赖 GNU/BusyBox 命令参数或文本输出格式。 */ -import { exec } from 'child_process' -import { promisify } from 'util' - -const execAsync = promisify(exec) +import { statfs } from 'fs/promises' interface DiskSpaceInfo { /** 磁盘总容量(字节) */ @@ -27,102 +21,44 @@ interface DiskSpaceInfo { mountPoint: string } -/** - * 检查磁盘空间(Linux/WSL 使用 df 命令) - */ -async function checkDiskSpaceLinux(path: string): Promise { - try { - // df -B1: 以字节为单位显示 - // --output: 指定输出字段 - const { stdout } = await execAsync(`df -B1 --output=size,used,avail,pcent,target "${path}"`, { - timeout: 5000, // 5秒超时 - }) - - const lines = stdout.trim().split('\n') - if (lines.length < 2) { - console.warn('[DiskSpace] Failed to parse df output') - return null - } - - // 跳过标题行,解析数据行 - const data = lines[1].trim().split(/\s+/) - if (data.length < 5) { - console.warn('[DiskSpace] Invalid df output format') - return null - } - - const total = parseInt(data[0], 10) - const used = parseInt(data[1], 10) - const available = parseInt(data[2], 10) - const usePercentStr = data[3].replace('%', '') - const usePercent = parseInt(usePercentStr, 10) - const mountPoint = data[4] - - return { - total, - used, - available, - usePercent, - mountPoint, - } - } catch (error) { - console.error('[DiskSpace] Failed to check disk space:', error) - return null - } +function clampToSafeNumber(value: bigint): number { + if (value <= BigInt(0)) return 0 + const maxSafeInteger = BigInt(Number.MAX_SAFE_INTEGER) + return value > maxSafeInteger ? Number.MAX_SAFE_INTEGER : Number(value) } /** - * 检查磁盘空间(Windows 原生使用 PowerShell) - */ -async function checkDiskSpaceWindows(path: string): Promise { - try { - // 获取盘符 - const driveLetter = path.charAt(0).toUpperCase() - const drive = driveLetter + ':' - - // 使用 PowerShell Get-PSDrive 获取磁盘信息 - const { stdout } = await execAsync( - `powershell -Command "Get-PSDrive -Name '${driveLetter}' | Select-Object Used,Free | ConvertTo-Json"`, - { timeout: 5000 } - ) - - const result = JSON.parse(stdout) - if (!result) { - return null - } - - const used = result.Used || 0 - const free = result.Free || 0 - const total = used + free - const usePercent = total > 0 ? Math.round((used / total) * 100) : 0 - - return { - total, - used, - available: free, - usePercent, - mountPoint: drive, - } - } catch (error) { - console.error('[DiskSpace] Failed to check disk space (Windows):', error) - return null - } -} - -/** - * 检查磁盘空间(自动检测平台) + * 检查磁盘空间 * @param path 要检查的路径(默认为临时目录) * @returns 磁盘空间信息,失败返回 null */ export async function checkDiskSpace(path: string = require('os').tmpdir()): Promise { - const platform = process.platform + try { + const space = await statfs(path, { bigint: true }) + const zero = BigInt(0) + const blockSize = space.bsize > zero ? space.bsize : zero + const totalBytes = space.blocks > zero ? space.blocks * blockSize : zero + const freeBytes = space.bfree > zero ? space.bfree * blockSize : zero + const availableBytes = space.bavail > zero ? space.bavail * blockSize : zero + const usedBytes = totalBytes > freeBytes ? totalBytes - freeBytes : zero + // 与 df 的 Use% 语义一致:非特权进程不能使用的保留块不进入分母。 + // 绝对空间门禁同样使用 bavail,避免把 root 保留空间误算给 nextjs。 + const usableBytes = usedBytes + availableBytes + const usePercent = usableBytes > zero + ? Number((usedBytes * BigInt(100) + usableBytes - BigInt(1)) / usableBytes) + : 0 - if (platform === 'win32' && !process.env.WSL_DISTRO_NAME) { - // Windows 原生环境(非 WSL) - return checkDiskSpaceWindows(path) - } else { - // Linux、macOS、WSL - return checkDiskSpaceLinux(path) + return { + total: clampToSafeNumber(totalBytes), + used: clampToSafeNumber(usedBytes), + available: clampToSafeNumber(availableBytes), + usePercent: Math.min(100, Math.max(0, usePercent)), + // statfs 不返回挂载点;保留被检查路径用于日志与诊断。 + mountPoint: path, + } + } catch (error) { + console.error('[DiskSpace] Failed to check disk space:', error) + return null } }