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
8 changes: 8 additions & 0 deletions docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,14 @@ The `generate` command reads source files from one or more rulesync source trees
> warning — sweeps no skill directory from the inside at all: with the source
> only partly read, a generated file it cannot account for may be one it still
> wants.
>
> An output directory reached through a symbolic link that leads outside the
> output root — a checked-out `.cursor/commands -> ~/notes`, say — is not swept
> either: what the sweep would list there are files in the link's target, not
> Rulesync's output, so each one is kept and named in a `Refusing to delete`
> warning, the same way a write through such a link is refused. A link that stays
> inside the output root is followed as usual, and an orphan that is itself a
> symbolic link is removed as a link, leaving its target untouched.

> **Note on unreadable sources:** This applies to the single-file features —
> `mcp`, `hooks`, `permissions`, and `ignore` — each of which is generated from
Expand Down
2 changes: 1 addition & 1 deletion src/generated/docs-content.ts

Large diffs are not rendered by default.

104 changes: 104 additions & 0 deletions src/types/feature-processor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,110 @@ describe("FeatureProcessor", () => {
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining("kept.md"));
});

it.skipIf(process.platform === "win32")(
"should refuse to delete through a linked directory that leads out of the output root",
async () => {
const logger = createMockLogger();
const root = join(testDir, "root");
const outside = join(testDir, "outside");
await mkdir(root, { recursive: true });
await mkdir(outside, { recursive: true });
// A checked-out repository can link a whole output directory elsewhere;
// the files listed through it belong to that directory, not the project.
await symlink(outside, join(root, ".tool"));
const processor = new TestProcessor({ logger, outputRoot: root });

const linkedPath = join(root, ".tool", "notes.md");
const orphanPath = join(root, "orphan.md");
const count = await processor.removeOrphanAiFiles(
[
createMockFile(linkedPath, { outputRoot: root }),
createMockFile(orphanPath, { outputRoot: root }),
],
[],
);

expect(count).toBe(1);
expect(removeFile).toHaveBeenCalledExactlyOnceWith(orphanPath);
expect(processor.getRemovedPaths()).toEqual([{ path: orphanPath, kind: "file" }]);
expect(logger.warn).toHaveBeenCalledWith(
`Refusing to delete ${JSON.stringify(linkedPath)}: it resolves outside ` +
`${JSON.stringify(root)} through a symbolic link`,
);
},
);

it.skipIf(process.platform === "win32")(
"should refuse a linked directory out of the output root before the dry-run report",
async () => {
const logger = createMockLogger();
const root = join(testDir, "root");
const outside = join(testDir, "outside");
await mkdir(root, { recursive: true });
await mkdir(outside, { recursive: true });
await symlink(outside, join(root, ".tool"));
const processor = new TestProcessor({ logger, outputRoot: root, dryRun: true });

const count = await processor.removeOrphanAiFiles(
[createMockFile(join(root, ".tool", "notes.md"), { outputRoot: root })],
[],
);

// `--check` must not report a deletion a real run would refuse.
expect(count).toBe(0);
expect(processor.getRemovedPaths()).toEqual([]);
expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining("[DRY RUN]"));
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining("through a symbolic link"),
);
},
);

it.skipIf(process.platform === "win32")(
"should still delete an orphan that is itself a link out of the output root",
async () => {
const logger = createMockLogger();
const root = join(testDir, "root");
const outside = join(testDir, "outside.md");
await mkdir(join(root, ".tool"), { recursive: true });
await writeFile(outside, "outside");
// Removing the link unlinks it where it is spelled; its target is untouched.
const linkPath = join(root, ".tool", "linked.md");
await symlink(outside, linkPath);
const processor = new TestProcessor({ logger, outputRoot: root });

const count = await processor.removeOrphanAiFiles(
[createMockFile(linkPath, { outputRoot: root })],
[],
);

expect(count).toBe(1);
expect(removeFile).toHaveBeenCalledExactlyOnceWith(linkPath);
expect(logger.warn).not.toHaveBeenCalled();
},
);

it.skipIf(process.platform === "win32")(
"should delete through a linked directory that stays inside the output root",
async () => {
const logger = createMockLogger();
const root = join(testDir, "root");
await mkdir(join(root, "dotfiles", ".tool"), { recursive: true });
await symlink(join(root, "dotfiles", ".tool"), join(root, ".tool"));
const processor = new TestProcessor({ logger, outputRoot: root });

const linkedPath = join(root, ".tool", "orphan.md");
const count = await processor.removeOrphanAiFiles(
[createMockFile(linkedPath, { outputRoot: root })],
[],
);

expect(count).toBe(1);
expect(removeFile).toHaveBeenCalledExactlyOnceWith(linkedPath);
expect(logger.warn).not.toHaveBeenCalled();
},
);

it("should not remove any files when existing is empty", async () => {
const processor = new TestProcessor({ logger: createMockLogger(), outputRoot: testDir });

Expand Down
46 changes: 44 additions & 2 deletions src/types/feature-processor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { join } from "node:path";
import { dirname, join } from "node:path";

import { RULESYNC_RELATIVE_DIR_PATH } from "../constants/rulesync-paths.js";
import type { DeletedPath } from "../lib/orphan-sweep.js";
Expand Down Expand Up @@ -197,8 +197,19 @@ export abstract class FeatureProcessor extends RulesyncSourceConsumer {
(f) => !generatedPaths.has(caseFoldIdentity(f.getFilePath())),
);

let removedCount = 0;
for (const aiFile of orphanFiles) {
const filePath = aiFile.getFilePath();
// Asked before the dry-run branch so `--check` and a real run agree.
if (
await refusesDeleteOutsideRoot({
logger: this.logger,
rootPath: aiFile.getOutputRoot(),
filePath,
})
) {
continue;
}
// The path is an on-disk name rulesync did not choose, so it is stripped
// like every other untrusted string this file logs: a name carrying
// `\x1b[2K\r` would otherwise rewrite the line and hide what was deleted.
Expand All @@ -213,9 +224,10 @@ export abstract class FeatureProcessor extends RulesyncSourceConsumer {
this.logger.info(`Deleted: ${loggedPath}`);
}
this.removedPaths.push({ path: aiFile.getRelativePathFromCwd(), kind: "file" });
removedCount++;
}

return orphanFiles.length;
return removedCount;
}

/**
Expand Down Expand Up @@ -383,6 +395,36 @@ export async function refusesWriteOutsideRoot({
return true;
}

/**
* Whether deleting `filePath` has to be refused because a link on the way to
* the directory holding it leads out of `rootPath`, warning about the refusal.
*
* Deletion candidates are enumerated without following links *inside* the
* scanned directory, but the scanned directory itself is reached through
* whatever links its path contains: a checked-out repository can carry
* `.cursor/commands -> ~/notes`, and every file in `~/notes` would then read as
* an orphan. Only the parent is judged, because removing a file that is itself
* a link unlinks the link where it is spelled and never touches its target.
*/
async function refusesDeleteOutsideRoot({
logger,
rootPath,
filePath,
}: {
logger: Logger;
rootPath: string;
filePath: string;
}): Promise<boolean> {
if (!(await writablePathEscapesRoot({ rootPath, targetPath: dirname(filePath) }))) {
return false;
}
logger.warn(
`Refusing to delete ${quoteForLog(filePath)}: it resolves outside ` +
`${quoteForLog(rootPath)} through a symbolic link`,
);
return true;
}

/**
* {@link refusesWriteOutsideRoot} over every path a single unit of output
* writes, stopping at the first refusal: a directory written as one unit is
Expand Down
Loading