mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 09:37:33 +00:00
* fix(files): never write through a dangling symlink A path that doesn't exist yet was authorized by walking up to the nearest existing folder. A dangling symlink fails realpath with ENOENT, so the walk stepped over it and approved the link's own path; the write then followed the link and created its target outside the allowed folders. The walk now continues from the link's target, and returns that real destination. * fix(files): refuse any dangling symlink in the missing-path walk Following a dangling link by hand resolved relative targets against the lexical parent, not the link's real folder: through a folder link the walk could loop forever or authorize the wrong file. Refuse instead: realpath can't say where a dangling link leads, but a write would follow it. * test(files): drop the loop case that duplicated the dangling-link refusal
425 lines
15 KiB
TypeScript
425 lines
15 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 { 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 paths through a symlink out of the repo, and any dangling symlink',
|
|
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')
|
|
await symlink(join(outsidePath, 'new.txt'), join(repoPath, 'dangling.txt'))
|
|
await symlink(join(repoPath, 'later.txt'), join(repoPath, 'inside.txt'))
|
|
const store = makeStore([{ ...repo, id: 'repo-temp', path: repoPath }])
|
|
|
|
await expect(
|
|
resolveAuthorizedPath(join(repoPath, 'linked-outside', 'new', 'file.ts'), store)
|
|
).rejects.toThrow('Access denied')
|
|
// A write follows a dangling link wherever it points, so any dangling link is refused.
|
|
for (const name of ['dangling.txt', 'inside.txt']) {
|
|
await expect(resolveAuthorizedPath(join(repoPath, name), 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()
|
|
}
|
|
})
|
|
})
|