fix(export): не падать с EPERM при сохранении в корень диска на Windows

Перед записью zip больше не вызываем mkdir для E:\ / C:\ — на Windows это даёт operation not permitted.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-09-10 18:09:48 +08:00
parent 48a9c933af
commit 3d0474fe97
5 changed files with 63 additions and 12 deletions
+26
View File
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { ensureParentDir } from './ensureParentDir';
void test('ensureParentDir: creates nested parent', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-mkdir-'));
try {
const file = path.join(root, 'a', 'b', 'out.ttrpg.zip');
await ensureParentDir(file);
const st = await fs.stat(path.dirname(file));
assert.equal(st.isDirectory(), true);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
void test('ensureParentDir: drive root is a no-op (Windows)', async () => {
if (process.platform !== 'win32') return;
// Must not throw EPERM the way fs.mkdir('E:\\') often does.
await ensureParentDir('E:\\campaign.ttrpg.zip');
await ensureParentDir('C:\\file.ttrpg.zip');
});
+14
View File
@@ -0,0 +1,14 @@
import fs from 'node:fs/promises';
import path from 'node:path';
/**
* Создаёт родительский каталог для файла.
* На Windows `mkdir('E:\\')` даёт EPERM — корень диска пропускаем.
*/
export async function ensureParentDir(filePath: string): Promise<void> {
const dir = path.dirname(filePath);
if (!dir || dir === '.') return;
const root = path.parse(path.resolve(filePath)).root;
if (root && path.resolve(dir) === path.resolve(root)) return;
await fs.mkdir(dir, { recursive: true });
}
@@ -139,6 +139,16 @@ void test('main index: full zip import loads app-tokens like playlists', () => {
assert.match(importFromPath[0], /importAppTokensFromOpenProjectCache/);
});
void test('zipStore: zipDir uses ensureParentDir (no mkdir of drive root)', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /from '\.\/ensureParentDir'/);
const i = src.indexOf('async function zipDir(');
assert.ok(i >= 0);
const block = src.slice(i, i + 800);
assert.match(block, /await ensureParentDir\(outZipPath\)/);
assert.doesNotMatch(block, /fs\.mkdir\(path\.dirname\(outZipPath\)/);
});
void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => {
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
const i = src.indexOf('export async function replaceFileAtomic');
+12 -11
View File
@@ -112,6 +112,7 @@ import { getAppSemanticVersion } from '../versionInfo';
import { reconcileAssetFiles } from './assetPrune';
import { recoverOrphanProjectZipTmpInRoot, replaceFileAtomic } from './atomicReplace';
import { ensureParentDir } from './ensureParentDir';
import { rmWithRetries } from './fsRetry';
import { optimizeImageBufferVisuallyLossless } from './optimizeImageImport.lib.mjs';
import { getLegacyProjectsRootDirs, getProjectsCacheRootDir, getProjectsRootDir } from './paths';
@@ -475,7 +476,7 @@ export class ZipProjectStore {
const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath);
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
await fs.mkdir(path.dirname(abs), { recursive: true });
await ensureParentDir(abs);
await fs.writeFile(abs, buf);
const asset = buildMediaAsset(id, kind0, orig, relPath, sha256, buf.length);
@@ -1165,7 +1166,7 @@ export class ZipProjectStore {
const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await ensureParentDir(abs);
await fs.copyFile(filePath, abs);
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
}
@@ -1226,7 +1227,7 @@ export class ZipProjectStore {
const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await ensureParentDir(abs);
await fs.copyFile(filePath, abs);
staged.push(buildMediaAsset(id, kind, orig, relPath, sha256, buf.length));
}
@@ -1316,7 +1317,7 @@ export class ZipProjectStore {
const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await ensureParentDir(abs);
await fs.writeFile(abs, buf);
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
nextAssetId = id;
@@ -1551,7 +1552,7 @@ export class ZipProjectStore {
const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await ensureParentDir(abs);
await fs.writeFile(abs, buf);
stagedAsset = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
nextAssetId = id;
@@ -2038,7 +2039,7 @@ export class ZipProjectStore {
private async packZipFromCache(cacheDir: string, zipPath: string): Promise<void> {
const tmpPath = `${zipPath}.tmp`;
await fs.mkdir(path.dirname(zipPath), { recursive: true });
await ensureParentDir(zipPath);
await zipDir(cacheDir, tmpPath);
const st = await fs.stat(tmpPath).catch(() => null);
if (!st?.isFile() || st.size < 22) {
@@ -2178,7 +2179,7 @@ export class ZipProjectStore {
}
const src = path.join(getProjectsRootDir(), entry.fileName);
const dest = path.resolve(destinationPath);
await fs.mkdir(path.dirname(dest), { recursive: true });
await ensureParentDir(dest);
if (onProgress) onProgress({ stage: 'copy', percent: 1, detail: 'Копирование…' });
await copyFileWithProgress(src, dest, (pct) => {
if (onProgress) onProgress({ stage: 'copy', percent: pct, detail: 'Копирование…' });
@@ -2291,7 +2292,7 @@ export class ZipProjectStore {
if (!a) continue;
const srcAbs = path.join(snap.cacheDir, a.relPath);
const destAbs = path.join(exportCache, a.relPath);
await fs.mkdir(path.dirname(destAbs), { recursive: true });
await ensureParentDir(destAbs);
await fs.copyFile(srcAbs, destAbs);
if (onProgress) {
const pct = Math.round(((i + 1) / Math.max(1, assetIds.length)) * 75);
@@ -2428,7 +2429,7 @@ export class ZipProjectStore {
if (!srcAsset || !tgtMeta) continue;
const srcAbs = path.join(sourceCache, srcAsset.relPath);
const destAbs = path.join(targetCache, tgtMeta.relPath);
await fs.mkdir(path.dirname(destAbs), { recursive: true });
await ensureParentDir(destAbs);
await fs.copyFile(srcAbs, destAbs);
if (onProgress) {
const pct = Math.round(((i + 1) / Math.max(1, assetCopies.length)) * 90);
@@ -2894,7 +2895,7 @@ async function copyFileWithProgress(
onPercent(100);
return;
}
await fs.mkdir(path.dirname(dest), { recursive: true });
await ensureParentDir(dest);
await new Promise<void>((resolve, reject) => {
let done = 0;
const rs = fssync.createReadStream(src);
@@ -3027,7 +3028,7 @@ async function zipDir(srcDir: string, outZipPath: string): Promise<void> {
}
zipfile.addBuffer(buf, rel, zipOptionsForRelativeEntry(rel));
}
await fs.mkdir(path.dirname(outZipPath), { recursive: true });
await ensureParentDir(outZipPath);
const out = fssync.createWriteStream(outZipPath);
const done = new Promise<void>((resolve, reject) => {
out.on('close', resolve);