mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
fix(server): keep home directory search out of ~/Library on macOS (#5789)
Discovery from the home directory walked into ~/Library, where readdir on TCC-protected group containers blocks forever for a daemon that cannot show a consent prompt. Each blocked call holds a libuv threadpool thread, so a few searches wedged every fs operation in the daemon. - Add an excludedDiscoveryPaths option; discovery skips those paths, while exact typed paths still resolve and browsing inside lists one level. - Resolve rooted typed paths directly and anchor the walk under an existing typed parent instead of walking the whole home directory. Refs #4879
This commit is contained in:
@@ -15,7 +15,7 @@ import equal from "fast-deep-equal";
|
||||
import { SessionDelivery, type OwnedSubscription } from "./session/owned-subscriptions/index.js";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
|
||||
import { basename, resolve, sep } from "path";
|
||||
import { basename, join, resolve, sep } from "path";
|
||||
import { homedir } from "node:os";
|
||||
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||
import { formatPluginSourceReference } from "@getpaseo/protocol/plugin-source-reference";
|
||||
@@ -5056,8 +5056,13 @@ export class Session {
|
||||
try {
|
||||
const workspaceCwd = cwd?.trim();
|
||||
const searchesWorkspace = Boolean(workspaceCwd);
|
||||
const homeRoot = process.env.HOME ?? homedir();
|
||||
// readdir on TCC-protected folders under ~/Library blocks forever for a process that cannot
|
||||
// show a consent prompt, and each blocked call holds a libuv threadpool thread.
|
||||
const excludedDiscoveryPaths =
|
||||
!searchesWorkspace && process.platform === "darwin" ? [join(homeRoot, "Library")] : [];
|
||||
const entries = await searchDirectoryEntries({
|
||||
root: workspaceCwd ? expandTilde(workspaceCwd) : (process.env.HOME ?? homedir()),
|
||||
root: workspaceCwd ? expandTilde(workspaceCwd) : homeRoot,
|
||||
query,
|
||||
pathFormat: searchesWorkspace ? "relative" : "absolute",
|
||||
pathQueryPolicy: searchesWorkspace ? "slashes" : "rooted",
|
||||
@@ -5068,6 +5073,7 @@ export class Session {
|
||||
: [],
|
||||
confidentResultScanThreshold: searchesWorkspace ? undefined : 5_000,
|
||||
respectGitIgnore: searchesWorkspace,
|
||||
excludedDiscoveryPaths,
|
||||
includeFiles,
|
||||
includeDirectories,
|
||||
matchMode,
|
||||
|
||||
@@ -697,6 +697,105 @@ describe("absolute directory-path configuration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("home search with excluded discovery paths", () => {
|
||||
let homeDir: string;
|
||||
let libraryDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
homeDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-excluded-")));
|
||||
libraryDir = path.join(homeDir, "Library");
|
||||
mkdirSync(path.join(libraryDir, "sub", "match-me"), { recursive: true });
|
||||
mkdirSync(path.join(homeDir, "projects", "other"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(homeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function searchHome(query: string) {
|
||||
return searchDirectoryEntries({
|
||||
root: homeDir,
|
||||
query,
|
||||
pathFormat: "absolute",
|
||||
includeDirectories: true,
|
||||
includeFiles: false,
|
||||
pathQueryPolicy: "rooted",
|
||||
rootAliases: ["~"],
|
||||
blankQueryBehavior: "none",
|
||||
confidentResultScanThreshold: 5_000,
|
||||
excludedDiscoveryPaths: [libraryDir],
|
||||
}).then((entries) => entries.map((entry) => entry.path));
|
||||
}
|
||||
|
||||
it("does not discover entries inside an excluded path", async () => {
|
||||
await expect(searchHome("match")).resolves.toEqual([]);
|
||||
await expect(searchHome("~")).resolves.toEqual([path.join(homeDir, "projects")]);
|
||||
});
|
||||
|
||||
it("excludes by path, so a nested directory with the same name is still discovered", async () => {
|
||||
const nested = path.join(homeDir, "projects", "game", "Library", "match-me");
|
||||
mkdirSync(nested, { recursive: true });
|
||||
|
||||
await expect(searchHome("match")).resolves.toEqual([nested]);
|
||||
});
|
||||
|
||||
// POSIX-only: creates and follows a symlink fixture.
|
||||
it.skipIf(isWindows)("does not discover an excluded path through a symlink", async () => {
|
||||
symlinkSync(path.join(libraryDir, "sub"), path.join(homeDir, "link"), "dir");
|
||||
|
||||
await expect(searchHome("match")).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("resolves an exact typed path inside an excluded path", async () => {
|
||||
await expect(searchHome("~/Library/sub/match-me")).resolves.toEqual([
|
||||
path.join(libraryDir, "sub", "match-me"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists one level when browsing inside an excluded path", async () => {
|
||||
await expect(searchHome("~/Library/")).resolves.toEqual([
|
||||
libraryDir,
|
||||
path.join(libraryDir, "sub"),
|
||||
]);
|
||||
await expect(searchHome("~/Library/su")).resolves.toEqual([path.join(libraryDir, "sub")]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rooted queries with a typed parent", () => {
|
||||
let homeDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
homeDir = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-typed-parent-")));
|
||||
mkdirSync(path.join(homeDir, "Developer", "pasta"), { recursive: true });
|
||||
mkdirSync(path.join(homeDir, "Archive", "Developer", "pasta"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(homeDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("walks only under the typed parent when it exists", async () => {
|
||||
await expect(
|
||||
searchAbsoluteDirectoryPaths({ homeDir, query: "~/Developer/pas" }),
|
||||
).resolves.toEqual([path.join(homeDir, "Developer", "pasta")]);
|
||||
});
|
||||
|
||||
it("matches the whole tree when the typed parent does not exist", async () => {
|
||||
await expect(searchAbsoluteDirectoryPaths({ homeDir, query: "~/Devel/pas" })).resolves.toEqual([
|
||||
path.join(homeDir, "Developer", "pasta"),
|
||||
path.join(homeDir, "Archive", "Developer", "pasta"),
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the typed directory first", async () => {
|
||||
mkdirSync(path.join(homeDir, "Developer", "pasta-extra"));
|
||||
|
||||
const results = await searchAbsoluteDirectoryPaths({ homeDir, query: "~/Developer/pasta" });
|
||||
|
||||
expect(results[0]).toBe(path.join(homeDir, "Developer", "pasta"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("relative typed-entry configuration", () => {
|
||||
let tempRoot: string;
|
||||
let workspaceDir: string;
|
||||
|
||||
@@ -33,6 +33,9 @@ export interface SearchDirectoryEntriesOptions {
|
||||
maxEntriesScanned?: number;
|
||||
confidentResultScanThreshold?: number;
|
||||
respectGitIgnore?: boolean;
|
||||
// Absolute paths that discovery never walks into. A path the caller types explicitly still
|
||||
// resolves, and browsing inside one lists a single level at a time.
|
||||
excludedDiscoveryPaths?: readonly string[];
|
||||
}
|
||||
|
||||
interface QueryPlan {
|
||||
@@ -55,6 +58,11 @@ interface RawChildEntry {
|
||||
kind: DirectorySuggestionKind | "symlink";
|
||||
}
|
||||
|
||||
interface WalkStart {
|
||||
visiblePath: string;
|
||||
resolvedPath: string;
|
||||
}
|
||||
|
||||
interface TraversedEntry extends ChildEntry {
|
||||
visiblePath: string;
|
||||
depth: number;
|
||||
@@ -174,29 +182,64 @@ export async function searchDirectoryEntries(
|
||||
const gitIgnoredPaths = options.respectGitIgnore
|
||||
? await loadGitIgnoredPaths(root)
|
||||
: new Set<string>();
|
||||
const input = buildSearchInput(options, root, gitIgnoredPaths);
|
||||
const excludedDiscoveryPaths = await resolveExistingPaths(options.excludedDiscoveryPaths ?? []);
|
||||
const input = buildSearchInput(options, root, gitIgnoredPaths, excludedDiscoveryPaths);
|
||||
if (!input) return [];
|
||||
|
||||
const exact =
|
||||
input.plan.browseExactPath || (input.matchMode === "suffix" && input.plan.isPathQuery)
|
||||
input.plan.browseExactPath ||
|
||||
(input.plan.isPathQuery && (input.matchMode === "suffix" || resolvesTypedPath(input)))
|
||||
? await findExactEntry(input)
|
||||
: null;
|
||||
if (exact && input.limit === 1) return [exact];
|
||||
|
||||
const browsesRoot = input.plan.isPathQuery && !input.plan.normalizedQuery;
|
||||
const browsesAbsoluteParent = input.plan.browseExactPath === true;
|
||||
const ranked =
|
||||
browsesRoot || browsesAbsoluteParent ? await searchChildren(input) : await searchTree(input);
|
||||
const ranked = await searchCandidates(input);
|
||||
const results = sortAndFormat(ranked, input.root, input.pathFormat).slice(0, input.limit);
|
||||
return exact
|
||||
? [exact, ...results.filter((entry) => !sameEntry(entry, exact))].slice(0, input.limit)
|
||||
: results;
|
||||
}
|
||||
|
||||
async function searchCandidates(input: SearchInput): Promise<RankedEntry[]> {
|
||||
const browsesRoot = input.plan.isPathQuery && !input.plan.normalizedQuery;
|
||||
const browsesAbsoluteParent = input.plan.browseExactPath === true;
|
||||
if (browsesRoot || browsesAbsoluteParent) return searchChildren(input);
|
||||
if (!resolvesTypedPath(input) || !input.plan.parentPart) return searchTree(input);
|
||||
|
||||
// A typed parent such as `~/Developer/pas` anchors the walk under `~/Developer`. A parent that
|
||||
// does not exist falls back to matching the typed path against the whole tree.
|
||||
const typedParent = await resolveTypedParent(input);
|
||||
if (!typedParent) return searchTree(input);
|
||||
if (isInsideExcludedPath(typedParent.resolvedPath, input)) return searchChildren(input);
|
||||
return searchTree(input, typedParent);
|
||||
}
|
||||
|
||||
// Only a rooted query under the rooted policy names a location. Under the slashes policy a query
|
||||
// with a slash is still a fragment that may match anywhere in the tree.
|
||||
function resolvesTypedPath(input: SearchInput): boolean {
|
||||
return input.pathQueryPolicy === "rooted" && input.plan.isPathQuery;
|
||||
}
|
||||
|
||||
async function resolveTypedParent(input: SearchInput): Promise<WalkStart | null> {
|
||||
const visiblePath = path.resolve(input.root, input.plan.parentPart);
|
||||
const resolvedPath = await realpath(visiblePath).catch(() => null);
|
||||
if (!resolvedPath || !isPathInsideRoot(input.root, resolvedPath)) return null;
|
||||
const info = await stat(resolvedPath).catch(() => null);
|
||||
return info?.isDirectory() ? { visiblePath, resolvedPath } : null;
|
||||
}
|
||||
|
||||
async function resolveExistingPaths(paths: readonly string[]): Promise<Set<string>> {
|
||||
const resolved = await Promise.all(
|
||||
paths.map((candidate) => realpath(path.resolve(candidate)).catch(() => null)),
|
||||
);
|
||||
return new Set(resolved.filter((candidate): candidate is string => candidate !== null));
|
||||
}
|
||||
|
||||
function buildSearchInput(
|
||||
options: SearchDirectoryEntriesOptions,
|
||||
root: string,
|
||||
gitIgnoredPaths: Set<string>,
|
||||
excludedDiscoveryPaths: Set<string>,
|
||||
): SearchInput | null {
|
||||
const includeDirectories = options.includeDirectories ?? true;
|
||||
const includeFiles = options.includeFiles ?? false;
|
||||
@@ -218,6 +261,7 @@ function buildSearchInput(
|
||||
includeDirectories,
|
||||
includeFiles,
|
||||
matchMode: options.matchMode ?? "fuzzy",
|
||||
pathQueryPolicy: options.pathQueryPolicy ?? "slashes",
|
||||
pathFormat: options.pathFormat,
|
||||
hiddenDirectoryNames: new Set(options.traversableHiddenDirectoryNames ?? []),
|
||||
limit: normalizeLimit(options.limit),
|
||||
@@ -225,6 +269,7 @@ function buildSearchInput(
|
||||
maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_ENTRIES_SCANNED,
|
||||
confidentResultScanThreshold: options.confidentResultScanThreshold,
|
||||
gitIgnoredPaths,
|
||||
excludedDiscoveryPaths,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,6 +297,7 @@ interface SearchInput {
|
||||
includeDirectories: boolean;
|
||||
includeFiles: boolean;
|
||||
matchMode: DirectorySuggestionMatchMode;
|
||||
pathQueryPolicy: PathQueryPolicy;
|
||||
pathFormat: DirectorySuggestionPathFormat;
|
||||
hiddenDirectoryNames: Set<string>;
|
||||
limit: number;
|
||||
@@ -259,6 +305,7 @@ interface SearchInput {
|
||||
maxEntriesScanned: number;
|
||||
confidentResultScanThreshold: number | undefined;
|
||||
gitIgnoredPaths: Set<string>;
|
||||
excludedDiscoveryPaths: Set<string>;
|
||||
}
|
||||
|
||||
async function searchChildren(input: SearchInput): Promise<RankedEntry[]> {
|
||||
@@ -266,9 +313,13 @@ async function searchChildren(input: SearchInput): Promise<RankedEntry[]> {
|
||||
const parent = await realpath(visibleParent).catch(() => null);
|
||||
if (!parent || !isPathInsideRoot(input.root, parent)) return [];
|
||||
if (isGitIgnoredPath(parent, input)) return [];
|
||||
// Browsing inside an excluded path is explicit: the caller typed it, so list what is there.
|
||||
const discoveryInput = isInsideExcludedPath(parent, input)
|
||||
? { ...input, excludedDiscoveryPaths: new Set<string>() }
|
||||
: input;
|
||||
const entries = await readChildren(parent);
|
||||
return entries.flatMap((entry) => {
|
||||
if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, input))
|
||||
if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, discoveryInput))
|
||||
return [];
|
||||
const candidate: TraversedEntry = {
|
||||
...entry,
|
||||
@@ -279,17 +330,20 @@ async function searchChildren(input: SearchInput): Promise<RankedEntry[]> {
|
||||
});
|
||||
}
|
||||
|
||||
async function searchTree(input: SearchInput): Promise<RankedEntry[]> {
|
||||
async function searchTree(
|
||||
input: SearchInput,
|
||||
start: WalkStart = { visiblePath: input.root, resolvedPath: input.root },
|
||||
): Promise<RankedEntry[]> {
|
||||
if (!(input.maxEntriesScanned > 0)) return [];
|
||||
const roots = (await readChildren(input.root)).filter((entry) =>
|
||||
const roots = (await readChildren(start.resolvedPath)).filter((entry) =>
|
||||
staysInsideRoot(entry, input.root),
|
||||
);
|
||||
const visited = new Set<string>([input.root]);
|
||||
const visited = new Set<string>([start.resolvedPath]);
|
||||
const branches = roots.flatMap((entry) =>
|
||||
shouldDiscover(entry, input)
|
||||
? [
|
||||
walkBranch(
|
||||
{ ...entry, visiblePath: path.join(input.root, entry.name), depth: 1 },
|
||||
{ ...entry, visiblePath: path.join(start.visiblePath, entry.name), depth: 1 },
|
||||
input,
|
||||
visited,
|
||||
),
|
||||
@@ -370,6 +424,7 @@ function staysInsideRoot(entry: ChildEntry, root: string): boolean {
|
||||
|
||||
function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean {
|
||||
if (isNewlyGitIgnored(entry, input)) return false;
|
||||
if (isNewlyExcluded(entry, input)) return false;
|
||||
if (entry.kind === "file") {
|
||||
return input.includeFiles && !entry.name.startsWith(".");
|
||||
}
|
||||
@@ -386,6 +441,21 @@ function isNewlyGitIgnored(entry: ChildEntry, input: SearchInput): boolean {
|
||||
return input.gitIgnoredPaths.has(entry.resolvedPath);
|
||||
}
|
||||
|
||||
// Same reasoning as Git-ignored paths: only a symlink can land inside an excluded path without
|
||||
// first passing through it by name.
|
||||
function isNewlyExcluded(entry: ChildEntry, input: SearchInput): boolean {
|
||||
if (input.excludedDiscoveryPaths.size === 0) return false;
|
||||
if (entry.viaSymlink) return isInsideExcludedPath(entry.resolvedPath, input);
|
||||
return input.excludedDiscoveryPaths.has(entry.resolvedPath);
|
||||
}
|
||||
|
||||
function isInsideExcludedPath(absolutePath: string, input: SearchInput): boolean {
|
||||
for (const excludedPath of input.excludedDiscoveryPaths) {
|
||||
if (isPathInsideRoot(excludedPath, absolutePath)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isGitIgnoredPath(absolutePath: string, input: SearchInput): boolean {
|
||||
let candidate = absolutePath;
|
||||
while (candidate !== input.root && isPathInsideRoot(input.root, candidate)) {
|
||||
|
||||
Reference in New Issue
Block a user