补齐逐文件备份符号链接恢复
记录data-dir内部相对符号链接并拒绝绝对或越界目标 恢复full catalog时安全重建符号链接 补齐备份测试与生产运维文档
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {createHash} from 'node:crypto';
|
||||
import {chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync} from 'node:fs';
|
||||
import {chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
@@ -106,6 +106,8 @@ async function assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload(
|
||||
const workDir = path.join(root, 'work');
|
||||
mkdirSync(path.join(dataDir, 'replicas', '1', 'snapshots', '00000000000000000010.snapshot_dir', 'objects'), {recursive: true});
|
||||
mkdirSync(path.join(dataDir, 'empty-directory'), {recursive: true});
|
||||
mkdirSync(path.join(dataDir, 'bin', '2.6.0'), {recursive: true});
|
||||
symlinkSync('2.6.0', path.join(dataDir, 'bin', 'current'));
|
||||
writeFileSync(path.join(dataDir, 'control-db'), 'control');
|
||||
writeFileSync(
|
||||
path.join(dataDir, 'replicas', '1', 'snapshots', '00000000000000000010.snapshot_dir', 'objects', 'object.bin'),
|
||||
@@ -122,6 +124,10 @@ async function assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload(
|
||||
'files catalog 必须原样保留 snapshot 内文件的相对路径。',
|
||||
);
|
||||
assertTrue(collected.directories.includes('empty-directory'), 'files catalog 必须保留空目录。');
|
||||
assertTrue(
|
||||
collected.symlinks.some(({path: symlinkPath, target}) => symlinkPath === 'bin/current' && target === '2.6.0'),
|
||||
'files catalog 必须保留指向 data-dir 内部的相对符号链接。',
|
||||
);
|
||||
|
||||
const first = await runDirectFilesBackup(options);
|
||||
assertEqual(first.uploadedCount, 2, '首次 files full 应上传全部普通文件。');
|
||||
@@ -258,6 +264,8 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() {
|
||||
const restoreDir = path.join(root, 'restore');
|
||||
mkdirSync(path.join(dataDir, 'empty-directory'), {recursive: true});
|
||||
mkdirSync(path.join(dataDir, 'config'), {recursive: true});
|
||||
mkdirSync(path.join(dataDir, 'bin', '2.6.0'), {recursive: true});
|
||||
symlinkSync('2.6.0', path.join(dataDir, 'bin', 'current'));
|
||||
const keyPath = path.join(dataDir, 'config', 'id_ecdsa');
|
||||
writeFileSync(keyPath, 'private key fixture');
|
||||
chmodSync(keyPath, 0o640);
|
||||
@@ -291,6 +299,8 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() {
|
||||
assertEqual(readFileSync(path.join(restoreDir, 'config', 'id_ecdsa'), 'utf8'), 'updated private key fixture', 'files restore 必须按最新 full catalog 的原相对路径恢复内容。');
|
||||
assertTrue(existsSync(path.join(restoreDir, 'empty-directory')), 'files restore 必须重建空目录。');
|
||||
assertEqual(statSync(path.join(restoreDir, 'config', 'id_ecdsa')).mode & 0o7777, 0o640, 'files restore 必须恢复文件权限。');
|
||||
assertTrue(lstatSync(path.join(restoreDir, 'bin', 'current')).isSymbolicLink(), 'files restore 必须重建符号链接。');
|
||||
assertEqual(readlinkSync(path.join(restoreDir, 'bin', 'current'), 'utf8'), '2.6.0', 'files restore 必须保留符号链接目标。');
|
||||
|
||||
rmSync(restoreDir, {recursive: true, force: true});
|
||||
const downloadBufferFn = async ({objectKey}) => {
|
||||
@@ -322,6 +332,7 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() {
|
||||
});
|
||||
assertEqual(dryRun.catalogId, latestFull.catalogId, 'OSS-only dry-run 必须选择 latestFullCatalog。');
|
||||
assertEqual(dryRun.fileCount, 1, 'OSS-only dry-run 应返回 full catalog 文件数。');
|
||||
assertEqual(dryRun.symlinkCount, 1, 'OSS-only dry-run 应返回 full catalog 符号链接数。');
|
||||
assertEqual(dryRun.totalSizeBytes, String(Buffer.byteLength('updated private key fixture')), 'OSS-only dry-run 应返回总字节数。');
|
||||
assertEqual(objectDownloadCount, 0, 'OSS-only dry-run 不得下载数据对象。');
|
||||
assertTrue(!existsSync(restoreDir), 'OSS-only dry-run 不得创建恢复目录。');
|
||||
@@ -339,6 +350,7 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() {
|
||||
assertEqual(latestRestored.catalogId, latestFull.catalogId, 'OSS-only restore 必须选择 latestFullCatalog。');
|
||||
assertEqual(latestRestored.downloadedCount, 1, 'OSS-only restore 应下载 latest full catalog 的数据对象。');
|
||||
assertEqual(readFileSync(path.join(restoreDir, 'config', 'id_ecdsa'), 'utf8'), 'updated private key fixture', 'OSS-only restore 应还原最新 full 内容。');
|
||||
assertEqual(readlinkSync(path.join(restoreDir, 'bin', 'current'), 'utf8'), '2.6.0', 'OSS-only restore 应还原符号链接。');
|
||||
}
|
||||
|
||||
function assertCanonicalQueryAndAuthorizationIncludeMultipartParameters() {
|
||||
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
openSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
renameSync,
|
||||
rmSync,
|
||||
statfsSync,
|
||||
statSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import {basename, dirname, isAbsolute, join, relative, resolve, sep} from 'node:path';
|
||||
@@ -1058,6 +1060,7 @@ export async function collectDirectFileEntries({dataDir, candidates = null, obje
|
||||
throw new Error(`files 数据目录不存在或不是目录: ${resolvedDataDir}`);
|
||||
}
|
||||
const files = new Map();
|
||||
const symlinks = new Map();
|
||||
const directories = new Set(['.']);
|
||||
const roots = candidates === null
|
||||
? [{absolutePath: resolvedDataDir, relativePath: '.'}]
|
||||
@@ -1069,7 +1072,13 @@ export async function collectDirectFileEntries({dataDir, candidates = null, obje
|
||||
const visit = async (absolutePath, relativePath) => {
|
||||
const stat = lstatSync(absolutePath);
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new Error(`files 模式拒绝符号链接: ${absolutePath}`);
|
||||
const target = readlinkSync(absolutePath, 'utf8');
|
||||
if (!target || isAbsolute(target)) {
|
||||
throw new Error(`files 模式只允许 data-dir 内部的相对符号链接: ${absolutePath} -> ${target}`);
|
||||
}
|
||||
assertSafeRelativePath(resolvedDataDir, resolve(dirname(absolutePath), target));
|
||||
symlinks.set(relativePath, {path: relativePath, target});
|
||||
return;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
directories.add(relativePath);
|
||||
@@ -1108,16 +1117,18 @@ export async function collectDirectFileEntries({dataDir, candidates = null, obje
|
||||
return {
|
||||
directories: [...directories].sort(),
|
||||
files: [...files.values()].sort((left, right) => left.path.localeCompare(right.path)),
|
||||
symlinks: [...symlinks.values()].sort((left, right) => left.path.localeCompare(right.path)),
|
||||
};
|
||||
}
|
||||
|
||||
function directCatalogIdentity({mode, baselineCatalogId, rootName, directories, files}) {
|
||||
function directCatalogIdentity({mode, baselineCatalogId, rootName, directories, files, symlinks}) {
|
||||
return sha256Hex(JSON.stringify({
|
||||
mode,
|
||||
baselineCatalogId: baselineCatalogId || '',
|
||||
rootName,
|
||||
directories,
|
||||
files: files.map(({path, sizeBytes, sha256, mode, objectKey}) => ({path, sizeBytes, sha256, mode, objectKey})),
|
||||
symlinks,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1362,6 +1373,7 @@ export async function runDirectFilesBackup({
|
||||
rootName,
|
||||
directories: collected.directories,
|
||||
files: collected.files.map(({sourceStat: _sourceStat, ...file}) => file),
|
||||
symlinks: collected.symlinks,
|
||||
};
|
||||
writeManifest({manifestPath: catalogPath, payload: catalog});
|
||||
const summary = {
|
||||
@@ -1370,13 +1382,14 @@ export async function runDirectFilesBackup({
|
||||
catalogObjectKey,
|
||||
catalogId,
|
||||
fileCount: collected.files.length,
|
||||
symlinkCount: collected.symlinks.length,
|
||||
totalSizeBytes: collected.files.reduce((sum, file) => sum + BigInt(file.sizeBytes), 0n).toString(),
|
||||
candidateCount: plan?.candidates.length ?? 0,
|
||||
};
|
||||
if (resultFile) {
|
||||
atomicWriteJson(resolvePath(resultFile), {...summary, dryRun});
|
||||
}
|
||||
console.log(`[database-backup] files ${mode}: files=${summary.fileCount}, size=${formatBytes(summary.totalSizeBytes)}, catalog=${catalogId}`);
|
||||
console.log(`[database-backup] files ${mode}: files=${summary.fileCount}, symlinks=${summary.symlinkCount}, size=${formatBytes(summary.totalSizeBytes)}, catalog=${catalogId}`);
|
||||
if (dryRun) {
|
||||
console.log('[database-backup] files dry-run,仅扫描并生成本地 catalog,不上传或删除。');
|
||||
return {...summary, catalog, uploadedCount: 0, reusedCount: 0};
|
||||
@@ -1478,6 +1491,7 @@ export async function runDirectFilesBackup({
|
||||
sha256: catalogUpload.archiveSha256,
|
||||
verifiedAt: catalogUpload.verifiedAt,
|
||||
files: catalog.files,
|
||||
symlinks: catalog.symlinks,
|
||||
};
|
||||
const nextState = {
|
||||
schemaVersion: DIRECT_FILES_STATE_SCHEMA_VERSION,
|
||||
@@ -1563,7 +1577,7 @@ async function loadDirectFilesCatalog({catalogRef, database, bucket, uploadOptio
|
||||
) {
|
||||
throw new Error(`files restore catalog 契约无效: ${catalogRef.objectKey}`);
|
||||
}
|
||||
return catalog;
|
||||
return {...catalog, symlinks: catalog.symlinks ?? []};
|
||||
}
|
||||
|
||||
function assertDirectCatalogFile(file, index) {
|
||||
@@ -1581,6 +1595,22 @@ function assertDirectCatalogFile(file, index) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertDirectCatalogSymlink(symlink, index, restoreDir) {
|
||||
if (
|
||||
!symlink
|
||||
|| typeof symlink.path !== 'string'
|
||||
|| !symlink.path
|
||||
|| typeof symlink.target !== 'string'
|
||||
|| !symlink.target
|
||||
|| isAbsolute(symlink.target)
|
||||
) {
|
||||
throw new Error(`files restore catalog 符号链接项无效: index=${index}`);
|
||||
}
|
||||
const destinationPath = resolve(restoreDir, symlink.path);
|
||||
assertSafeRelativePath(restoreDir, destinationPath);
|
||||
assertSafeRelativePath(restoreDir, resolve(dirname(destinationPath), symlink.target));
|
||||
}
|
||||
|
||||
async function restoreDirectFilesCatalog({
|
||||
catalog,
|
||||
restoreDir,
|
||||
@@ -1591,12 +1621,14 @@ async function restoreDirectFilesCatalog({
|
||||
}) {
|
||||
const resolvedRestoreDir = resolvePath(restoreDir);
|
||||
catalog.files.forEach(assertDirectCatalogFile);
|
||||
catalog.symlinks.forEach((symlink, index) => assertDirectCatalogSymlink(symlink, index, resolvedRestoreDir));
|
||||
const totalSizeBytes = catalog.files.reduce((sum, file) => sum + BigInt(file.sizeBytes), 0n).toString();
|
||||
if (dryRun) {
|
||||
const result = {
|
||||
restoreDir: resolvedRestoreDir,
|
||||
catalogId: catalog.catalogId,
|
||||
fileCount: catalog.files.length,
|
||||
symlinkCount: catalog.symlinks.length,
|
||||
totalSizeBytes,
|
||||
downloadedCount: 0,
|
||||
reusedCount: 0,
|
||||
@@ -1644,10 +1676,19 @@ async function restoreDirectFilesCatalog({
|
||||
chmodSync(destinationPath, file.mode & 0o7777);
|
||||
console.log(`[database-backup] files restore: ${index + 1}/${catalog.files.length} (${reusable ? 'reused' : 'downloaded'}) ${file.path}`);
|
||||
}
|
||||
for (const symlink of catalog.symlinks) {
|
||||
const destinationPath = resolve(resolvedRestoreDir, symlink.path);
|
||||
assertSafeRelativePath(resolvedRestoreDir, destinationPath);
|
||||
mkdirSync(dirname(destinationPath), {recursive: true});
|
||||
rmSync(destinationPath, {recursive: true, force: true});
|
||||
symlinkSync(symlink.target, destinationPath);
|
||||
console.log(`[database-backup] files restore: symlink ${symlink.path} -> ${symlink.target}`);
|
||||
}
|
||||
const result = {
|
||||
restoreDir: resolvedRestoreDir,
|
||||
catalogId: catalog.catalogId,
|
||||
fileCount: catalog.files.length,
|
||||
symlinkCount: catalog.symlinks.length,
|
||||
totalSizeBytes,
|
||||
downloadedCount,
|
||||
reusedCount,
|
||||
|
||||
Reference in New Issue
Block a user