Files
orca/src/main/ipc/filesystem-auth.test.ts
T
Neil a7db6c336b perf(git): skip the sparse probe for worktree listings that never read it (#18050)
Three main-process call sites list a repo's worktrees to read `worktree.path`
and nothing else, but went through the annotated listing, so each one paid a
sparse-checkout probe per worktree and cached the result nobody consumed:

- `registered-worktree-roots-cache.ts` rebuilds the filesystem-auth authorized
  roots. `invalidateAuthorizedRootsCache()` fires on every worktree create and
  remove, plus repo add/clone/settings changes, so this reruns constantly.
- `filesystem-source-control-ai-targets.ts` checks whether a local repo owns a
  worktree path.
- `hosted-review.ts` verifies a worktree belongs to the repo before granting
  access.

The probe is an `fs.stat` of the per-worktree `info/sparse-checkout` plus, when
that file is non-empty, a git config read. On a WSL-hosted repo both cross 9p.
#17859 cached it and #17932 keyed that cache on the distro, which fixed a wrong
answer but also meant the distro-less callers above populate a second entry per
worktree — probed cold, revalidated on their own five-minute loop, and read by
nobody. Worktree create/remove clears the sparse cache and dirties the roots
cache together, so both variants go cold at once and the discarded half is
re-probed in full on the next auth check.

`listRepoWorktreeGraph` routes those callers to `listWorktreeGraph`, which
already existed as the annotation-free listing (#17655).

Doing only that would have cost a second `git worktree list`. The scan cache
keys in-flight scans on a `kind`, and graph and lenient were separate kinds, so
a roots rebuild overlapping a sidebar refresh would spawn its own subprocess
where the two previously coalesced. That is a real regression on macOS, Linux
and native Windows, where `getLocalProjectWorktreeGitOptions` returns `{}` and
both callers land on the identical key; on WSL they already differ by distro and
never shared.

So the annotated listing is now the graph listing plus annotation, rather than a
parallel scan of its own: `listWorktrees` awaits `listWorktreeGraph` and
annotates the rows it returns. Both soften a Git failure to `[]`, so they can
share one listing; strict keeps its own because it must be able to reject. The
two kinds ran Git twice before and now run it once, so the overlap case gets
strictly faster instead of paying for the opt-out.

An annotated scan holds two in-flight entries now (its own, plus the graph
listing it shares). Keeping its own entry matters: `detectSparseCheckoutCached`
dedupes revalidation but not the initial fill, so two concurrent badge readers
sharing only the graph scan would both probe.

Per-platform delta:
- macOS/Linux: fewer probes on the three call sites; one `git worktree list`
  instead of two when a graph and an annotated scan overlap.
- native Windows, no WSL: same, and the saved subprocess is the expensive half.
- Windows + WSL: the largest win. The discarded probes were 9p round-trips
  re-paid cold after every worktree create/remove.
- SSH/relay: none. `listRepoWorktreeGraph` returns through the same provider
  branch as `listRepoWorktrees` before reaching local Git.
- folder workspaces: none. Both return the same synthetic folder worktree.

Not in this change:
- The badge listing itself. It still probes, still annotates, and still keys on
  the distro exactly as #17932 left it.
- The remaining `listRepoWorktrees` callers. They read `isSparse`, or feed rows
  to something that does.
2026-09-01 20:19:56 -07:00

461 lines
16 KiB
TypeScript

import type * as NodePath from 'node:path'
import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { 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 { FolderWorkspace } from '../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../shared/project-group-types'
import type { Repo } from '../../shared/repo-types'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import {
AUTHORIZED_EXTERNAL_PATHS_MAX,
authorizeExternalPath,
isPathAllowed,
resolveAuthorizedPath
} from './filesystem-auth'
import { isDescendantOrEqual, validateGitRelativeFilePath } from './filesystem-path-containment'
import {
__resetCreatedWorktreeRootsForTests,
invalidateAuthorizedRootsCache,
rebuildAuthorizedRootsCache,
registerCreatedWorktreeRoot,
resolveRegisteredWorktreePath
} from './registered-worktree-roots-cache'
vi.mock('../repo-worktrees', async () => {
const actual = await vi.importActual<typeof RepoWorktrees>('../repo-worktrees')
return {
...actual,
listRepoWorktreeGraph: vi.fn()
}
})
const LARGE_WORKTREE_ROOT_COUNT = 150_000
const repo: Repo = {
id: 'repo-1',
path: '/repos/app',
displayName: 'app',
badgeColor: '#000000',
addedAt: 1,
kind: 'git'
}
function makeProjectGroup(overrides: Partial<ProjectGroup> = {}): ProjectGroup {
return {
id: 'group-1',
name: 'Workspace',
parentPath: '/folders/workspace',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): FolderWorkspace {
return {
id: 'folder-workspace-1',
projectGroupId: 'group-1',
name: 'Feature',
folderPath: '/folders/workspace',
comment: '',
linkedTask: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1,
...overrides
}
}
function makeStore(
repos: Repo[] = [repo],
options: {
projectGroups?: ProjectGroup[]
folderWorkspaces?: FolderWorkspace[]
} = {}
): Store {
return {
getRepos: () => repos,
getProjectGroups: () => options.projectGroups ?? [],
getFolderWorkspaces: () => options.folderWorkspaces ?? [],
getSettings: () => ({})
} as unknown as Store
}
describe('filesystem auth worktree roots', () => {
beforeEach(() => {
invalidateAuthorizedRootsCache()
__resetCreatedWorktreeRootsForTests()
vi.mocked(listRepoWorktreeGraph).mockReset()
})
it('rebuilds the authorized roots cache for large worktree lists', async () => {
const worktrees: GitWorktreeInfo[] = Array.from(
{ length: LARGE_WORKTREE_ROOT_COUNT },
(_, index) => ({
path: `/linked/worktree-${index}`,
head: '',
branch: `refs/heads/generated-${index}`,
isBare: false,
isMainWorktree: false
})
)
vi.mocked(listRepoWorktreeGraph).mockResolvedValue(worktrees)
const store = makeStore()
await rebuildAuthorizedRootsCache(store)
const lastWorktreePath = `/linked/worktree-${LARGE_WORKTREE_ROOT_COUNT - 1}`
await expect(resolveRegisteredWorktreePath(lastWorktreePath, store)).resolves.toBe(
resolve(lastWorktreePath)
)
expect(listRepoWorktreeGraph).toHaveBeenCalledTimes(1)
})
it("keeps a repo's roots when its listing fails mid-rebuild", async () => {
// Why: the rebuild runs while Git is still broken, and dropping the roots would revoke
// a worktree a create just recovered without a listing (#16520).
const store = makeStore()
registerCreatedWorktreeRoot(store, repo.id, '/linked/recovered')
vi.mocked(listRepoWorktreeGraph).mockRejectedValue(new Error('git worktree list failed.'))
await rebuildAuthorizedRootsCache(store)
await expect(resolveRegisteredWorktreePath('/linked/recovered', store)).resolves.toBe(
resolve('/linked/recovered')
)
})
it('keeps a recovered root when a healthy rebuild still cannot list it', async () => {
// Why: the rebuild recomputes from the very listing that omitted the row, so replacing the cache
// would re-deny the worktree the create just recovered (#16520).
const scratch = await mkdtemp(join(await realpath(tmpdir()), 'orca-recovered-'))
const recovered = join(scratch, 'feature')
await mkdir(recovered)
const store = makeStore()
registerCreatedWorktreeRoot(store, repo.id, recovered)
vi.mocked(listRepoWorktreeGraph).mockResolvedValue([])
await rebuildAuthorizedRootsCache(store)
await expect(resolveRegisteredWorktreePath(recovered, store)).resolves.toBe(resolve(recovered))
await rm(scratch, { recursive: true, force: true })
})
it('survives a rebuild that was already in flight when the create recovered', async () => {
const scratch = await mkdtemp(join(await realpath(tmpdir()), 'orca-recovered-race-'))
const recovered = join(scratch, 'feature')
await mkdir(recovered)
const store = makeStore()
// Register mid-listing: the rebuild's own result was computed before this worktree existed.
vi.mocked(listRepoWorktreeGraph).mockImplementation(async () => {
registerCreatedWorktreeRoot(store, repo.id, recovered)
return []
})
await rebuildAuthorizedRootsCache(store)
await expect(resolveRegisteredWorktreePath(recovered, store)).resolves.toBe(resolve(recovered))
await rm(scratch, { recursive: true, force: true })
})
it('retires a recovered root once the listing can see it again', async () => {
const store = makeStore()
registerCreatedWorktreeRoot(store, repo.id, '/linked/feature')
vi.mocked(listRepoWorktreeGraph).mockResolvedValue([
{
path: '/linked/feature',
head: '',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false
}
])
await rebuildAuthorizedRootsCache(store)
// Still authorized, but now from the listing itself; a later listing that drops it is authoritative.
await expect(resolveRegisteredWorktreePath('/linked/feature', store)).resolves.toBe(
resolve('/linked/feature')
)
vi.mocked(listRepoWorktreeGraph).mockResolvedValue([])
await rebuildAuthorizedRootsCache(store)
await expect(resolveRegisteredWorktreePath('/linked/feature', store)).rejects.toThrow(
'Access denied'
)
})
it('bounds concurrent repo probes while rebuilding authorized roots', async () => {
const repos = Array.from({ length: 20 }, (_, index) => ({
...repo,
id: `repo-${index}`,
path: `/repos/app-${index}`
}))
let active = 0
let maxActive = 0
vi.mocked(listRepoWorktreeGraph).mockImplementation(async () => {
active += 1
maxActive = Math.max(maxActive, active)
await new Promise((resolve) => setTimeout(resolve, 1))
active -= 1
return []
})
await rebuildAuthorizedRootsCache(makeStore(repos))
expect(listRepoWorktreeGraph).toHaveBeenCalledTimes(repos.length)
expect(maxActive).toBeLessThanOrEqual(8)
})
})
describe('filesystem-auth path containment', () => {
it('authorizes missing nested descendants under an allowed repo', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-missing-'))
try {
const repoPath = join(tempRoot, 'repo')
await mkdir(repoPath)
const store = makeStore([{ ...repo, id: 'repo-temp', path: repoPath }])
const targetPath = join(repoPath, 'new', 'nested', 'file.ts')
await expect(resolveAuthorizedPath(targetPath, store)).resolves.toBe(
join(await realpath(repoPath), 'new', 'nested', 'file.ts')
)
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('authorizes local folder workspace roots outside child repo roots', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-folder-workspace-'))
try {
const folderPath = join(tempRoot, 'platform')
const repoPath = join(folderPath, 'web')
await mkdir(repoPath, { recursive: true })
const projectGroup = makeProjectGroup({ parentPath: folderPath })
const folderWorkspace = makeFolderWorkspace({ folderPath, projectGroupId: projectGroup.id })
const store = makeStore([{ ...repo, id: 'repo-temp', path: repoPath }], {
projectGroups: [projectGroup],
folderWorkspaces: [folderWorkspace]
})
await expect(resolveAuthorizedPath(folderPath, store)).resolves.toBe(
await realpath(folderPath)
)
await expect(resolveAuthorizedPath(join(folderPath, 'notes.md'), store)).resolves.toBe(
join(await realpath(folderPath), 'notes.md')
)
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('authorizes local folder-backed project group roots outside child repo roots', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-project-group-'))
try {
const folderPath = join(tempRoot, 'platform')
const repoPath = join(folderPath, 'web')
await mkdir(repoPath, { recursive: true })
const projectGroup = makeProjectGroup({ parentPath: folderPath })
const store = makeStore([{ ...repo, id: 'repo-temp', path: repoPath }], {
projectGroups: [projectGroup]
})
await expect(resolveAuthorizedPath(folderPath, store)).resolves.toBe(
await realpath(folderPath)
)
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('does not authorize SSH-only folder workspace roots as local paths', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-remote-folder-workspace-'))
try {
const folderPath = join(tempRoot, 'remote-platform')
const repoPath = join(folderPath, 'web')
await mkdir(repoPath, { recursive: true })
const projectGroup = makeProjectGroup({ parentPath: folderPath })
const folderWorkspace = makeFolderWorkspace({ folderPath, projectGroupId: projectGroup.id })
const store = makeStore(
[{ ...repo, id: 'repo-temp', path: repoPath, connectionId: 'ssh-1' }],
{
projectGroups: [projectGroup],
folderWorkspaces: [folderWorkspace]
}
)
await expect(resolveAuthorizedPath(folderPath, store)).rejects.toThrow('Access denied')
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('does not authorize repo-less SSH-provenance folder roots as local paths', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-remote-folder-provenance-'))
try {
const folderPath = join(tempRoot, 'remote-platform')
await mkdir(folderPath, { recursive: true })
const projectGroup = makeProjectGroup({ parentPath: folderPath, connectionId: 'ssh-1' })
const folderWorkspace = makeFolderWorkspace({
folderPath,
projectGroupId: projectGroup.id,
connectionId: 'ssh-1'
})
const store = makeStore([], {
projectGroups: [projectGroup],
folderWorkspaces: [folderWorkspace]
})
await expect(resolveAuthorizedPath(folderPath, store)).rejects.toThrow('Access denied')
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it('does not authorize SSH-only folder-backed project group roots as local paths', async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-remote-project-group-'))
try {
const folderPath = join(tempRoot, 'remote-platform')
const repoPath = join(folderPath, 'web')
await mkdir(repoPath, { recursive: true })
const projectGroup = makeProjectGroup({ parentPath: folderPath })
const store = makeStore(
[{ ...repo, id: 'repo-temp', path: repoPath, connectionId: 'ssh-1' }],
{
projectGroups: [projectGroup]
}
)
await expect(resolveAuthorizedPath(folderPath, store)).rejects.toThrow('Access denied')
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
})
it.skipIf(process.platform === 'win32')(
'rejects missing descendants under a symlinked ancestor outside the repo',
async () => {
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-symlink-'))
try {
const repoPath = join(tempRoot, 'repo')
const outsidePath = join(tempRoot, 'outside')
await mkdir(repoPath)
await mkdir(outsidePath)
await symlink(outsidePath, join(repoPath, 'linked-outside'), 'dir')
const store = makeStore([{ ...repo, id: 'repo-temp', path: repoPath }])
await expect(
resolveAuthorizedPath(join(repoPath, 'linked-outside', 'new', 'file.ts'), store)
).rejects.toThrow('Access denied')
} finally {
await rm(tempRoot, { recursive: true, force: true })
}
}
)
it('allows descendants whose path segment starts with dotdot characters', () => {
const root = resolve('/workspace/repo')
const child = resolve('/workspace/repo/..fixtures/file.ts')
expect(isDescendantOrEqual(child, root)).toBe(true)
})
it('allows git-relative files under dotdot-prefixed child directories', () => {
expect(validateGitRelativeFilePath(resolve('/workspace/repo'), '..fixtures/file.ts')).toBe(
join('..fixtures', 'file.ts')
)
})
it('still rejects parent-directory escapes', () => {
const root = resolve('/workspace/repo')
const outside = resolve('/workspace/repo/../other/file.ts')
expect(isDescendantOrEqual(outside, root)).toBe(false)
expect(() => validateGitRelativeFilePath(root, '../other/file.ts')).toThrow(
'Access denied: git file path escapes the selected worktree'
)
})
it('accepts Windows descendants when drive and root casing differ', async () => {
vi.resetModules()
vi.doMock('../repo-worktrees', () => ({
isRepoRoot: vi.fn(),
listRepoWorktreeGraph: vi.fn()
}))
vi.doMock('path', async () => {
const path = await vi.importActual<typeof NodePath>('node:path')
return {
...path.win32,
default: path.win32
}
})
try {
const { isDescendantOrEqual: isDescendantOrEqualWithWinPath } =
await import('./filesystem-path-containment')
expect(
isDescendantOrEqualWithWinPath(String.raw`c:\repo\src\app.ts`, String.raw`C:\Repo`)
).toBe(true)
expect(
isDescendantOrEqualWithWinPath(String.raw`D:\repo\src\app.ts`, String.raw`C:\Repo`)
).toBe(false)
} finally {
vi.doUnmock('path')
vi.doUnmock('../repo-worktrees')
vi.resetModules()
}
})
})
describe('filesystem-auth authorized external path bound', () => {
// Empty allow-list store, so a path is allowed only if it (or an ancestor) is
// in the session-authorized external-path set.
const emptyStore = makeStore([])
const flood = (n: number): string =>
resolve(`/leak-audit-ext/flood-${String(n).padStart(6, '0')}`)
it('bounds the authorized external path set with LRU eviction', () => {
const keep = resolve('/leak-audit-ext/keep')
authorizeExternalPath(keep)
// Flood past the cap with distinct external paths, re-authorizing `keep`
// periodically so LRU keeps it hot.
const total = AUTHORIZED_EXTERNAL_PATHS_MAX + 200
for (let i = 0; i < total; i += 1) {
authorizeExternalPath(flood(i))
if (i % 250 === 0) {
authorizeExternalPath(keep)
}
}
// The oldest never-re-touched entries fell out of the bounded set...
expect(isPathAllowed(flood(0), emptyStore)).toBe(false)
// ...while the periodically re-authorized path and the most recent survive.
expect(isPathAllowed(keep, emptyStore)).toBe(true)
expect(isPathAllowed(flood(total - 1), emptyStore)).toBe(true)
})
it('re-authorizes an evicted path on next use (self-healing)', () => {
const path = resolve('/leak-audit-ext/evicted-then-reused')
for (let i = 0; i < AUTHORIZED_EXTERNAL_PATHS_MAX + 50; i += 1) {
authorizeExternalPath(flood(100_000 + i))
}
expect(isPathAllowed(path, emptyStore)).toBe(false)
authorizeExternalPath(path)
expect(isPathAllowed(path, emptyStore)).toBe(true)
})
})