diff --git a/logic.ts b/logic.ts index c38260f..08b2633 100644 --- a/logic.ts +++ b/logic.ts @@ -60,6 +60,11 @@ export type CleanFolderDetailedOptions = { keeplistName?: string; }; +export type CleanFromPlanOptions = { + mode?: CleanMode; + keeplistName?: string; +}; + type KeeplistConfig = { keepRules: string[]; renameDirectives: RenameDirective[]; @@ -300,6 +305,13 @@ async function readValidatedKeeplist( return config; } +export async function assertKeeplistReady( + root: string, + keeplistName = KEEPLIST_FILE, +): Promise { + await readValidatedKeeplist(root, keeplistName); +} + function normalizeRule(rule: string): string { let normalized = rule.trim().replaceAll("\\", "/"); @@ -533,22 +545,113 @@ function buildQuarantineRunId(): string { async function quarantineFiles( root: string, removableFiles: string[], -): Promise { +): Promise<{ runId?: string; movedFiles: string[] }> { if (removableFiles.length === 0) { - return undefined; + return { runId: undefined, movedFiles: [] }; } const runId = buildQuarantineRunId(); const quarantineRoot = join(root, QUARANTINE_DIR, runId); + const movedFiles: string[] = []; for (const file of removableFiles) { const sourcePath = join(root, file); const targetPath = join(quarantineRoot, file); await Deno.mkdir(dirname(targetPath), { recursive: true }); - await Deno.rename(sourcePath, targetPath); + try { + await Deno.rename(sourcePath, targetPath); + movedFiles.push(file); + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + continue; + } + throw error; + } } - return runId; + return { runId: movedFiles.length === 0 ? undefined : runId, movedFiles }; +} + +function validatePlanRelativePath(path: string, kind: string): string { + const normalizedPath = normalizeLiteralPath(path); + + if (!normalizedPath) { + throw new Error(`Invalid scan plan: ${kind} path is empty.`); + } + + if (isAbsolute(normalizedPath) || /^[A-Za-z]:\//.test(normalizedPath)) { + throw new Error( + `Invalid scan plan: ${kind} path '${normalizedPath}' must be relative.`, + ); + } + + if ( + normalizedPath.includes("*") || normalizedPath.includes("?") || + normalizedPath.includes("[") || normalizedPath.includes("]") + ) { + throw new Error( + `Invalid scan plan: wildcards are not allowed in ${kind} path '${normalizedPath}'.`, + ); + } + + const segments = normalizedPath.split("/"); + if ( + segments.some((segment) => + segment.length === 0 || segment === "." || segment === ".." + ) + ) { + throw new Error( + `Invalid scan plan: ${kind} path '${normalizedPath}' must be a clean relative path.`, + ); + } + + return normalizedPath; +} + +function validateScanPlanInput(plan: ScanPlan): ScanPlan { + const removableFiles: string[] = []; + const plannedRenames: RenameDirective[] = []; + const seenFrom = new Set(); + const seenTo = new Set(); + + for (const file of plan.removableFiles) { + const normalizedFile = validatePlanRelativePath(file, "removable file"); + if (isProtectedKeeplistPath(normalizedFile)) { + throw new Error( + `Invalid scan plan: removable file '${normalizedFile}' is protected.`, + ); + } + removableFiles.push(normalizedFile); + } + + for (const rename of plan.plannedRenames) { + const from = validatePlanRelativePath(rename.from, "rename from"); + const to = validatePlanRelativePath(rename.to, "rename to"); + + if (from === to) { + throw new Error( + `Invalid scan plan: rename source and target are identical ('${from}').`, + ); + } + + if (seenFrom.has(from)) { + throw new Error( + `Invalid scan plan: duplicate rename source path '${from}'.`, + ); + } + + if (seenTo.has(to)) { + throw new Error( + `Invalid scan plan: duplicate rename target path '${to}'.`, + ); + } + + seenFrom.add(from); + seenTo.add(to); + plannedRenames.push({ from, to }); + } + + return { removableFiles, plannedRenames }; } export async function cleanFolderDetailed( @@ -556,28 +659,57 @@ export async function cleanFolderDetailed( options: CleanFolderDetailedOptions = {}, ): Promise { const plan = await buildScanPlan(root, options.keeplistName ?? KEEPLIST_FILE); + return cleanFromPlan(root, plan, { + mode: options.mode, + keeplistName: options.keeplistName, + }); +} + +export async function cleanFromPlan( + root: string, + plan: ScanPlan, + options: CleanFromPlanOptions = {}, +): Promise { const mode = options.mode ?? "delete"; + const keeplistName = options.keeplistName ?? KEEPLIST_FILE; if (mode !== "delete" && mode !== "quarantine") { throw new Error(`Invalid clean mode: ${mode}`); } + const validatedPlan = validateScanPlanInput(plan); + await readValidatedKeeplist(root, keeplistName); + let quarantineRunId: string | undefined; + const removedFiles: string[] = []; if (mode === "delete") { - for (const file of plan.removableFiles) { - await Deno.remove(join(root, file)); + for (const file of validatedPlan.removableFiles) { + try { + await Deno.remove(join(root, file)); + removedFiles.push(file); + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + continue; + } + throw error; + } } } else { - quarantineRunId = await quarantineFiles(root, plan.removableFiles); + const quarantineResult = await quarantineFiles( + root, + validatedPlan.removableFiles, + ); + quarantineRunId = quarantineResult.runId; + removedFiles.push(...quarantineResult.movedFiles); } - const renameResults = await applyRenames(root, plan.plannedRenames); + const renameResults = await applyRenames(root, validatedPlan.plannedRenames); - await pruneEmptyParentDirs(root, plan.removableFiles); + await pruneEmptyParentDirs(root, removedFiles); return { - removedFiles: plan.removableFiles, + removedFiles, renameResults, mode, quarantineRunId, diff --git a/logic_test.ts b/logic_test.ts index be980f7..1fa35a0 100644 --- a/logic_test.ts +++ b/logic_test.ts @@ -1,7 +1,9 @@ import { + assertKeeplistReady, buildScanPlan, cleanFolder, cleanFolderDetailed, + cleanFromPlan, GENERATED_KEEPLIST_HEADER, getRemovableFiles, isKept, @@ -499,6 +501,252 @@ Deno.test("scan plan includes planned renames", async () => { } }); +Deno.test("cleanFromPlan executes exactly the previously built scan plan", async () => { + const root = await Deno.makeTempDir(); + + try { + await Deno.mkdir(join(root, "keep"), { recursive: true }); + await Deno.mkdir(join(root, "trash"), { recursive: true }); + await Deno.mkdir(join(root, "restore"), { recursive: true }); + + await Deno.writeTextFile(join(root, "keep", "stay.txt"), "keep"); + await Deno.writeTextFile(join(root, "trash", "planned.tmp"), "planned"); + await Deno.writeTextFile(join(root, "restore", "backup.bin"), "backup"); + + await Deno.writeTextFile( + join(root, KEEPLIST_FILE), + [ + "keep/**", + "!rename restore/backup.bin -> restore/live.bin", + ].join("\n"), + ); + + const plan = await buildScanPlan(root); + + await Deno.writeTextFile( + join(root, "trash", "added-after-scan.tmp"), + "late", + ); + + const result = await cleanFromPlan(root, plan); + assertEquals(result.removedFiles, ["trash/planned.tmp"]); + assertEquals(result.renameResults, [ + { + from: "restore/backup.bin", + to: "restore/live.bin", + applied: true, + }, + ]); + + const lateFileExists = await Deno.stat( + join(root, "trash", "added-after-scan.tmp"), + ) + .then(() => true) + .catch(() => false); + assertEquals(lateFileExists, true); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("cleanFromPlan validates clean mode values", async () => { + const root = await Deno.makeTempDir(); + + try { + const invalidOptions = { mode: "archive" } as unknown as { + mode?: "delete" | "quarantine"; + }; + + await assertRejects( + () => + cleanFromPlan( + root, + { removableFiles: [], plannedRenames: [] }, + invalidOptions, + ), + Error, + "Invalid clean mode: archive", + ); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("cleanFromPlan fails with explicit error when current keeplist is missing", async () => { + const root = await Deno.makeTempDir(); + + try { + await Deno.writeTextFile(join(root, "trash.tmp"), "remove"); + await Deno.writeTextFile(join(root, KEEPLIST_FILE), "keep/**\n"); + + const plan = await buildScanPlan(root); + await Deno.remove(join(root, KEEPLIST_FILE)); + + await assertRejects( + () => cleanFromPlan(root, plan), + Error, + "#keeplist.txt not found", + ); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("cleanFromPlan fails with explicit error when current keeplist has no keep rules", async () => { + const root = await Deno.makeTempDir(); + + try { + await Deno.writeTextFile(join(root, "trash.tmp"), "remove"); + await Deno.writeTextFile(join(root, KEEPLIST_FILE), "keep/**\n"); + + const plan = await buildScanPlan(root); + await Deno.writeTextFile(join(root, KEEPLIST_FILE), "\n"); + + await assertRejects( + () => cleanFromPlan(root, plan), + Error, + "#keeplist.txt is empty", + ); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("assertKeeplistReady fails with explicit error when keeplist is missing", async () => { + const root = await Deno.makeTempDir(); + + try { + await assertRejects( + () => assertKeeplistReady(root), + Error, + "#keeplist.txt not found", + ); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("assertKeeplistReady fails with explicit error when keeplist has no keep rules", async () => { + const root = await Deno.makeTempDir(); + + try { + await Deno.writeTextFile(join(root, KEEPLIST_FILE), "\n"); + await assertRejects( + () => assertKeeplistReady(root), + Error, + "#keeplist.txt is empty", + ); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("cleanFromPlan rejects traversal paths in removable files", async () => { + const root = await Deno.makeTempDir(); + + try { + await assertRejects( + () => + cleanFromPlan(root, { + removableFiles: ["../outside.txt"], + plannedRenames: [], + }), + Error, + "Invalid scan plan", + ); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("cleanFromPlan rejects protected keeplist targets in removable files", async () => { + const root = await Deno.makeTempDir(); + + try { + await assertRejects( + () => + cleanFromPlan(root, { + removableFiles: ["#keeplist.txt"], + plannedRenames: [], + }), + Error, + "is protected", + ); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("cleanFromPlan rejects invalid rename paths in scan plan", async () => { + const root = await Deno.makeTempDir(); + + try { + await assertRejects( + () => + cleanFromPlan(root, { + removableFiles: [], + plannedRenames: [{ from: "keep/file.txt", to: "../escape.txt" }], + }), + Error, + "Invalid scan plan", + ); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("cleanFromPlan ignores planned removals that are already missing", async () => { + const root = await Deno.makeTempDir(); + + try { + await Deno.mkdir(join(root, "trash"), { recursive: true }); + await Deno.mkdir(join(root, "restore"), { recursive: true }); + await Deno.writeTextFile(join(root, "trash", "remove.tmp"), "remove"); + await Deno.writeTextFile(join(root, "restore", "backup.bin"), "backup"); + await Deno.writeTextFile( + join(root, KEEPLIST_FILE), + [ + "keep/**", + "!rename restore/backup.bin -> restore/live.bin", + ].join("\n"), + ); + + const plan = await buildScanPlan(root); + await Deno.remove(join(root, "trash", "remove.tmp")); + + const result = await cleanFromPlan(root, plan); + assertEquals(result.removedFiles, []); + assertEquals(result.renameResults, [ + { + from: "restore/backup.bin", + to: "restore/live.bin", + applied: true, + }, + ]); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("cleanFromPlan quarantine mode ignores planned removals that are already missing", async () => { + const root = await Deno.makeTempDir(); + + try { + await Deno.mkdir(join(root, "trash"), { recursive: true }); + await Deno.writeTextFile(join(root, "trash", "remove.tmp"), "remove"); + await Deno.writeTextFile(join(root, KEEPLIST_FILE), "keep/**\n"); + + const plan = await buildScanPlan(root); + await Deno.remove(join(root, "trash", "remove.tmp")); + + const result = await cleanFromPlan(root, plan, { mode: "quarantine" }); + assertEquals(result.removedFiles, []); + assertEquals(result.quarantineRunId, undefined); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + Deno.test("cleanFolder applies !rename after deletion and reports rename results", async () => { const root = await Deno.makeTempDir(); diff --git a/main.ts b/main.ts index 9ebc5f9..7a19939 100644 --- a/main.ts +++ b/main.ts @@ -2,10 +2,11 @@ import { WebUI } from "WebUI"; import { FileDialog, load as loadNativeDialog } from "@miyauci/rfd/deno"; import { buildScanPlan, - cleanFolderDetailed, + cleanFromPlan, type CleanMode, QUARANTINE_DIR, resolveKeeplistName, + type ScanPlan, writeKeeplist, } from "./logic.ts"; @@ -392,7 +393,7 @@ button.danger:hover:not(:disabled) {
- @@ -788,6 +789,15 @@ async function getRootPath(event: WebUI.Event): Promise { let lastScannedRoot: string | null = null; let lastScannedKeeplistName: string | null = null; +let lastScannedMode: CleanMode | null = null; +let lastScanPlan: ScanPlan | null = null; + +function resetCachedScanState(): void { + lastScannedRoot = null; + lastScannedKeeplistName = null; + lastScannedMode = null; + lastScanPlan = null; +} async function browseFolder(event: WebUI.Event): Promise { const currentPath = event.arg.string(0).trim(); @@ -810,8 +820,7 @@ async function browseFolder(event: WebUI.Event): Promise { JSON.stringify(selectedPath) }; markScanDirty();`, ); - lastScannedRoot = null; - lastScannedKeeplistName = null; + resetCachedScanState(); runCleanReady(event, false); runStatus(event, "Folder selected", "success"); } catch (error) { @@ -836,8 +845,7 @@ async function generateKeeplist(event: WebUI.Event): Promise { try { runBusy(event, true); const files = await writeKeeplist(root, keeplistName); - lastScannedRoot = null; - lastScannedKeeplistName = null; + resetCachedScanState(); runCleanReady(event, false); runStatus( event, @@ -865,9 +873,12 @@ async function scanFolder(event: WebUI.Event): Promise { try { runBusy(event, true); + const mode = await getCleanMode(event); const plan = await buildScanPlan(root, keeplistName); lastScannedRoot = root; lastScannedKeeplistName = keeplistName; + lastScannedMode = mode; + lastScanPlan = plan; runCleanReady(event, true); runResults(event, plan.removableFiles, plan.plannedRenames); runStatus( @@ -876,8 +887,7 @@ async function scanFolder(event: WebUI.Event): Promise { "info", ); } catch (error) { - lastScannedRoot = null; - lastScannedKeeplistName = null; + resetCachedScanState(); runCleanReady(event, false); runStatus(event, `Failed to scan folder: ${String(error)}`, "error"); } finally { @@ -897,11 +907,18 @@ async function cleanFiles(event: WebUI.Event): Promise { return; } - if (lastScannedRoot !== root || lastScannedKeeplistName !== keeplistName) { + const mode = await getCleanMode(event); + + if ( + !lastScanPlan || + lastScannedRoot !== root || + lastScannedKeeplistName !== keeplistName || + lastScannedMode !== mode + ) { runCleanReady(event, false); runStatus( event, - "Run Scan first for the current folder, then review the results before cleaning.", + "Run Scan first for the current folder, keeplist, and clean mode, then review the results before cleaning.", "error", ); return; @@ -913,11 +930,12 @@ async function cleanFiles(event: WebUI.Event): Promise { return; } - const mode = await getCleanMode(event); - try { runBusy(event, true); - const result = await cleanFolderDetailed(root, { mode, keeplistName }); + const result = await cleanFromPlan(root, lastScanPlan, { + mode, + keeplistName, + }); const appliedRenames = result.renameResults.filter((rename) => rename.applied ) @@ -951,8 +969,7 @@ async function cleanFiles(event: WebUI.Event): Promise { } catch (error) { runStatus(event, `Failed to clean folder: ${String(error)}`, "error"); } finally { - lastScannedRoot = null; - lastScannedKeeplistName = null; + resetCachedScanState(); runCleanReady(event, false); runBusy(event, false); }