feat(storage): complete phase 3 branding foundation
This commit is contained in:
@@ -6,6 +6,8 @@ import net from "node:net";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const PNG_BYTES = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);
|
||||
|
||||
const dataDir = path.join(process.cwd(), ".data", `phase1-auth-smoke-${Date.now()}`);
|
||||
const databasePath = path.join(dataDir, "neta.db");
|
||||
const port = await getAvailablePort();
|
||||
@@ -91,6 +93,9 @@ try {
|
||||
]) {
|
||||
insertClient.run(clientId, ownerUserId, name);
|
||||
}
|
||||
db.prepare(
|
||||
"insert into projects (id, owner_user_id, client_id, name, status) values (?, ?, ?, ?, ?)",
|
||||
).run("project-alpha", ownerUserId, "client-alpha", "Alpha Project", "active");
|
||||
|
||||
const rejectedRegistration = await authPost("/api/auth/sign-up/email", {
|
||||
name: "Public Attacker",
|
||||
@@ -111,6 +116,60 @@ try {
|
||||
}
|
||||
assert.ok(ownerCookie, "Owner session cookie must be issued");
|
||||
|
||||
const anonymousUpload = await uploadFile("avatar", { fileName: "anonymous.png" });
|
||||
assert.equal(anonymousUpload.response.status, 401, "Anonymous file upload must fail");
|
||||
assert.deepEqual(
|
||||
{ ok: anonymousUpload.payload.ok, code: anonymousUpload.payload.error.code },
|
||||
{ ok: false, code: "UNAUTHENTICATED" },
|
||||
"File API errors must use the standard envelope",
|
||||
);
|
||||
|
||||
const logoUpload = await uploadFile("branding_logo", {
|
||||
cookie: ownerCookie,
|
||||
fileName: "logo.png",
|
||||
});
|
||||
assert.equal(logoUpload.response.status, 201, JSON.stringify(logoUpload.payload));
|
||||
assert.equal(logoUpload.payload.ok, true, "File API success must use the standard envelope");
|
||||
const logoFileId = logoUpload.payload.data.id;
|
||||
const brandingUpdate = await jsonRequest("/api/branding", {
|
||||
method: "PATCH",
|
||||
cookie: ownerCookie,
|
||||
body: {
|
||||
applicationName: "Neta Smoke Studio",
|
||||
primaryColor: "#336699",
|
||||
accentColor: "#F0CC22",
|
||||
lightLogoFileId: logoFileId,
|
||||
},
|
||||
});
|
||||
assert.equal(brandingUpdate.response.ok, true, JSON.stringify(brandingUpdate.payload));
|
||||
assert.equal(brandingUpdate.payload.data.applicationName, "Neta Smoke Studio");
|
||||
const brandedLoginHtml = await (await fetch(`${baseUrl}/login`)).text();
|
||||
assert.match(brandedLoginHtml, /Neta Smoke Studio/, "Branding metadata must be server-rendered");
|
||||
assert.match(brandedLoginHtml, /--primary:#336699/, "Brand tokens must be present in first HTML response");
|
||||
const dynamicManifest = await (await fetch(`${baseUrl}/manifest.webmanifest`)).json();
|
||||
assert.equal(dynamicManifest.name, "Neta Smoke Studio", "Manifest must use instance branding");
|
||||
const publicLogo = await fetch(`${baseUrl}/api/branding/assets/${logoFileId}`);
|
||||
assert.equal(publicLogo.status, 200, "Referenced branding asset must be publicly readable");
|
||||
assert.equal(publicLogo.headers.get("x-content-type-options"), "nosniff");
|
||||
assert.deepEqual(new Uint8Array(await publicLogo.arrayBuffer()), PNG_BYTES);
|
||||
|
||||
const portalAssetUpload = await uploadFile("project_asset", {
|
||||
cookie: ownerCookie,
|
||||
fileName: "portal.png",
|
||||
projectId: "project-alpha",
|
||||
portalVisible: true,
|
||||
});
|
||||
assert.equal(portalAssetUpload.response.status, 201, JSON.stringify(portalAssetUpload.payload));
|
||||
const portalAssetFileId = portalAssetUpload.payload.data.id;
|
||||
const privateAssetUpload = await uploadFile("project_asset", {
|
||||
cookie: ownerCookie,
|
||||
fileName: "private.png",
|
||||
projectId: "project-alpha",
|
||||
portalVisible: false,
|
||||
});
|
||||
assert.equal(privateAssetUpload.response.status, 201, JSON.stringify(privateAssetUpload.payload));
|
||||
const privateAssetFileId = privateAssetUpload.payload.data.id;
|
||||
|
||||
const anonymousInvite = await jsonRequest("/api/portal-invitations", {
|
||||
method: "POST",
|
||||
body: { clientId: "anonymous-client", email: "anonymous@example.com" },
|
||||
@@ -193,6 +252,39 @@ try {
|
||||
assert.equal(clientSignIn.response.ok, true, JSON.stringify(clientSignIn.payload));
|
||||
const clientCookie = cookieHeader(clientSignIn.response);
|
||||
|
||||
const clientPortalAsset = await fetch(`${baseUrl}/api/files/${portalAssetFileId}`, {
|
||||
headers: { cookie: clientCookie },
|
||||
});
|
||||
assert.equal(clientPortalAsset.status, 200, "Client must read portal-visible project asset");
|
||||
const clientPrivateAsset = await fetch(`${baseUrl}/api/files/${privateAssetFileId}`, {
|
||||
headers: { cookie: clientCookie },
|
||||
});
|
||||
assert.equal(clientPrivateAsset.status, 404, "Client must not read private project asset");
|
||||
|
||||
const forbiddenProjectUpload = await uploadFile("project_asset", {
|
||||
cookie: clientCookie,
|
||||
fileName: "forbidden.png",
|
||||
projectId: "project-alpha",
|
||||
portalVisible: true,
|
||||
});
|
||||
assert.equal(forbiddenProjectUpload.response.status, 403, "Client must not upload project assets");
|
||||
|
||||
const clientAvatarUpload = await uploadFile("avatar", {
|
||||
cookie: clientCookie,
|
||||
fileName: "client-avatar.png",
|
||||
});
|
||||
assert.equal(clientAvatarUpload.response.status, 201, JSON.stringify(clientAvatarUpload.payload));
|
||||
const clientAvatarFileId = clientAvatarUpload.payload.data.id;
|
||||
const clientAvatar = await fetch(`${baseUrl}/api/files/${clientAvatarFileId}`, {
|
||||
headers: { cookie: clientCookie },
|
||||
});
|
||||
assert.equal(clientAvatar.status, 200, "Client must read own avatar");
|
||||
const deletedAvatar = await fetch(`${baseUrl}/api/files/${clientAvatarFileId}`, {
|
||||
method: "DELETE",
|
||||
headers: { cookie: clientCookie, origin: baseUrl },
|
||||
});
|
||||
assert.equal(deletedAvatar.status, 204, "Client must delete own avatar");
|
||||
|
||||
const roleViolation = await jsonRequest("/api/portal-invitations", {
|
||||
method: "POST",
|
||||
cookie: clientCookie,
|
||||
@@ -318,6 +410,19 @@ async function authPost(pathname, body, cookie) {
|
||||
return jsonRequest(pathname, { method: "POST", body, cookie });
|
||||
}
|
||||
|
||||
async function uploadFile(kind, { cookie, fileName, projectId, portalVisible } = {}) {
|
||||
const formData = new FormData();
|
||||
formData.set("kind", kind);
|
||||
formData.set("file", new Blob([PNG_BYTES], { type: "image/png" }), fileName ?? "upload.png");
|
||||
if (projectId) formData.set("projectId", projectId);
|
||||
if (portalVisible !== undefined) formData.set("portalVisible", String(portalVisible));
|
||||
const headers = { origin: baseUrl };
|
||||
if (cookie) headers.cookie = cookie;
|
||||
const response = await fetch(`${baseUrl}/api/files`, { method: "POST", headers, body: formData });
|
||||
const text = await response.text();
|
||||
return { response, payload: text ? JSON.parse(text) : null };
|
||||
}
|
||||
|
||||
async function jsonRequest(pathname, { method, body, cookie } = {}) {
|
||||
const headers = { origin: baseUrl };
|
||||
if (body !== undefined) headers["content-type"] = "application/json";
|
||||
|
||||
@@ -37,6 +37,10 @@ try {
|
||||
sqlite.close();
|
||||
}
|
||||
|
||||
const uploadFixturePath = path.join(smokeRoot, "uploads", "project-assets", "backup-fixture.txt");
|
||||
fs.mkdirSync(path.dirname(uploadFixturePath), { recursive: true });
|
||||
fs.writeFileSync(uploadFixturePath, "neta-upload-backup-fixture");
|
||||
|
||||
const reopened = new Database(dbPath, { readonly: true });
|
||||
|
||||
try {
|
||||
@@ -83,4 +87,29 @@ try {
|
||||
restored.close();
|
||||
}
|
||||
|
||||
const restoredUploadFixture = path.join(
|
||||
restoreRoot,
|
||||
"uploads",
|
||||
"project-assets",
|
||||
"backup-fixture.txt",
|
||||
);
|
||||
if (fs.readFileSync(restoredUploadFixture, "utf8") !== "neta-upload-backup-fixture") {
|
||||
throw new Error("Restore smoke check failed: upload fixture missing or corrupted.");
|
||||
}
|
||||
|
||||
fs.appendFileSync(path.join(backupDir, "uploads", "project-assets", "backup-fixture.txt"), "-tampered");
|
||||
let corruptedBackupRejected = false;
|
||||
try {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
["scripts/restore.mjs", "--from", backupDir, "--target", `${restoreRoot}-corrupt`, "--force"],
|
||||
{ cwd: process.cwd(), env: process.env, stdio: "pipe" },
|
||||
);
|
||||
} catch {
|
||||
corruptedBackupRejected = true;
|
||||
}
|
||||
if (!corruptedBackupRejected) {
|
||||
throw new Error("Restore smoke check failed: corrupted upload checksum was accepted.");
|
||||
}
|
||||
|
||||
console.log("Phase 1 smoke checks passed.");
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const dataDir = path.join(process.cwd(), ".data", `phase3-storage-smoke-${Date.now()}`);
|
||||
const databasePath = path.join(dataDir, "neta.db");
|
||||
const env = { ...process.env, DATA_DIR: dataDir, DATABASE_PATH: databasePath };
|
||||
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
execFileSync(process.execPath, ["scripts/migrate.mjs"], { cwd: process.cwd(), env, stdio: "inherit" });
|
||||
execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.phase3-smoke.json"], {
|
||||
cwd: process.cwd(),
|
||||
stdio: "inherit",
|
||||
});
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[path.join(".next", "phase3-storage-smoke-dist", "scripts", "phase3-storage-smoke.js"), dataDir],
|
||||
{ cwd: process.cwd(), stdio: "inherit" },
|
||||
);
|
||||
@@ -0,0 +1,173 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import * as schema from "../server/db/schema";
|
||||
import { BrandingService, contrastRatio } from "../server/branding/service";
|
||||
import type { DomainActor } from "../server/domain/actor";
|
||||
import { DomainError } from "../server/domain/errors";
|
||||
import { resolveStoragePath } from "../server/files/paths";
|
||||
import { MAX_UPLOAD_BYTES } from "../server/files/policy";
|
||||
import { FileService } from "../server/files/service";
|
||||
import { DomainService } from "../server/services/domain";
|
||||
|
||||
const dataDir = process.argv[2];
|
||||
assert.ok(dataDir, "Data directory is required");
|
||||
const databasePath = path.join(dataDir, "neta.db");
|
||||
const uploadsDir = path.join(dataDir, "uploads");
|
||||
const tmpDir = path.join(dataDir, "tmp");
|
||||
const sqlite = new Database(databasePath);
|
||||
sqlite.pragma("foreign_keys = ON");
|
||||
const db = drizzle({ client: sqlite, schema });
|
||||
let generatedId = 0;
|
||||
const fileService = new FileService(db, { uploadsDir, tmpDir }, () => `file-${++generatedId}`);
|
||||
const brandingService = new BrandingService(db);
|
||||
const domainService = new DomainService(db, () => `domain-${++generatedId}`);
|
||||
|
||||
const ownerOne: DomainActor = { authUserId: "owner-1", role: "freelancer", clientId: null, disabled: false };
|
||||
const ownerTwo: DomainActor = { authUserId: "owner-2", role: "freelancer", clientId: null, disabled: false };
|
||||
const clientOne: DomainActor = { authUserId: "client-user-1", role: "client", clientId: "client-1", disabled: false };
|
||||
const clientTwo: DomainActor = { authUserId: "client-user-2", role: "client", clientId: "client-2", disabled: false };
|
||||
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]);
|
||||
|
||||
try {
|
||||
for (const actor of [ownerOne, ownerTwo, clientOne, clientTwo]) {
|
||||
db.insert(schema.user).values({
|
||||
id: actor.authUserId,
|
||||
name: actor.authUserId,
|
||||
email: `${actor.authUserId}@example.com`,
|
||||
emailVerified: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}).run();
|
||||
}
|
||||
domainService.createClient(ownerOne, { id: "client-1", name: "Client One" });
|
||||
domainService.createClient(ownerOne, { id: "client-2", name: "Client Two" });
|
||||
domainService.createClient(ownerTwo, { id: "client-other", name: "Other Client" });
|
||||
db.update(schema.clients).set({ authUserId: clientOne.authUserId }).where(eq(schema.clients.id, "client-1")).run();
|
||||
db.update(schema.clients).set({ authUserId: clientTwo.authUserId }).where(eq(schema.clients.id, "client-2")).run();
|
||||
domainService.createProject(ownerOne, { id: "project-1", name: "Portal Project", clientId: "client-1", status: "active" });
|
||||
domainService.createProject(ownerOne, { id: "project-2", name: "Private Project", clientId: "client-2", status: "active" });
|
||||
domainService.createProject(ownerTwo, { id: "project-other", name: "Other Project", clientId: "client-other", status: "active" });
|
||||
|
||||
const ownerAvatar = fileService.upload(ownerOne, imageInput("avatar", "../owner avatar.png"));
|
||||
const clientAvatar = fileService.upload(clientOne, imageInput("avatar", "client.png"));
|
||||
assert.equal(ownerAvatar.storagePath, `avatars/${ownerAvatar.id}.png`);
|
||||
assert.equal(ownerAvatar.originalName, "..-owner avatar.png");
|
||||
assert.equal(db.select({ image: schema.user.image }).from(schema.user).where(eq(schema.user.id, ownerOne.authUserId)).get()?.image, `/api/files/${ownerAvatar.id}`);
|
||||
assert.deepEqual(fileService.read(clientOne, clientAvatar.id).bytes, Buffer.from(png));
|
||||
const collisionService = new FileService(db, { uploadsDir, tmpDir }, () => ownerAvatar.id);
|
||||
assert.throws(
|
||||
() => collisionService.upload(ownerOne, imageInput("avatar", "collision.png")),
|
||||
/EEXIST/,
|
||||
"A generated path collision must never overwrite the existing file",
|
||||
);
|
||||
assert.deepEqual(fileService.read(ownerOne, ownerAvatar.id).bytes, Buffer.from(png));
|
||||
assertDomainError(() => fileService.read(clientOne, ownerAvatar.id), "NOT_FOUND");
|
||||
assertDomainError(() => fileService.read(ownerTwo, ownerAvatar.id), "NOT_FOUND");
|
||||
|
||||
const logo = fileService.upload(ownerOne, imageInput("branding_logo", "logo.png"));
|
||||
const icon = fileService.upload(ownerOne, imageInput("branding_icon", "icon.png"));
|
||||
assertDomainError(() => fileService.readPublicBranding(logo.id), "NOT_FOUND");
|
||||
const branding = brandingService.update(ownerOne, {
|
||||
applicationName: "Studio Portal",
|
||||
shortName: "Studio",
|
||||
primaryColor: "#336699",
|
||||
accentColor: "#f0cc22",
|
||||
lightLogoFileId: logo.id,
|
||||
iconFileId: icon.id,
|
||||
defaultColorMode: "dark",
|
||||
radiusScale: "soft",
|
||||
});
|
||||
assert.equal(branding.applicationName, "Studio Portal");
|
||||
assert.equal(branding.primaryColor, "#336699");
|
||||
assert.equal(branding.darkLogoUrl, branding.lightLogoUrl, "Missing dark logo must fall back to light logo");
|
||||
assert.equal(fileService.readPublicBranding(logo.id).metadata.id, logo.id);
|
||||
assert.ok(contrastRatio(branding.primaryColor, branding.cssVariables["--primary-foreground"]) >= 4.5);
|
||||
assert.ok(contrastRatio(branding.accentColor, branding.cssVariables["--accent-foreground"]) >= 4.5);
|
||||
assertDomainError(() => brandingService.update(clientOne, { applicationName: "Attack" }), "FORBIDDEN");
|
||||
assertDomainError(() => brandingService.update(ownerTwo, { applicationName: "Attack" }), "FORBIDDEN");
|
||||
assertDomainError(() => brandingService.update(ownerOne, { primaryColor: "red" }), "VALIDATION_ERROR");
|
||||
|
||||
const portalAsset = fileService.upload(ownerOne, {
|
||||
...imageInput("project_asset", "cover.png"),
|
||||
projectId: "project-1",
|
||||
portalVisible: true,
|
||||
});
|
||||
const privateAsset = fileService.upload(ownerOne, {
|
||||
...imageInput("project_asset", "private.png"),
|
||||
projectId: "project-1",
|
||||
portalVisible: false,
|
||||
});
|
||||
assert.equal(fileService.read(clientOne, portalAsset.id).metadata.id, portalAsset.id);
|
||||
assertDomainError(() => fileService.read(clientOne, privateAsset.id), "NOT_FOUND");
|
||||
assertDomainError(() => fileService.read(clientTwo, portalAsset.id), "NOT_FOUND");
|
||||
assertDomainError(
|
||||
() => fileService.upload(clientOne, { ...imageInput("project_asset", "attack.png"), projectId: "project-1" }),
|
||||
"FORBIDDEN",
|
||||
);
|
||||
assertDomainError(
|
||||
() => fileService.upload(ownerOne, { ...imageInput("project_asset", "foreign.png"), projectId: "project-other" }),
|
||||
"NOT_FOUND",
|
||||
);
|
||||
|
||||
assertDomainError(() => fileService.upload(ownerOne, { ...imageInput("avatar", "fake.png"), claimedMimeType: "image/jpeg" }), "VALIDATION_ERROR");
|
||||
assertDomainError(() => fileService.upload(ownerOne, { ...imageInput("avatar", "fake.svg"), claimedMimeType: "image/svg+xml" }), "VALIDATION_ERROR");
|
||||
assertDomainError(() => fileService.upload(ownerOne, { ...imageInput("avatar", "large.png"), bytes: new Uint8Array(MAX_UPLOAD_BYTES + 1) }), "VALIDATION_ERROR");
|
||||
assertDomainError(() => fileService.upload(ownerOne, { ...imageInput("avatar", "bad.png"), bytes: Uint8Array.from([1, 2, 3]) }), "VALIDATION_ERROR");
|
||||
|
||||
for (const candidate of ["../secret", "/etc/passwd", "project-assets/../../secret", "project-assets\\secret"] ) {
|
||||
assertDomainError(() => resolveStoragePath(uploadsDir, candidate), "VALIDATION_ERROR");
|
||||
}
|
||||
|
||||
const outsidePath = path.join(dataDir, "outside.png");
|
||||
fs.writeFileSync(outsidePath, png);
|
||||
const symlinkPath = path.join(uploadsDir, "project-assets", "symlink.png");
|
||||
fs.symlinkSync(outsidePath, symlinkPath);
|
||||
db.insert(schema.files).values({
|
||||
id: "symlink-file",
|
||||
ownerUserId: ownerOne.authUserId,
|
||||
uploadedByUserId: ownerOne.authUserId,
|
||||
projectId: "project-1",
|
||||
kind: "project_asset",
|
||||
visibility: "private",
|
||||
storagePath: "project-assets/symlink.png",
|
||||
originalName: "symlink.png",
|
||||
mimeType: "image/png",
|
||||
byteSize: png.byteLength,
|
||||
sha256: "0".repeat(64),
|
||||
}).run();
|
||||
assertDomainError(() => fileService.read(ownerOne, "symlink-file"), "NOT_FOUND");
|
||||
fileService.delete(ownerOne, "symlink-file");
|
||||
assert.equal(fs.readFileSync(outsidePath).byteLength, png.byteLength, "Deleting symlink metadata must not delete target");
|
||||
|
||||
assert.throws(
|
||||
() => sqlite.prepare("insert into files (id, owner_user_id, uploaded_by_user_id, project_id, kind, visibility, storage_path, original_name, mime_type, byte_size, sha256) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run("invalid-path", ownerOne.authUserId, ownerOne.authUserId, "project-1", "project_asset", "private", "../escape.png", "escape.png", "image/png", 1, "0".repeat(64)),
|
||||
/CHECK constraint failed/,
|
||||
);
|
||||
|
||||
const avatarPath = resolveStoragePath(uploadsDir, ownerAvatar.storagePath);
|
||||
assert.ok(fs.existsSync(avatarPath));
|
||||
fileService.delete(ownerOne, ownerAvatar.id);
|
||||
assert.equal(fs.existsSync(avatarPath), false);
|
||||
assert.equal(db.select({ image: schema.user.image }).from(schema.user).where(eq(schema.user.id, ownerOne.authUserId)).get()?.image, null);
|
||||
|
||||
const logoPath = resolveStoragePath(uploadsDir, logo.storagePath);
|
||||
fileService.delete(ownerOne, logo.id);
|
||||
assert.equal(fs.existsSync(logoPath), false);
|
||||
assert.equal(brandingService.getPublic().lightLogoFileId, null, "Deleting a logo must clear branding reference");
|
||||
|
||||
console.log("Phase 3 storage smoke passed: uploads, authorization, path safety, branding and deletion verified.");
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
|
||||
function imageInput(kind: "avatar" | "branding_logo" | "branding_icon" | "project_asset", originalName: string) {
|
||||
return { kind, originalName, claimedMimeType: "image/png", bytes: png } as const;
|
||||
}
|
||||
|
||||
function assertDomainError(run: () => unknown, code: DomainError["code"]) {
|
||||
assert.throws(run, (error) => error instanceof DomainError && error.code === code);
|
||||
}
|
||||
@@ -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