Limit extracted upgrade package size

This commit is contained in:
FengLee
2026-05-10 13:16:08 +08:00
parent 14b7b3afe6
commit d398ec967f
3 changed files with 51 additions and 5 deletions

View File

@@ -49,6 +49,9 @@ const COLD_ALLOWED_FILES = new Set([
const BLOCKED_TOP_LEVEL_NAMES = new Set(['.git', 'node_modules', '.next', 'dist', 'backups', 'local-storage', 'upgrade-state']);
const BLOCKED_ANYWHERE_NAMES = new Set(['.git', 'node_modules', '.next']);
const MAX_EXTRACTED_FILES = Number(process.env.UPGRADE_MAX_EXTRACTED_FILES || 5000);
const MAX_EXTRACTED_BYTES = Number(process.env.UPGRADE_MAX_EXTRACTED_BYTES || 500 * 1024 * 1024);
const MAX_EXTRACTED_FILE_BYTES = Number(process.env.UPGRADE_MAX_EXTRACTED_FILE_BYTES || 200 * 1024 * 1024);
const PAYLOAD_TOP_LEVEL_DIRECTORIES = new Set([
...HOT_ALLOWED_PREFIXES.map(prefix => prefix.replace(/\/$/, '')),
...COLD_ALLOWED_PREFIXES.map(prefix => prefix.replace(/\/$/, '')),
@@ -122,13 +125,15 @@ async function main() {
run('tar', [...tarReadArgs('extract', packagePath), '-C', extractDir], { cwd: projectRoot, label: '解压升级包' });
const payloadRoot = resolvePayloadRoot(extractDir);
const files = listFiles(payloadRoot);
const packageStats = collectPackageStats(payloadRoot);
const files = packageStats.files;
if (files.length === 0) {
throw new Error('升级包为空');
}
validatePackageSize(packageStats);
const validation = validateFiles(files, mode);
logStep('升级包内容', `校验通过,共 ${files.length} 个文件:${files.slice(0, 20).join('、')}${files.length > 20 ? `${files.length} 个文件` : ''}`);
logStep('升级包内容', `校验通过,共 ${files.length} 个文件,解压后 ${formatBytes(packageStats.totalBytes)}${files.slice(0, 20).join('、')}${files.length > 20 ? `${files.length} 个文件` : ''}`);
updateState({
step: 'validated',
progress: 14,
@@ -136,6 +141,9 @@ async function main() {
restartRequired: mode === 'cold' || validation.requiresRestart,
packageHash: sha256(packagePath),
changedFiles: files,
extractedFileCount: packageStats.files.length,
extractedBytes: packageStats.totalBytes,
largestFileBytes: packageStats.largestFileBytes,
dryRun,
});
@@ -404,10 +412,12 @@ function resolvePayloadRoot(root) {
return root;
}
function listFiles(root) {
function collectPackageStats(root) {
const files = [];
let totalBytes = 0;
let largestFileBytes = 0;
walk(root, '');
return files.sort();
return { files: files.sort(), totalBytes, largestFileBytes };
function walk(currentRoot, relativeRoot) {
for (const entry of fs.readdirSync(currentRoot, { withFileTypes: true })) {
@@ -417,6 +427,9 @@ function listFiles(root) {
if (entry.isDirectory()) {
walk(absolute, relative);
} else if (entry.isFile()) {
const stat = fs.statSync(absolute);
totalBytes += stat.size;
largestFileBytes = Math.max(largestFileBytes, stat.size);
files.push(relative);
} else {
throw new Error(`升级包包含不支持的文件类型: ${relative}`);
@@ -425,6 +438,18 @@ function listFiles(root) {
}
}
function validatePackageSize(stats) {
if (Number.isFinite(MAX_EXTRACTED_FILES) && stats.files.length > MAX_EXTRACTED_FILES) {
throw new Error(`升级包文件数量过多:${stats.files.length} 个,最多允许 ${MAX_EXTRACTED_FILES}`);
}
if (Number.isFinite(MAX_EXTRACTED_BYTES) && stats.totalBytes > MAX_EXTRACTED_BYTES) {
throw new Error(`升级包解压后过大:${formatBytes(stats.totalBytes)},最多允许 ${formatBytes(MAX_EXTRACTED_BYTES)}`);
}
if (Number.isFinite(MAX_EXTRACTED_FILE_BYTES) && stats.largestFileBytes > MAX_EXTRACTED_FILE_BYTES) {
throw new Error(`升级包包含过大的单个文件:${formatBytes(stats.largestFileBytes)},最多允许 ${formatBytes(MAX_EXTRACTED_FILE_BYTES)}`);
}
}
function validateFiles(files, updateMode) {
for (const file of files) {
assertSafeRelativePath(file);
@@ -586,6 +611,19 @@ function sha256(file) {
return hash.digest('hex');
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes)) return 'unknown';
if (bytes < 1024) return `${bytes} B`;
const units = ['KB', 'MB', 'GB'];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
}
function toPosix(file) {
return file.split(path.sep).join('/');
}

View File

@@ -33,6 +33,9 @@ type UpgradeJobState = {
sourceBackupHash?: string;
restartRequired?: boolean;
changedFiles?: string[];
extractedFileCount?: number;
extractedBytes?: number;
largestFileBytes?: number;
preExistingFiles?: string[];
error?: string;
startedAt: string;

View File

@@ -54,6 +54,9 @@ type UpgradeJob = {
sourceBackupHash?: string;
restartRequired?: boolean;
changedFiles?: string[];
extractedFileCount?: number;
extractedBytes?: number;
largestFileBytes?: number;
preExistingFiles?: string[];
error?: string;
startedAt: string;
@@ -519,7 +522,9 @@ function UpgradeStatusPanel({
<InfoRow label="任务 ID" value={job.id} />
<InfoRow label="升级包" value={job.packageName} />
<InfoRow label="当前步骤" value={job.step} />
<InfoRow label="文件数量" value={`${changedFiles.length} 个文件`} />
<InfoRow label="文件数量" value={`${job.extractedFileCount ?? changedFiles.length} 个文件`} />
{typeof job.extractedBytes === 'number' && <InfoRow label="解压大小" value={formatBytes(job.extractedBytes)} />}
{typeof job.largestFileBytes === 'number' && <InfoRow label="最大文件" value={formatBytes(job.largestFileBytes)} />}
<InfoRow label="需要重启" value={job.restartRequired ? '是' : '否'} />
{job.staleAt && <InfoRow label="超时标记" value={formatDate(job.staleAt)} />}
{job.backupFile && <InfoRow label="数据备份" value={job.backupFile} />}