Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
152 changes: 142 additions & 10 deletions logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,11 @@ export type CleanFolderDetailedOptions = {
keeplistName?: string;
};

export type CleanFromPlanOptions = {
mode?: CleanMode;
keeplistName?: string;
};

type KeeplistConfig = {
keepRules: string[];
renameDirectives: RenameDirective[];
Expand Down Expand Up @@ -300,6 +305,13 @@ async function readValidatedKeeplist(
return config;
}

export async function assertKeeplistReady(
root: string,
keeplistName = KEEPLIST_FILE,
): Promise<void> {
await readValidatedKeeplist(root, keeplistName);
}

function normalizeRule(rule: string): string {
let normalized = rule.trim().replaceAll("\\", "/");

Expand Down Expand Up @@ -533,51 +545,171 @@ function buildQuarantineRunId(): string {
async function quarantineFiles(
root: string,
removableFiles: string[],
): Promise<string | undefined> {
): 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<string>();
const seenTo = new Set<string>();

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(
root: string,
options: CleanFolderDetailedOptions = {},
): Promise<CleanResult> {
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<CleanResult> {
Comment on lines +668 to +672
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because cleanFromPlan is exported and accepts a ScanPlan from callers, it should defensively validate that plan.removableFiles and plan.plannedRenames contain only clean relative paths (no absolute paths, no .. segments) before executing filesystem operations. Without this, a crafted plan could cause path traversal (deleting/renaming outside root).

Copilot uses AI. Check for mistakes.
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,
Expand Down
Loading