Skip to content
Open
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
22 changes: 22 additions & 0 deletions apps/server/src/workspace/WorkspaceEntries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,28 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => {
}),
);

it.effect("includes directory symlinks when browsing", () =>
Effect.gen(function* () {
const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const path = yield* Path.Path;
const platform = yield* HostProcessPlatform;
const cwd = yield* makeTempDir({ prefix: "t3code-workspace-browse-symlink-" });
const target = path.join(cwd, "target");
const link = path.join(cwd, "linked");
yield* writeTextFile(cwd, "target/index.ts", "export {};\n");
yield* Effect.promise(() =>
fsPromises.symlink(target, link, platform === "win32" ? "junction" : "dir"),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Symlink regression test cannot run

Medium Severity

The new browse symlink regression test calls fsPromises.symlink, but this file only imports the fs promises module as NodeFSP. Setup throws before browse runs, so the only coverage for directory symlink/junction inclusion never executes.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ae46c95. Configure here.


const result = yield* workspaceEntries.browse({
partialPath: yield* appendSeparator(cwd),
});

expect(result.entries).toContainEqual({ name: "linked", fullPath: link });
expect(result.entries).toContainEqual({ name: "target", fullPath: target });
}),
);

it.effect("supports relative paths when cwd is provided", () =>
Effect.gen(function* () {
const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
Expand Down
70 changes: 56 additions & 14 deletions apps/server/src/workspace/WorkspaceEntries.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// @effect-diagnostics nodeBuiltinImport:off
import type { Dirent } from "node:fs";
import * as NodeFSP from "node:fs/promises";
import * as NodeOS from "node:os";

Expand Down Expand Up @@ -97,6 +98,41 @@ export class WorkspaceEntries extends Context.Service<
}
>()("t3/workspace/WorkspaceEntries") {}

const WINDOWS_LEGACY_PROFILE_JUNCTION_NAMES = new Set([
"Application Data",
"Cookies",
"Local Settings",
"My Documents",
"NetHood",
"PrintHood",
"Recent",
"SendTo",
"Start Menu",
"Templates",
]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Incomplete Windows junction denylist

Medium Severity

WINDOWS_LEGACY_PROFILE_JUNCTION_NAMES omits the standard profile junctions My Music, My Pictures, and My Videos, while including the same-class entry My Documents. After symlink/junction browsing is enabled, those missing names can pass stat and show up under the user profile as noisy dead-end folders that fail when opened.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ae46c95. Configure here.


async function isDirectoryEntry(
dirent: Dirent,
fullPath: string,
platform: NodeJS.Platform,
): Promise<boolean> {
if (dirent.isDirectory()) {
return true;
}
if (!dirent.isSymbolicLink()) {
return false;
}
if (platform === "win32" && WINDOWS_LEGACY_PROFILE_JUNCTION_NAMES.has(dirent.name)) {
return false;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Legacy junction filter too broad

Low Severity

On Windows, the isDirectoryEntry function's check for WINDOWS_LEGACY_PROFILE_JUNCTION_NAMES applies too broadly. It filters out valid directory symlinks whose names match, even when they are outside the user profile, preventing these legitimate project folders from appearing in browse results.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ae46c95. Configure here.


try {
return (await NodeFSP.stat(fullPath)).isDirectory();
} catch {
return false;
}
}

function expandHomePath(input: string, path: Path.Path): string {
if (input === "~") {
return NodeOS.homedir();
Expand Down Expand Up @@ -134,6 +170,7 @@ const resolveBrowseTarget = Effect.fn("WorkspaceEntries.resolveBrowseTarget")(fu

export const make = Effect.gen(function* () {
const path = yield* Path.Path;
const platform = yield* HostProcessPlatform;
const workspacePaths = yield* WorkspacePaths.WorkspacePaths;
const workspaceSearchIndexes = yield* WorkspaceSearchIndex.WorkspaceSearchIndexMap;

Expand Down Expand Up @@ -206,23 +243,28 @@ export const make = Effect.gen(function* () {

const showHidden = endsWithSeparator || prefix.startsWith(".");
const lowerPrefix = prefix.toLowerCase();
const entries: Array<{ readonly name: string; readonly fullPath: string }> = [];
for (const dirent of dirents) {
if (
dirent.isDirectory() &&
dirent.name.toLowerCase().startsWith(lowerPrefix) &&
(showHidden || !dirent.name.startsWith("."))
) {
entries.push({
name: dirent.name,
fullPath: path.join(parentPath, dirent.name),
});
}
}
const entries = yield* Effect.forEach(
dirents,
(dirent) =>
Effect.promise(async () => {
const fullPath = path.join(parentPath, dirent.name);
if (
!dirent.name.toLowerCase().startsWith(lowerPrefix) ||
(!showHidden && dirent.name.startsWith(".")) ||
!(await isDirectoryEntry(dirent, fullPath, platform))
) {
return null;
}
return { name: dirent.name, fullPath };
}),
{ concurrency: 16 },
);

return {
parentPath,
entries: entries.toSorted((left, right) => left.name.localeCompare(right.name)),
entries: entries
.filter((entry): entry is NonNullable<typeof entry> => entry !== null)
.toSorted((left, right) => left.name.localeCompare(right.name)),
};
},
);
Expand Down
Loading