fix(project): prevent deleted projects from resurrecting

- Migrate legacy project zips by moving instead of copying
- Remove legacy zip copies on project delete
- Add contract test for legacy migration/delete behavior

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-22 15:29:22 +08:00
parent ffce066842
commit f823a7c05f
3 changed files with 53 additions and 2 deletions
@@ -0,0 +1,25 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
void test('zipStore: deleteProjectById removes legacy sibling copies by entry.fileName', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /async deleteProjectById/);
assert.match(src, /for \(const legacyRoot of getLegacyProjectsRootDirs\(\)\)/);
assert.match(src, /path\.join\(legacyRoot, entry\.fileName\)/);
assert.match(src, /rmWithRetries\(fs\.rm, legacyZipPath, \{ force: true \}\)/);
});
void test('zipStore: legacy migration moves or copy\\+rm so deleted projects are not resurrected', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /migrateLegacyProjectZipsIfNeeded/);
assert.match(src, /if \(destZips\.has\(name\)\) continue/);
assert.match(src, /await fs\.rename\(from, to\)/);
assert.match(src, /await fs\.copyFile\(from, to\)/);
assert.match(src, /rmWithRetries\(fs\.rm, from, \{ force: true \}\)/);
assert.match(src, /не «возрождались»/);
});
+27 -1
View File
@@ -100,7 +100,21 @@ export class ZipProjectStore {
try {
const st = await fs.stat(from);
if (!st.isFile()) continue;
await fs.copyFile(from, to);
// Переносим (а не копируем), чтобы:
// - не было дублей между разными appName
// - удалённые пользователем проекты не «возрождались» при следующем ensureRoots()
try {
await fs.rename(from, to);
} catch {
await fs.copyFile(from, to);
try {
await rmWithRetries(fs.rm, from, { force: true });
} catch {
// best effort: если zip уже скопирован в dest, миграцию считаем успешной;
// legacy-копия может остаться (например из-за lock/AV), но удаление проекта
// затем чистит legacy по fileName.
}
}
destZips.add(name);
} catch {
/* ignore */
@@ -795,6 +809,18 @@ export class ZipProjectStore {
await rmWithRetries(fs.rm, zipPath, { force: true });
await rmWithRetries(fs.rm, cacheDir, { recursive: true, force: true });
// Если проект подтянулся миграцией из legacy userData (другое имя приложения),
// то после удаления из текущей папки он может снова появиться при следующем ensureRoots().
// Поэтому удаляем и legacy-копии архива.
for (const legacyRoot of getLegacyProjectsRootDirs()) {
const legacyZipPath = path.join(legacyRoot, entry.fileName);
try {
await rmWithRetries(fs.rm, legacyZipPath, { force: true });
} catch {
/* ignore */
}
}
}
private randomId(): string {
+1 -1
View File
@@ -10,7 +10,7 @@
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
"lint": "eslint . --max-warnings 0",
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs",
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/project/assetPrune.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs",
"format": "prettier . --check",
"format:write": "prettier . --write",
"release:info": "node scripts/print-release-info.mjs",