mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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'
|
||||
)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user