import { chmod, chown, lstat, mkdir, mkdtemp, open, rm, unlink } from "node:fs/promises"; import type { Stats } from "node:fs"; import { basename, dirname, join } from "node:path"; import { assertMutationAllowed } from "./guard"; import { INSTALL_STATE_DIR } from "./installState"; export const DIAGNOSTICS_ROOT = `${INSTALL_STATE_DIR}/diagnostics`; const ROOT_UID = 0; const ROOT_GID = 0; const DIAGNOSTICS_MODE = 0o700; const ARCHIVE_MODE = 0o600; const HAS_POSIX_MODE_SEMANTICS = process.platform !== "win32"; type DirectoryPolicy = { uid: number; gid: number; exactMode?: number; rejectGroupOrOtherWrite?: boolean; }; export type DiagnosticsStorageOptions = { stateRoot?: string; diagnosticsRoot?: string; uid?: number; gid?: number; }; export type DiagnosticsWorkspace = { operationId: string; outDir: string; archive: string; }; function errnoCode(error: unknown): string | undefined { return error && typeof error === "object" && "code" in error ? String((error as { code?: unknown }).code) : undefined; } async function lstatOptional(path: string): Promise { try { return await lstat(path); } catch (error) { if (errnoCode(error) === "ENOENT") { return null; } throw error; } } function octal(mode: number): string { return (mode & 0o777).toString(8).padStart(4, "0"); } /** * Проверяет каталог именно по lstat-снимку: stat здесь запрещён, поскольку он * скрыл бы symlink и тем самым снова перенёс root-запись в чужой namespace. */ export function assertTrustedDirectory(path: string, stats: Stats, policy: DirectoryPolicy): void { if (stats.isSymbolicLink()) { throw new Error(`небезопасный каталог ${path}: symbolic link запрещён`); } if (!stats.isDirectory()) { throw new Error(`небезопасный каталог ${path}: ожидается обычный каталог`); } if (stats.uid !== policy.uid || stats.gid !== policy.gid) { throw new Error( `небезопасный каталог ${path}: ожидается владелец ${policy.uid}:${policy.gid}, ` + `получен ${stats.uid}:${stats.gid}` ); } const mode = stats.mode & 0o777; if (policy.exactMode !== undefined && mode !== policy.exactMode) { throw new Error( `небезопасный каталог ${path}: ожидается режим ${octal(policy.exactMode)}, получен ${octal(mode)}` ); } if (policy.rejectGroupOrOtherWrite && (mode & 0o022) !== 0) { throw new Error( `небезопасный каталог ${path}: группа или остальные могут изменять root namespace (режим ${octal(mode)})` ); } } function resolveOptions(options: DiagnosticsStorageOptions = {}): Required { const stateRoot = options.stateRoot ?? INSTALL_STATE_DIR; const diagnosticsRoot = options.diagnosticsRoot ?? join(stateRoot, "diagnostics"); const uid = options.uid ?? ROOT_UID; const gid = options.gid ?? ROOT_GID; if (dirname(diagnosticsRoot) !== stateRoot) { throw new Error(`diagnostics root обязан быть непосредственным дочерним каталогом ${stateRoot}`); } return { stateRoot, diagnosticsRoot, uid, gid }; } function assertWorkspacePaths( workspace: DiagnosticsWorkspace, options: DiagnosticsStorageOptions = {} ): void { const resolved = resolveOptions(options); const directoryName = basename(workspace.outDir); if ( dirname(workspace.outDir) !== resolved.diagnosticsRoot || !/^diag-[A-Za-z0-9]{6}$/.test(directoryName) || workspace.operationId !== directoryName || workspace.archive !== `${workspace.outDir}.tar.gz` ) { throw new Error(`небезопасные пути diagnostics workspace: ${workspace.outDir}`); } } /** * Создаёт и затем повторно проверяет root-only namespace диагностики. * * Родитель уже принадлежит install-state и недоступен сервисному пользователю. * Неверные существующие права не «исправляются» автоматически: это может быть * следом подмены, поэтому граница привилегий обязана отказать fail closed. */ export async function ensureDiagnosticsStorageRoot( options: DiagnosticsStorageOptions = {} ): Promise { assertMutationAllowed("ensureDiagnosticsStorageRoot"); const resolved = resolveOptions(options); const stateStats = await lstatOptional(resolved.stateRoot); if (!stateStats) { throw new Error(`доверенный каталог состояния отсутствует: ${resolved.stateRoot}`); } assertTrustedDirectory(resolved.stateRoot, stateStats, { uid: resolved.uid, gid: resolved.gid, rejectGroupOrOtherWrite: HAS_POSIX_MODE_SEMANTICS }); let created = false; try { await mkdir(resolved.diagnosticsRoot, { mode: DIAGNOSTICS_MODE }); created = true; } catch (error) { // Параллельные read-only diagnostics не берут operation lock и законно // могут одновременно увидеть отсутствующий каталог. Победитель создаёт // его, проигравший всё равно обязан проверить итоговый inode через lstat. if (errnoCode(error) !== "EEXIST") { throw error; } } if (created) { // mkdir применяет umask. Под доверенным root-only родителем pathname нельзя // подменить непривилегированным процессом, поэтому режим можно довести до // точного публичного контракта и затем проверить ещё раз. if (HAS_POSIX_MODE_SEMANTICS) { await chown(resolved.diagnosticsRoot, resolved.uid, resolved.gid); } await chmod(resolved.diagnosticsRoot, DIAGNOSTICS_MODE); } const diagnosticsStats = await lstat(resolved.diagnosticsRoot); assertTrustedDirectory(resolved.diagnosticsRoot, diagnosticsStats, { uid: resolved.uid, gid: resolved.gid, exactMode: HAS_POSIX_MODE_SEMANTICS ? DIAGNOSTICS_MODE : undefined }); return resolved.diagnosticsRoot; } /** Резервирует непредсказуемые рабочий каталог и archive path эксклюзивно. */ export async function createDiagnosticsWorkspace( options: DiagnosticsStorageOptions = {} ): Promise { assertMutationAllowed("createDiagnosticsWorkspace"); const root = await ensureDiagnosticsStorageRoot(options); const outDir = await mkdtemp(join(root, "diag-")); try { const resolved = resolveOptions(options); if (HAS_POSIX_MODE_SEMANTICS) { await chown(outDir, resolved.uid, resolved.gid); } await chmod(outDir, DIAGNOSTICS_MODE); assertTrustedDirectory(outDir, await lstat(outDir), { uid: resolved.uid, gid: resolved.gid, exactMode: HAS_POSIX_MODE_SEMANTICS ? DIAGNOSTICS_MODE : undefined }); const archive = `${outDir}.tar.gz`; const archiveHandle = await open(archive, "wx", ARCHIVE_MODE); try { if (HAS_POSIX_MODE_SEMANTICS) { await archiveHandle.chown(resolved.uid, resolved.gid); } await archiveHandle.chmod(ARCHIVE_MODE); } finally { await archiveHandle.close(); } return { operationId: basename(outDir), outDir, archive }; } catch (error) { await rm(outDir, { recursive: true, force: true }).catch(() => undefined); throw error; } } export async function assertDiagnosticsArchive(path: string, options: DiagnosticsStorageOptions = {}): Promise { const resolved = resolveOptions(options); if (dirname(path) !== resolved.diagnosticsRoot || !/^diag-[A-Za-z0-9]{6}\.tar\.gz$/.test(basename(path))) { throw new Error(`небезопасный путь diagnostics archive: ${path}`); } const stats = await lstat(path); if (stats.isSymbolicLink() || !stats.isFile()) { throw new Error(`небезопасный diagnostics archive ${path}: ожидается обычный файл`); } if (stats.uid !== resolved.uid || stats.gid !== resolved.gid) { throw new Error(`небезопасный diagnostics archive ${path}: неверный владелец`); } if (HAS_POSIX_MODE_SEMANTICS && (stats.mode & 0o777) !== ARCHIVE_MODE) { throw new Error(`небезопасный diagnostics archive ${path}: ожидается режим 0600`); } } export async function finalizeDiagnosticsArchive( path: string, options: DiagnosticsStorageOptions = {} ): Promise { assertMutationAllowed("finalizeDiagnosticsArchive"); await chmod(path, ARCHIVE_MODE); await assertDiagnosticsArchive(path, options); } export async function cleanupDiagnosticsWorkspace( workspace: DiagnosticsWorkspace, options: DiagnosticsStorageOptions = {} ): Promise { assertMutationAllowed("cleanupDiagnosticsWorkspace"); assertWorkspacePaths(workspace, options); await rm(workspace.outDir, { recursive: true, force: true }); } export async function discardDiagnosticsArchive( workspace: DiagnosticsWorkspace, options: DiagnosticsStorageOptions = {} ): Promise { assertMutationAllowed("discardDiagnosticsArchive"); assertWorkspacePaths(workspace, options); await unlink(workspace.archive).catch((error) => { if (errnoCode(error) !== "ENOENT") { throw error; } }); }