Files
orca/src/main/ipc/filesystem-path-containment-unicode-normalization.test.ts
T
Neil 7980ab9942 test: remove mock echoes and duplicate contracts that only reading finds (#23953)
* test: remove mock echoes and duplicate contracts that only reading finds

Two veins in one wave, both requiring the production path to be read rather than
pattern-matched.

Mock echoes (36 strongest candidates reviewed, 2 real): the flagged shape —
literals shared between a mock factory and an expect matcher — is almost always
a test feeding an input and asserting a transform. The two genuine echoes are in
`pty-management.test.ts`, where the handler returns
`getDaemonFolderAccessMismatch(identity)` verbatim, so asserting the mock's own
`evidence('allowed')` object and its `null` proved only the mock. One case's own
comment conceded the handler makes no decision. The branch-exercising cases in
that file stay.

Semantic sweep of 80 files no detector flagged, 27 junk cases removed. What it
found has no mechanical signature:
- a handler that is literally `() => getComputerUsePermissionStatus()`, so
  `resolves.toBe(result)` guarded nothing;
- "does not mutate a stale registration off Linux" whose refusal came from a
  DIFFERENT guard — `cli.ts` has no platform check, and the test's own mock made
  `resolveAppImageRuntimeIdentity` return null, which a sibling case already owns;
- "keeps probing a host that is still retained" asserting a no-op, because
  `retainScopes` only cancels queued probes and stores no state;
- two cases comparing against `referenceAllowedRoots`, a verbatim copy of the
  pre-change algorithm kept in the test file — expected values produced by the
  thing under test. The third such case stays: it calls the old algorithm to
  COUNT its work (100_000 containment checks vs 100), a real bound on the
  authorization hot path;
- a remote-folder refusal that came from the fake provider's own rejection
  message rather than any Orca guard;
- "advertises each capability once", where a duplicate entry is inert in
  production because membership is `includes`;
- an Antigravity `scaffold self-check` built on a hand-typed five-line screen —
  the exact fixture shape docs/reference/antigravity-readiness-evidence.md blames
  for five failed detector attempts — whose banner had already drifted to
  `Antigravity CLI 1.0.3` against a real captured `1.2.0`. The raw-capture
  provenance guard and the transcript checklist ratchet in that file stay.

No production file is touched and no test file is deleted.

* test: retire duplicate daemon and filesystem cases the sweep found

Continues the semantic sweep into src/main/ipc filesystem handlers and
src/main/daemon. 16 cases removed across 13 files; no production file touched.

The recurring shape is a case that reaches the same branch as its sibling by a
different-looking route:
- `parseArgs` is a flag-scanning loop, so "handles flags in any order" asserts the
  identical result object as the in-order case, and "throws with no args" lands on
  the same `Usage:` throw the two missing-flag cases already reach;
- an ENOENT case with "no code at all" and its sibling with a numeric transport
  code both fall through to the same `ENOENT_MESSAGE.test` branch;
- "establishes connection with hello handshake" and "receives stream events" are
  strict subsets of cases that require two matching authenticated sockets and a
  frame split inside a multibyte character.

Two were vacuous rather than duplicated: a fixture self-comparison asserting
`String.normalize` gives different NFC and NFD spellings, and a
`not.toThrow` batch case whose 200k events are truncated by
`MAX_BATCHED_WATCHER_EVENTS = 5_000` long before the argument spread it was
written to exercise.

One whole-file deletion was reversed: `freebuff-detection.test.ts` looked like a
table restatement, but the sibling detection test covers only dependency ordering
and platform gating, not freebuff/codebuff independence — and those two names
share a suffix, so it is the only guard against one shadowing the other.
2026-09-29 17:35:12 -07:00

200 lines
7.2 KiB
TypeScript

import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { Store } from '../persistence'
import type { Repo } from '../../shared/repo-types'
import { PATH_ACCESS_DENIED_MESSAGE, resolveAuthorizedPath } from './filesystem-auth'
import { isDescendantOrEqual } from './filesystem-path-containment'
/**
* Opening any file under a Korean-named workspace failed on macOS with
* "Access denied: path resolves outside allowed directories" (#21172).
*
* The file was inside the workspace the whole time. The two sides of the containment check reached
* it through different doors: the root was registered from the file picker or from git, which spell
* the name in NFC, while the path being read came back from the filesystem in NFD. Same name, same
* file on APFS, different bytes — so the guard reported an escape.
*
* "Same file" is the load-bearing half, so it is the half that gets proven: both spellings reaching
* one directory authorize the file, two distinct directories — which ext4 allows and APFS does not
* — leave the unregistered one denied. A symlink stands in for the APFS fold on a byte-exact host.
*/
const FOLDER = '테스트프로젝트'
const NFC_FOLDER = FOLDER.normalize('NFC')
const NFD_FOLDER = FOLDER.normalize('NFD')
const scratchDirs: string[] = []
async function makeScratchDir(): Promise<string> {
// realpath first: macOS fronts the temp dir with a /var symlink of its own.
const scratch = await mkdtemp(join(await realpath(tmpdir()), 'orca-unicode-path-'))
scratchDirs.push(scratch)
return scratch
}
function isEEXIST(error: unknown): boolean {
return error instanceof Error && 'code' in error && error.code === 'EEXIST'
}
/** One directory, both spellings. EEXIST means the filesystem already folded them itself. */
async function makeOneDirectoryTwoSpellings(
scratch: string
): Promise<{ onDisk: string; registered: string }> {
const onDisk = join(scratch, NFD_FOLDER)
const registered = join(scratch, NFC_FOLDER)
await mkdir(onDisk)
try {
await symlink(onDisk, registered, 'dir')
} catch (error) {
if (!isEEXIST(error)) {
throw error
}
}
return { onDisk, registered }
}
/** Two canonically equal names as two distinct directories; null where the filesystem folds them. */
async function makeDistinctSiblings(
scratch: string
): Promise<{ registered: string; sibling: string } | null> {
const registered = join(scratch, NFC_FOLDER)
const sibling = join(scratch, NFD_FOLDER)
await mkdir(registered)
try {
await mkdir(sibling)
} catch (error) {
if (isEEXIST(error)) {
return null
}
throw error
}
return { registered, sibling }
}
function makeStore(repoPath: string): Store {
const repo: Repo = {
id: 'repo-1',
path: repoPath,
displayName: 'workspace',
badgeColor: '#000000',
addedAt: 1,
kind: 'git'
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the guard reads only these four accessors; Store is a class, so a structural double cannot satisfy it without the cast.
return {
getRepos: () => [repo],
getProjectGroups: () => [],
getFolderWorkspaces: () => [],
getSettings: () => ({})
} as unknown as Store
}
afterEach(async () => {
await Promise.all(scratchDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
})
describe('path containment across Unicode forms', () => {
it('accepts a child of a root the filesystem spells the other way', async () => {
const scratch = await makeScratchDir()
const { onDisk, registered } = await makeOneDirectoryTwoSpellings(scratch)
expect(isDescendantOrEqual(join(onDisk, 'test.txt'), registered)).toBe(true)
})
it('accepts the root itself under its other spelling', async () => {
const scratch = await makeScratchDir()
const { onDisk, registered } = await makeOneDirectoryTwoSpellings(scratch)
expect(isDescendantOrEqual(onDisk, registered)).toBe(true)
})
it('denies a canonically equal sibling that is a distinct directory', async () => {
const scratch = await makeScratchDir()
const siblings = await makeDistinctSiblings(scratch)
if (!siblings) {
// The filesystem folds the two names, so there is no second directory to reach.
return
}
expect(
isDescendantOrEqual(join(siblings.sibling, 'test.txt'), siblings.registered),
'the sibling is a different directory; the user opened only the registered one'
).toBe(false)
})
it('denies a fold it cannot check against the filesystem', () => {
// Neither spelling exists on disk, so identity is unproven — and unproven is denied.
expect(
isDescendantOrEqual(resolve(`/repos/${NFD_FOLDER}/test.txt`), resolve(`/repos/${NFC_FOLDER}`))
).toBe(false)
})
it('still rejects a sibling that only looks similar', () => {
// 테스트 is a prefix of 테스트프로젝트, not a canonical equivalent of it.
expect(
isDescendantOrEqual(resolve('/repos/테스트/test.txt'), resolve(`/repos/${NFC_FOLDER}`))
).toBe(false)
})
it('still rejects an escape out of a non-ASCII root', () => {
expect(
isDescendantOrEqual(
resolve(`/repos/${NFD_FOLDER}/../secrets`),
resolve(`/repos/${NFC_FOLDER}`)
)
).toBe(false)
})
it('leaves ASCII containment exactly as it was', () => {
expect(isDescendantOrEqual(resolve('/repos/app/src'), resolve('/repos/app'))).toBe(true)
expect(isDescendantOrEqual(resolve('/repos/apple'), resolve('/repos/app'))).toBe(false)
expect(isDescendantOrEqual(resolve('/repos/app'), resolve('/repos/app'))).toBe(true)
})
})
describe('fs:readFile authorization for a Korean-named workspace', () => {
it('authorizes a file the filesystem spells the other way', async () => {
const scratch = await makeScratchDir()
const { onDisk, registered } = await makeOneDirectoryTwoSpellings(scratch)
const file = join(onDisk, 'test.txt')
await writeFile(file, 'hello')
const store = makeStore(registered)
// Resolved before the assertion so a rejection lands on expect(), not on an unawaited promise.
const expected = await realpath(file)
await expect(
resolveAuthorizedPath(file, store),
'the file is inside the opened workspace; only its spelling differs'
).resolves.toBe(expected)
})
it('denies a canonically equal sibling the user never opened', async () => {
const scratch = await makeScratchDir()
const siblings = await makeDistinctSiblings(scratch)
if (!siblings) {
return
}
const file = join(siblings.sibling, 'test.txt')
await writeFile(file, 'secret')
const store = makeStore(siblings.registered)
await expect(resolveAuthorizedPath(file, store)).rejects.toThrow(PATH_ACCESS_DENIED_MESSAGE)
})
it('still denies a file outside the workspace', async () => {
const scratch = await makeScratchDir()
await makeOneDirectoryTwoSpellings(scratch)
const outside = join(scratch, 'outside.txt')
await writeFile(outside, 'secret')
const store = makeStore(join(scratch, NFC_FOLDER))
await expect(resolveAuthorizedPath(outside, store)).rejects.toThrow(PATH_ACCESS_DENIED_MESSAGE)
})
})