#!/usr/bin/env node import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { lstat, mkdir, readdir, readFile, realpath, rename, rm, stat, writeFile, } from "node:fs/promises"; import path from "node:path"; import process from "node:process"; import { parseDocument } from "yaml"; type CatalogFile = { path?: unknown; sha256?: unknown; }; type ProgramIndex = { type?: unknown; versions?: Array<{ files?: CatalogFile[] }>; additional_files?: CatalogFile[]; }; type Options = { check: boolean; targets: string[]; }; type FileReference = { data: CatalogFile; yamlPath: Array; }; const ignoredDirectories = new Set([".git", "node_modules", "files", "img"]); function usage(): void { console.log(`Использование: pnpm sha256 [--check] [путь ...] Без путей скрипт обходит текущий каталог. Путь может указывать на index.yaml или на папку, внутри которой нужно найти index.yaml. --check только проверить суммы, не изменяя YAML --help показать эту справку`); } function parseArguments(args: string[]): Options { const targets: string[] = []; let check = false; for (const argument of args) { if (argument === "--check") { check = true; } else if (argument === "--help" || argument === "-h") { usage(); process.exit(0); } else if (argument.startsWith("-")) { throw new Error(`Неизвестный параметр: ${argument}`); } else { targets.push(argument); } } return { check, targets: targets.length > 0 ? targets : ["."] }; } async function findIndexes(target: string, result: Set): Promise { const absoluteTarget = path.resolve(target); const targetStat = await lstat(absoluteTarget); if (targetStat.isFile()) { if (path.basename(absoluteTarget) !== "index.yaml") { throw new Error(`Ожидался index.yaml: ${target}`); } result.add(absoluteTarget); return; } if (!targetStat.isDirectory()) { throw new Error(`Путь не является файлом или каталогом: ${target}`); } const entries = await readdir(absoluteTarget, { withFileTypes: true }); for (const entry of entries) { const entryPath = path.join(absoluteTarget, entry.name); if (entry.isFile() && entry.name === "index.yaml") { result.add(entryPath); } else if (entry.isDirectory() && !ignoredDirectories.has(entry.name)) { await findIndexes(entryPath, result); } } } function collectFileReferences(data: ProgramIndex): FileReference[] { const references: FileReference[] = []; for (const [versionIndex, version] of (data.versions ?? []).entries()) { for (const [fileIndex, file] of (version.files ?? []).entries()) { references.push({ data: file, yamlPath: ["versions", versionIndex, "files", fileIndex], }); } } for (const [fileIndex, file] of (data.additional_files ?? []).entries()) { references.push({ data: file, yamlPath: ["additional_files", fileIndex], }); } return references; } async function resolveCatalogFile(indexPath: string, value: unknown): Promise { if (typeof value !== "string" || value.length === 0) { throw new Error("поле path отсутствует или не является строкой"); } if (path.isAbsolute(value) || value.includes("\\")) { throw new Error(`недопустимый путь: ${value}`); } const parts = value.split("/"); if (parts[0] !== "files" || parts.some((part) => part === ".." || part === "")) { throw new Error(`путь должен находиться внутри files/: ${value}`); } const programDirectory = path.dirname(indexPath); const filesDirectory = path.resolve(programDirectory, "files"); const filePath = path.resolve(programDirectory, ...parts); const relativePath = path.relative(filesDirectory, filePath); if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { throw new Error(`путь выходит за пределы files/: ${value}`); } const [realFilesDirectory, realFilePath] = await Promise.all([ realpath(filesDirectory), realpath(filePath), ]); const realRelativePath = path.relative(realFilesDirectory, realFilePath); if (realRelativePath.startsWith("..") || path.isAbsolute(realRelativePath)) { throw new Error(`символическая ссылка выходит за пределы files/: ${value}`); } if (!(await stat(realFilePath)).isFile()) { throw new Error(`путь не указывает на обычный файл: ${value}`); } return realFilePath; } async function calculateSha256(filePath: string): Promise { const hash = createHash("sha256"); for await (const chunk of createReadStream(filePath)) { hash.update(chunk); } return hash.digest("hex"); } async function writeAtomically(filePath: string, contents: string): Promise { const directory = path.dirname(filePath); const temporaryPath = path.join( directory, `.${path.basename(filePath)}.${process.pid}.tmp`, ); await mkdir(directory, { recursive: true }); try { await writeFile(temporaryPath, contents, "utf8"); await rename(temporaryPath, filePath); } finally { await rm(temporaryPath, { force: true }); } } async function processIndex(indexPath: string, check: boolean): Promise { const source = await readFile(indexPath, "utf8"); const document = parseDocument(source, { prettyErrors: true }); if (document.errors.length > 0) { throw new Error(document.errors.map((error) => error.message).join("; ")); } const data = document.toJS() as ProgramIndex; if (data.type !== "program") { return 0; } let changed = 0; const errors: string[] = []; const references = collectFileReferences(data); const results = await Promise.all( references.map(async (reference) => { try { const filePath = await resolveCatalogFile(indexPath, reference.data.path); return { reference, digest: await calculateSha256(filePath) }; } catch (error) { const message = error instanceof Error ? error.message : String(error); errors.push(`${String(reference.data.path)}: ${message}`); return undefined; } }), ); if (errors.length > 0) { throw new Error(errors.join("; ")); } for (const result of results) { if (result === undefined || result.reference.data.sha256 === result.digest) { continue; } changed += 1; if (!check) { document.setIn([...result.reference.yamlPath, "sha256"], result.digest); } } const displayPath = path.relative(process.cwd(), indexPath) || indexPath; if (changed > 0 && check) { console.error(`${displayPath}: требуется обновить SHA-256 (${changed})`); } else if (changed > 0) { await writeAtomically(indexPath, document.toString()); console.log(`${displayPath}: обновлено SHA-256 (${changed})`); } return changed; } async function main(): Promise { const options = parseArguments(process.argv.slice(2)); const indexes = new Set(); for (const target of options.targets) { await findIndexes(target, indexes); } let changed = 0; let failed = 0; for (const indexPath of [...indexes].sort()) { try { changed += await processIndex(indexPath, options.check); } catch (error) { failed += 1; const displayPath = path.relative(process.cwd(), indexPath) || indexPath; const message = error instanceof Error ? error.message : String(error); console.error(`${displayPath}: ${message}`); } } if (failed > 0) { console.error(`Ошибок: ${failed}`); process.exitCode = 1; } else if (options.check && changed > 0) { console.error(`Файлов с несовпадениями: ${changed}`); process.exitCode = 1; } else { console.log( changed > 0 ? `Готово, обновлено полей: ${changed}` : `Готово, все SHA-256 актуальны. Проверено index.yaml: ${indexes.size}`, ); } } await main();