feat(storage): complete phase 3 branding foundation
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { ensureDataLayout, getDataConfig } from "./lib/data-dir.mjs";
|
||||
@@ -18,11 +19,14 @@ const config = ensureDataLayout(getDataConfig(targetEnv));
|
||||
const backupDir = path.resolve(args.from);
|
||||
const backupDbPath = path.join(backupDir, "neta.db");
|
||||
const backupUploadsDir = path.join(backupDir, "uploads");
|
||||
const manifestPath = path.join(backupDir, "manifest.json");
|
||||
|
||||
if (!fs.existsSync(backupDbPath)) {
|
||||
throw new Error(`Backup database not found: ${backupDbPath}`);
|
||||
}
|
||||
|
||||
verifyManifest(backupDir, manifestPath);
|
||||
|
||||
if (fs.existsSync(config.databasePath) && !args.force) {
|
||||
throw new Error(`Target database exists: ${config.databasePath}. Pass --force to overwrite.`);
|
||||
}
|
||||
@@ -70,3 +74,64 @@ function copyDirectory(sourceDir, targetDir) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function verifyManifest(rootDir, manifestFile) {
|
||||
if (!fs.existsSync(manifestFile)) {
|
||||
throw new Error(`Backup manifest not found: ${manifestFile}`);
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
|
||||
if (!Array.isArray(manifest.files) || manifest.files.length === 0) {
|
||||
throw new Error("Backup manifest has no file entries.");
|
||||
}
|
||||
|
||||
const normalizedRoot = path.resolve(rootDir);
|
||||
const verifiedPaths = new Set();
|
||||
for (const entry of manifest.files) {
|
||||
if (
|
||||
!entry ||
|
||||
typeof entry.path !== "string" ||
|
||||
typeof entry.bytes !== "number" ||
|
||||
typeof entry.sha256 !== "string" ||
|
||||
!/^[0-9a-f]{64}$/i.test(entry.sha256)
|
||||
) {
|
||||
throw new Error("Backup manifest contains an invalid file entry.");
|
||||
}
|
||||
const filePath = path.resolve(normalizedRoot, entry.path);
|
||||
if (!filePath.startsWith(`${normalizedRoot}${path.sep}`)) {
|
||||
throw new Error(`Backup manifest path escapes backup root: ${entry.path}`);
|
||||
}
|
||||
const stat = fs.lstatSync(filePath);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size !== entry.bytes) {
|
||||
throw new Error(`Backup file metadata mismatch: ${entry.path}`);
|
||||
}
|
||||
const actualHash = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
|
||||
if (!crypto.timingSafeEqual(Buffer.from(actualHash, "hex"), Buffer.from(entry.sha256, "hex"))) {
|
||||
throw new Error(`Backup checksum mismatch: ${entry.path}`);
|
||||
}
|
||||
verifiedPaths.add(entry.path.replace(/\\/g, "/"));
|
||||
}
|
||||
|
||||
const actualPaths = collectBackupFiles(normalizedRoot, normalizedRoot);
|
||||
if (
|
||||
actualPaths.length !== verifiedPaths.size ||
|
||||
actualPaths.some((filePath) => !verifiedPaths.has(filePath))
|
||||
) {
|
||||
throw new Error("Backup contains files that are missing from the checksum manifest.");
|
||||
}
|
||||
}
|
||||
|
||||
function collectBackupFiles(rootDir, currentDir) {
|
||||
const files = [];
|
||||
for (const entry of fs.readdirSync(currentDir, { withFileTypes: true })) {
|
||||
const entryPath = path.join(currentDir, entry.name);
|
||||
if (entryPath === path.join(rootDir, "manifest.json")) continue;
|
||||
if (entry.isSymbolicLink()) throw new Error(`Backup contains a symbolic link: ${entry.name}`);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...collectBackupFiles(rootDir, entryPath));
|
||||
} else if (entry.isFile()) {
|
||||
files.push(path.relative(rootDir, entryPath).replace(/\\/g, "/"));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user