import crypto from "node:crypto"; import type { Stats } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import type { ExpectedEntry } from "./config"; export type SnapshotNode = { name: string; type: string; path: string; uid?: number; gid?: number; mode?: number; size?: number; }; function normalizeRelativePath(input: string): string { const normalized = path.posix.normalize(input); if (normalized === "") { return "."; } return normalized; } function normalizeEntryType(type: string): string { if (type === "dir" || type === "directory") { return "directory"; } return type; } async function readSha256(filePath: string): Promise { const digest = crypto.createHash("sha256"); digest.update(await fs.readFile(filePath)); return digest.digest("hex"); } function assertMetadata( entryLabel: string, expected: ExpectedEntry, actual: { uid?: number; gid?: number; mode?: number }, ) { if (expected.uid !== undefined && actual.uid !== expected.uid) { throw new Error(`${entryLabel} uid mismatch: expected ${expected.uid}, got ${String(actual.uid)}`); } if (expected.gid !== undefined && actual.gid !== expected.gid) { throw new Error(`${entryLabel} gid mismatch: expected ${expected.gid}, got ${String(actual.gid)}`); } if (expected.mode === undefined) { return; } if (actual.mode === undefined) { throw new Error(`${entryLabel} mode is missing`); } const permissionBits = actual.mode & 0o7777; if (permissionBits !== expected.mode) { throw new Error( `${entryLabel} mode mismatch: expected ${expected.mode.toString(8)}, got ${permissionBits.toString(8)}`, ); } } function getFilesystemEntryType(stats: Stats): ExpectedEntry["type"] { if (stats.isDirectory()) { return "directory"; } if (stats.isSymbolicLink()) { return "symlink"; } return "file"; } export async function verifyFilesystemEntries(basePath: string, expectedEntries: ExpectedEntry[], label: string) { for (const expected of expectedEntries) { const targetPath = path.resolve(basePath, expected.path); const stats = await fs.lstat(targetPath); const actualType = getFilesystemEntryType(stats); if (actualType !== expected.type) { throw new Error(`${label}: ${expected.path} type mismatch: expected ${expected.type}, got ${actualType}`); } assertMetadata(`${label}: ${expected.path}`, expected, stats); if (expected.type === "file" && expected.text !== undefined) { const actualText = await fs.readFile(targetPath, "utf8"); if (actualText !== expected.text) { throw new Error(`${label}: ${expected.path} text content mismatch`); } } if (expected.type === "file" && expected.sha256 !== undefined) { const actualSha = await readSha256(targetPath); if (actualSha !== expected.sha256.toLowerCase()) { throw new Error(`${label}: ${expected.path} sha256 mismatch`); } } if (expected.type !== "symlink" || expected.linkTarget === undefined) { continue; } const actualTarget = await fs.readlink(targetPath); if (actualTarget !== expected.linkTarget) { throw new Error( `${label}: ${expected.path} symlink target mismatch: expected ${expected.linkTarget}, got ${actualTarget}`, ); } } } export async function verifySnapshotEntries( snapshotRootPath: string, nodes: SnapshotNode[], expectedEntries: ExpectedEntry[], ) { const nodesByRelativePath = new Map(); for (const node of nodes) { const relativePath = path.posix.relative(snapshotRootPath, node.path); if (!relativePath || relativePath === "." || relativePath === ".." || relativePath.startsWith("../")) { continue; } nodesByRelativePath.set(normalizeRelativePath(relativePath), node); } for (const expected of expectedEntries) { const normalizedExpectedPath = normalizeRelativePath(expected.path); const node = nodesByRelativePath.get(normalizedExpectedPath); if (!node) { throw new Error(`snapshot: missing ${expected.path}`); } const actualType = normalizeEntryType(node.type); if (actualType !== expected.type) { throw new Error(`snapshot: ${expected.path} type mismatch: expected ${expected.type}, got ${actualType}`); } assertMetadata(`snapshot: ${expected.path}`, expected, node); } }