perf(ipc): build the filesystem allowed-root list once per authorization (#18423)

* perf(ipc): build the filesystem allowed-root list once per authorization

* perf(ipc): keep the allowed-root snapshot lazy so granted external paths build nothing

Hoisting getAllowedRoots to the top of resolveAuthorizedPath made every read of a
path covered by an external grant build the full root list, where main built none
(the grant answered before isPathAllowed reached the roots). Build on first use
instead: still one build per authorization, zero when a grant already answers.

* test(ipc): skip the allowed-root symlink escapes on Windows

Unprivileged Windows cannot create symlinks (EPERM), so both cases failed in
setup instead of exercising the escape check.
This commit is contained in:
Neil
2026-09-03 22:20:35 -07:00
committed by GitHub
parent 7574ee8403
commit 3941edd4b6
5 changed files with 492 additions and 34 deletions
@@ -0,0 +1,372 @@
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { Store } from '../persistence'
import type * as RepoWorktrees from '../repo-worktrees'
import { listRepoWorktreeGraph } from '../repo-worktrees'
import type * as ProjectGroupsModule from '../../shared/project-groups'
import { buildProjectGroupChildIndex, getProjectGroupSubtreeIds } from '../../shared/project-groups'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import { getWorktreeMirrorDistro } from '../project-runtime-git-options'
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../shared/project-group-types'
import type { Project } from '../../shared/project-types'
import type { Repo } from '../../shared/repo-types'
import { getAllowedRoots } from './filesystem-allowed-roots'
import { authorizeExternalPath, resolveAuthorizedPath } from './filesystem-auth'
import { invalidateAuthorizedRootsCache } from './registered-worktree-roots-cache'
import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic'
vi.mock('../repo-worktrees', async () => {
const actual = await vi.importActual<typeof RepoWorktrees>('../repo-worktrees')
return { ...actual, listRepoWorktreeGraph: vi.fn(async () => []) }
})
vi.mock('../../shared/project-groups', async () => {
const actual = await vi.importActual<typeof ProjectGroupsModule>('../../shared/project-groups')
return {
...actual,
buildProjectGroupChildIndex: vi.fn(actual.buildProjectGroupChildIndex),
getProjectGroupSubtreeIds: vi.fn(actual.getProjectGroupSubtreeIds)
}
})
type StoreFixture = {
repos: Repo[]
projects: Project[]
projectGroups: ProjectGroup[]
folderWorkspaces: FolderWorkspace[]
workspaceDir?: string
}
type StoreCallCounts = {
getRepos: number
getProjects: number
getProjectGroups: number
getFolderWorkspaces: number
}
function makeCountingStore(fixture: StoreFixture): { store: Store; counts: StoreCallCounts } {
const counts: StoreCallCounts = {
getRepos: 0,
getProjects: 0,
getProjectGroups: 0,
getFolderWorkspaces: 0
}
const store = {
getRepos: () => {
counts.getRepos += 1
// Match the real store, which rehydrates fresh repo objects on every read.
return fixture.repos.map((repo) => ({ ...repo }))
},
getProjects: () => {
counts.getProjects += 1
return fixture.projects.map((project) => ({ ...project }))
},
getProjectGroups: () => {
counts.getProjectGroups += 1
return fixture.projectGroups.map((group) => ({ ...group }))
},
getFolderWorkspaces: () => {
counts.getFolderWorkspaces += 1
return fixture.folderWorkspaces.map((workspace) => ({ ...workspace }))
},
getSettings: () => ({ nestWorkspaces: false, workspaceDir: fixture.workspaceDir ?? '' })
} as unknown as Store
return { store, counts }
}
/**
* The pre-change `getAllowedRoots` algorithm, kept verbatim so the equivalence test compares the
* new root list against the old one rather than against a hand-written expectation.
*/
function referenceAllowedRoots(store: Store): string[] {
const scopeStore = store as unknown as {
getRepos: () => Repo[]
getProjectGroups?: () => ProjectGroup[]
getFolderWorkspaces?: () => FolderWorkspace[]
getSettings: () => { workspaceDir?: string; nestWorkspaces?: boolean }
}
const localRepos = scopeStore.getRepos().filter((repo) => !repo.connectionId)
const settings = scopeStore.getSettings()
const scopeRepos = scopeStore.getRepos()
const projectGroups = scopeStore.getProjectGroups?.() ?? []
const isRemoteOnly = (
folderPath: string,
projectGroupId: string,
connectionId: string | null | undefined
): boolean => {
if (connectionId) {
return true
}
const groupIds = getProjectGroupSubtreeIds(projectGroups, projectGroupId)
const candidates = scopeRepos.filter(
(repo) =>
(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) ||
isPathInsideOrEqual(folderPath, repo.path)
)
return candidates.length > 0 && candidates.every((repo) => Boolean(repo.connectionId))
}
const folderScopeRoots: string[] = []
for (const group of projectGroups) {
if (group.parentPath && !isRemoteOnly(group.parentPath, group.id, group.connectionId)) {
folderScopeRoots.push(resolve(group.parentPath))
}
}
for (const workspace of scopeStore.getFolderWorkspaces?.() ?? []) {
const connectionId =
workspace.connectionId ??
projectGroups.find((group) => group.id === workspace.projectGroupId)?.connectionId ??
null
if (!isRemoteOnly(workspace.folderPath, workspace.projectGroupId, connectionId)) {
folderScopeRoots.push(resolve(workspace.folderPath))
}
}
const roots = [...localRepos.map((repo) => resolve(repo.path)), ...folderScopeRoots]
if (settings.workspaceDir) {
if (localRepos.length === 0) {
roots.push(resolve(settings.workspaceDir))
} else {
for (const repo of localRepos) {
roots.push(
resolve(
computeWorkspaceRoot(
repo.path,
getWorktreePathSettings(repo, settings as never, getWorktreeMirrorDistro(store, repo))
)
)
)
}
}
}
return roots
}
function makeRepo(overrides: Partial<Repo> & Pick<Repo, 'id' | 'path'>): Repo {
return {
displayName: overrides.id,
badgeColor: '#000000',
addedAt: 1,
kind: 'git',
...overrides
}
}
function makeGroup(overrides: Partial<ProjectGroup> & Pick<ProjectGroup, 'id'>): ProjectGroup {
return {
name: overrides.id,
parentPath: null,
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
function makeWorkspace(
overrides: Partial<FolderWorkspace> & Pick<FolderWorkspace, 'id' | 'folderPath'>
): FolderWorkspace {
return {
projectGroupId: 'group-root',
name: overrides.id,
comment: '',
linkedTask: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
/** Repos, nested groups, folder workspaces (one not a git worktree), and an SSH repo. */
function makeMixedFixture(): StoreFixture {
const repos = [
makeRepo({ id: 'repo-local', path: '/repos/app', projectGroupId: 'group-root' }),
makeRepo({ id: 'repo-nested', path: '/repos/nested', projectGroupId: 'group-child' }),
makeRepo({ id: 'repo-folder', path: '/folders/plain', kind: 'folder' }),
makeRepo({
id: 'repo-ssh',
path: '/remote/app',
connectionId: 'ssh-1',
projectGroupId: 'group-remote'
})
]
const projectGroups = [
makeGroup({ id: 'group-root', parentPath: '/folders/root' }),
makeGroup({ id: 'group-child', parentGroupId: 'group-root', parentPath: '/folders/child' }),
makeGroup({ id: 'group-grandchild', parentGroupId: 'group-child' }),
makeGroup({ id: 'group-remote', parentPath: '/remote/scope' }),
makeGroup({ id: 'group-connection', parentPath: '/remote/via-group', connectionId: 'ssh-1' })
]
const folderWorkspaces = [
makeWorkspace({ id: 'ws-git', folderPath: '/folders/root/feature' }),
// Not a git worktree: a plain folder workspace under a folder-kind repo.
makeWorkspace({
id: 'ws-plain',
folderPath: '/folders/plain/scratch',
projectGroupId: 'group-child'
}),
makeWorkspace({ id: 'ws-remote', folderPath: '/remote/ws', projectGroupId: 'group-remote' }),
makeWorkspace({
id: 'ws-connection',
folderPath: '/remote/direct',
projectGroupId: 'group-connection'
}),
makeWorkspace({
id: 'ws-unlinked',
folderPath: '/folders/unlinked',
projectGroupId: 'group-orphan'
})
]
const projects: Project[] = [
{
id: 'project-1',
displayName: 'App',
badgeColor: '#000000',
sourceRepoIds: ['repo-local', 'repo-nested'],
createdAt: 1,
updatedAt: 1
},
{
id: 'project-2',
displayName: 'Folder',
badgeColor: '#000000',
sourceRepoIds: ['repo-folder'],
createdAt: 1,
updatedAt: 1
}
]
return { repos, projects, projectGroups, folderWorkspaces, workspaceDir: '/workspaces' }
}
beforeEach(() => {
invalidateAuthorizedRootsCache()
vi.mocked(buildProjectGroupChildIndex).mockClear()
vi.mocked(getProjectGroupSubtreeIds).mockClear()
})
describe('getAllowedRoots', () => {
it('produces the same roots as the pre-change implementation', () => {
const { store } = makeCountingStore(makeMixedFixture())
expect(getAllowedRoots(store)).toEqual(referenceAllowedRoots(store))
})
it('reads the store once and indexes project groups once per build', () => {
const fixture = makeMixedFixture()
const { store, counts } = makeCountingStore(fixture)
getAllowedRoots(store)
expect.soft(counts.getRepos).toBe(1)
expect.soft(counts.getProjectGroups).toBe(1)
expect.soft(counts.getFolderWorkspaces).toBe(1)
// Batched runtime resolution scans the project list once, not once per local repo.
expect.soft(counts.getProjects).toBe(1)
// The per-scope subtree walk no longer rebuilds the parent->children index.
expect.soft(vi.mocked(buildProjectGroupChildIndex)).toHaveBeenCalledTimes(1)
expect.soft(vi.mocked(getProjectGroupSubtreeIds)).not.toHaveBeenCalled()
})
})
describe('resolveAuthorizedPath allowed-root reuse', () => {
let repoRoot: string
let outsideRoot: string
let store: Store
let counts: StoreCallCounts
beforeEach(async () => {
repoRoot = await mkdtemp(join(await realpath(tmpdir()), 'orca-allowed-roots-'))
outsideRoot = await mkdtemp(join(await realpath(tmpdir()), 'orca-outside-'))
const fixture = makeMixedFixture()
fixture.repos = [makeRepo({ id: 'repo-local', path: repoRoot }), ...fixture.repos]
fixture.projects[0]!.sourceRepoIds = ['repo-local']
;({ store, counts } = makeCountingStore(fixture))
})
afterEach(async () => {
await rm(repoRoot, { recursive: true, force: true })
await rm(outsideRoot, { recursive: true, force: true })
})
it('builds the allowed-root list once per call across repeated reads', async () => {
const dirPath = join(repoRoot, 'src')
await mkdir(dirPath)
await writeFile(join(dirPath, 'index.ts'), 'export {}\n')
const callCount = 5
for (let index = 0; index < callCount; index += 1) {
await resolveAuthorizedPath(dirPath, store)
await resolveAuthorizedPath(join(dirPath, 'index.ts'), store)
}
const buildCount = callCount * 2
// One build per authorization, not one per raw-path check plus one per realpath check.
expect.soft(counts.getFolderWorkspaces).toBe(buildCount)
expect.soft(counts.getRepos).toBe(buildCount)
expect.soft(counts.getProjects).toBe(buildCount)
expect.soft(vi.mocked(buildProjectGroupChildIndex)).toHaveBeenCalledTimes(buildCount)
expect.soft(vi.mocked(getProjectGroupSubtreeIds)).not.toHaveBeenCalled()
})
// Why (both symlink cases): creating a symlink on Windows needs elevation or
// Developer Mode, so these would fail EPERM in setup rather than exercise the
// escape check. Every non-symlink case still runs there.
it.skipIf(process.platform === 'win32')(
'still refuses a symlink that escapes every allowed root',
async () => {
const secret = join(outsideRoot, 'secret.txt')
await writeFile(secret, 'secret\n')
const escape = join(repoRoot, 'escape.txt')
await symlink(secret, escape)
await expect(resolveAuthorizedPath(escape, store)).rejects.toThrow('Access denied')
expect(vi.mocked(listRepoWorktreeGraph)).toHaveBeenCalled()
}
)
it('builds no allowed-root list at all for a granted external path', async () => {
const external = join(outsideRoot, 'external.md')
await writeFile(external, 'notes\n')
authorizeExternalPath(external)
counts.getRepos = 0
counts.getProjects = 0
counts.getFolderWorkspaces = 0
for (let index = 0; index < 5; index += 1) {
await expect(resolveAuthorizedPath(external, store)).resolves.toBe(external)
}
// The grant answers on its own; hoisting the snapshot must not turn zero builds into one per read.
expect.soft(counts.getRepos).toBe(0)
expect.soft(counts.getProjects).toBe(0)
expect.soft(counts.getFolderWorkspaces).toBe(0)
expect.soft(vi.mocked(buildProjectGroupChildIndex)).not.toHaveBeenCalled()
})
it.skipIf(process.platform === 'win32')(
'still refuses a directory symlink that escapes every allowed root',
async () => {
const outsideDir = join(outsideRoot, 'nested')
await mkdir(outsideDir)
await writeFile(join(outsideDir, 'file.txt'), 'secret\n')
const escape = join(repoRoot, 'escape-dir')
await symlink(outsideDir, escape)
await expect(resolveAuthorizedPath(join(escape, 'file.txt'), store)).rejects.toThrow(
'Access denied'
)
}
)
})
+44 -15
View File
@@ -1,9 +1,16 @@
import { resolve } from 'node:path'
import type { Store } from '../persistence'
import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic'
import { getWorktreeMirrorDistro } from '../project-runtime-git-options'
import {
getWorktreeMirrorDistroForRuntime,
resolveLocalProjectRuntimesForRepos
} from '../project-runtime-git-options'
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import { getProjectGroupSubtreeIds } from '../../shared/project-groups'
import {
buildProjectGroupChildIndex,
collectProjectGroupSubtreeIds,
type ProjectGroupChildIndex
} from '../../shared/project-groups'
import type { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../shared/project-group-types'
import type { Repo } from '../../shared/repo-types'
@@ -11,18 +18,22 @@ import type { Repo } from '../../shared/repo-types'
type FolderScopeStore = Pick<Store, 'getRepos'> &
Partial<Pick<Store, 'getProjectGroups' | 'getFolderWorkspaces'>>
// Why: SSH repo paths are remote-host paths; treating them as local roots could authorize unrelated local folders or probe SSH-only paths.
function filterLocalRepos(repos: readonly Repo[]): Repo[] {
return repos.filter((repo) => !repo.connectionId)
}
export function getLocalRepos(store: Store) {
// Why: SSH repo paths are remote-host paths; treating them as local roots could authorize unrelated local folders or probe SSH-only paths.
return store.getRepos().filter((repo) => !repo.connectionId)
return filterLocalRepos(store.getRepos())
}
function getFolderScopeCandidateRepos(
folderPath: string,
projectGroupId: string,
projectGroups: readonly ProjectGroup[],
childGroupIndex: ProjectGroupChildIndex,
repos: readonly Repo[]
): Repo[] {
const groupIds = getProjectGroupSubtreeIds(projectGroups, projectGroupId)
const groupIds = collectProjectGroupSubtreeIds(childGroupIndex, projectGroupId)
return repos.filter(
(repo) =>
(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) ||
@@ -34,13 +45,18 @@ function isRemoteOnlyFolderScope(
folderPath: string,
projectGroupId: string,
connectionId: string | null | undefined,
projectGroups: readonly ProjectGroup[],
childGroupIndex: ProjectGroupChildIndex,
repos: readonly Repo[]
): boolean {
if (connectionId) {
return true
}
const candidates = getFolderScopeCandidateRepos(folderPath, projectGroupId, projectGroups, repos)
const candidates = getFolderScopeCandidateRepos(
folderPath,
projectGroupId,
childGroupIndex,
repos
)
return candidates.length > 0 && candidates.every((repo) => Boolean(repo.connectionId))
}
@@ -55,16 +71,22 @@ function getFolderWorkspaceConnectionId(
)
}
function getLocalFolderScopeRoots(store: Store): string[] {
function getLocalFolderScopeRoots(store: Store, repos: readonly Repo[]): string[] {
const scopeStore = store as FolderScopeStore
const repos = scopeStore.getRepos()
// Why: many filesystem tests use narrow Store doubles; folder scopes are additive.
const projectGroups = scopeStore.getProjectGroups?.() ?? []
const childGroupIndex = buildProjectGroupChildIndex(projectGroups)
const roots: string[] = []
for (const group of projectGroups) {
if (
group.parentPath &&
!isRemoteOnlyFolderScope(group.parentPath, group.id, group.connectionId, projectGroups, repos)
!isRemoteOnlyFolderScope(
group.parentPath,
group.id,
group.connectionId,
childGroupIndex,
repos
)
) {
roots.push(resolve(group.parentPath))
}
@@ -75,7 +97,7 @@ function getLocalFolderScopeRoots(store: Store): string[] {
workspace.folderPath,
workspace.projectGroupId,
getFolderWorkspaceConnectionId(workspace, projectGroups),
projectGroups,
childGroupIndex,
repos
)
) {
@@ -86,16 +108,19 @@ function getLocalFolderScopeRoots(store: Store): string[] {
}
export function getAllowedRoots(store: Store): string[] {
const localRepos = getLocalRepos(store)
// Why one read: `getRepos` rehydrates every repo, and this runs twice per filesystem IPC.
const repos = store.getRepos()
const localRepos = filterLocalRepos(repos)
const settings = store.getSettings()
const roots = [
...localRepos.map((repo) => resolve(repo.path)),
...getLocalFolderScopeRoots(store)
...getLocalFolderScopeRoots(store, repos)
]
if (settings.workspaceDir) {
if (localRepos.length === 0) {
roots.push(resolve(settings.workspaceDir))
} else {
const projectRuntimeByRepoId = resolveLocalProjectRuntimesForRepos(store, localRepos)
for (const repo of localRepos) {
roots.push(
resolve(
@@ -104,7 +129,11 @@ export function getAllowedRoots(store: Store): string[] {
// Why enriched here too: placement has to agree with the create
// flow, or renderer file access is denied for a worktree Orca
// just put on the WSL side.
getWorktreePathSettings(repo, settings, getWorktreeMirrorDistro(store, repo))
getWorktreePathSettings(
repo,
settings,
getWorktreeMirrorDistroForRuntime(projectRuntimeByRepoId.get(repo.id))
)
)
)
)
+51 -14
View File
@@ -43,7 +43,24 @@ export function authorizeExternalPath(targetPath: string): void {
} catch {}
}
export function isPathAllowed(targetPath: string, store: Store): boolean {
/**
* One allowed-root list shared by every check in a single authorization.
*
* Lazy so a path already covered by an external grant still builds nothing at all, the way it did
* before the list was hoisted out of the individual checks.
*/
type AllowedRootsSnapshot = { get: () => readonly string[] }
function createAllowedRootsSnapshot(store: Store): AllowedRootsSnapshot {
let roots: readonly string[] | undefined
return { get: () => (roots ??= getAllowedRoots(store)) }
}
export function isPathAllowed(
targetPath: string,
store: Store,
allowedRoots?: AllowedRootsSnapshot
): boolean {
const resolvedTarget = resolve(targetPath)
if (authorizedExternalPaths.has(resolvedTarget)) {
return true
@@ -53,7 +70,9 @@ export function isPathAllowed(targetPath: string, store: Store): boolean {
return true
}
}
return getAllowedRoots(store).some((root) => isDescendantOrEqual(resolvedTarget, root))
return (allowedRoots?.get() ?? getAllowedRoots(store)).some((root) =>
isDescendantOrEqual(resolvedTarget, root)
)
}
export type ResolveAuthorizedPathOptions = {
@@ -69,7 +88,10 @@ export async function resolveAuthorizedPath(
options: ResolveAuthorizedPathOptions = {}
): Promise<string> {
const resolvedTarget = resolve(targetPath)
if (!(await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store))) {
// Why: the roots depend only on store state, not on the candidate path, so one snapshot serves
// every authorization below; each candidate is still checked against it in full.
const allowedRoots = createAllowedRootsSnapshot(store)
if (!(await isPathAllowedIncludingRegisteredWorktrees(resolvedTarget, store, { allowedRoots }))) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
}
@@ -80,14 +102,15 @@ export async function resolveAuthorizedPath(
realParent = await realpath(dirname(resolvedTarget))
} catch (error) {
if (isENOENT(error)) {
return resolveAuthorizedMissingPath(resolvedTarget, store)
return resolveAuthorizedMissingPath(resolvedTarget, store, allowedRoots)
}
throw error
}
const candidateTarget = resolve(realParent, basename(resolvedTarget))
if (
!(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, {
canonicalSourcePath: resolvedTarget
canonicalSourcePath: resolvedTarget,
allowedRoots
}))
) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
@@ -100,7 +123,8 @@ export async function resolveAuthorizedPath(
const realTarget = resolve(await realpath(resolvedTarget))
if (
!(await isPathAllowedIncludingRegisteredWorktrees(realTarget, store, {
canonicalSourcePath: resolvedTarget
canonicalSourcePath: resolvedTarget,
allowedRoots
}))
) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
@@ -110,11 +134,15 @@ export async function resolveAuthorizedPath(
if (!isENOENT(error)) {
throw error
}
return resolveAuthorizedMissingPath(resolvedTarget, store)
return resolveAuthorizedMissingPath(resolvedTarget, store, allowedRoots)
}
}
async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store): Promise<string> {
async function resolveAuthorizedMissingPath(
resolvedTarget: string,
store: Store,
allowedRoots: AllowedRootsSnapshot
): Promise<string> {
let existingAncestor = resolvedTarget
const missingSegments: string[] = []
@@ -124,7 +152,8 @@ async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store
const candidateTarget = resolve(realAncestor, ...missingSegments)
if (
!(await isPathAllowedIncludingRegisteredWorktrees(candidateTarget, store, {
canonicalSourcePath: resolvedTarget
canonicalSourcePath: resolvedTarget,
allowedRoots
}))
) {
throw new Error(PATH_ACCESS_DENIED_MESSAGE)
@@ -148,9 +177,9 @@ async function resolveAuthorizedMissingPath(resolvedTarget: string, store: Store
async function isPathAllowedIncludingRegisteredWorktrees(
targetPath: string,
store: Store,
options: { canonicalSourcePath?: string } = {}
options: { canonicalSourcePath?: string; allowedRoots?: AllowedRootsSnapshot } = {}
): Promise<boolean> {
if (isPathAllowed(targetPath, store)) {
if (isPathAllowed(targetPath, store, options.allowedRoots)) {
return true
}
@@ -158,7 +187,14 @@ async function isPathAllowedIncludingRegisteredWorktrees(
return true
}
if (await isPathAllowedByCanonicalAllowedRoot(targetPath, options.canonicalSourcePath, store)) {
if (
await isPathAllowedByCanonicalAllowedRoot(
targetPath,
options.canonicalSourcePath,
store,
options.allowedRoots
)
) {
return true
}
@@ -178,12 +214,13 @@ async function isPathAllowedIncludingRegisteredWorktrees(
async function isPathAllowedByCanonicalAllowedRoot(
targetPath: string,
sourcePath: string | undefined,
store: Store
store: Store,
allowedRoots?: AllowedRootsSnapshot
): Promise<boolean> {
if (!sourcePath) {
return false
}
for (const root of getAllowedRoots(store)) {
for (const root of allowedRoots?.get() ?? getAllowedRoots(store)) {
const resolvedRoot = resolve(root)
if (!isDescendantOrEqual(sourcePath, resolvedRoot)) {
continue
+6 -1
View File
@@ -102,7 +102,12 @@ export function getWorktreeMirrorDistro(
store: ProjectRuntimeResolutionStore,
repo: Repo
): string | undefined {
const projectRuntime = resolveLocalProjectRuntimeForRepo(store, repo)
return getWorktreeMirrorDistroForRuntime(resolveLocalProjectRuntimeForRepo(store, repo))
}
export function getWorktreeMirrorDistroForRuntime(
projectRuntime: ProjectExecutionRuntimeResolution | undefined
): string | undefined {
if (!projectRuntime || projectRuntime.status !== 'resolved') {
return undefined
}
+19 -4
View File
@@ -109,10 +109,12 @@ export function clearMissingProjectGroupMemberships(repos: Repo[], groups: Proje
)
}
export function getProjectGroupSubtreeIds(
groups: readonly Pick<ProjectGroup, 'id' | 'parentGroupId'>[],
rootGroupId: string
): Set<string> {
export type ProjectGroupChildIndex = ReadonlyMap<string, string[]>
/** Build once and reuse when collecting subtrees for more than one root. */
export function buildProjectGroupChildIndex(
groups: readonly Pick<ProjectGroup, 'id' | 'parentGroupId'>[]
): ProjectGroupChildIndex {
const childGroupsByParentId = new Map<string, string[]>()
for (const group of groups) {
if (!group.parentGroupId) {
@@ -122,7 +124,20 @@ export function getProjectGroupSubtreeIds(
children.push(group.id)
childGroupsByParentId.set(group.parentGroupId, children)
}
return childGroupsByParentId
}
export function getProjectGroupSubtreeIds(
groups: readonly Pick<ProjectGroup, 'id' | 'parentGroupId'>[],
rootGroupId: string
): Set<string> {
return collectProjectGroupSubtreeIds(buildProjectGroupChildIndex(groups), rootGroupId)
}
export function collectProjectGroupSubtreeIds(
childGroupsByParentId: ProjectGroupChildIndex,
rootGroupId: string
): Set<string> {
const subtreeIds = new Set<string>()
const pending = [rootGroupId]
while (pending.length > 0) {