mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
Fix fsync of read-only files on POSIX (#14235)
* fix(files): fsync read-only files on POSIX * test(e2e): add golden E2E tests for POSIX profile index fsync Validates that profile index files are properly persisted on POSIX systems, including with restrictive umask settings. These are release-blocking golden tests for Linux and macOS. * test(terminal): wait for fish child ownership before stdin write Fish 4.8 withdraws DECSET 2031 before spawning the child, so the shell-contracts harness could send hello into an intermediate prompt and hang waiting for CHILD-READ. Wait for the child's CHILD-READY marker and answer split DA1/CPR/OSC queries across chunk boundaries. * test(e2e): verify profile index persists to disk with restrictive umask Strengthen the POSIX fsync test to verify the rebuilt index is actually written to disk and has correct permissions under a restrictive umask, not just cached in memory.
This commit is contained in:
@@ -66,12 +66,14 @@ jobs:
|
||||
run: |
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden
|
||||
|
||||
- name: Run golden E2E tests on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e -- tests/e2e/golden-core-flows.spec.ts
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden
|
||||
|
||||
- name: Run golden E2E tests on Windows
|
||||
if: runner.os == 'Windows'
|
||||
|
||||
@@ -783,7 +783,7 @@ jobs:
|
||||
{
|
||||
echo "## Release E2E Signal"
|
||||
echo ""
|
||||
echo "- Platform golden E2E is release-blocking: terminal rendering on Linux/macOS and fresh startup on Windows."
|
||||
echo "- Platform golden E2E is release-blocking: terminal rendering and restrictive-umask profile writes on Linux/macOS, plus fresh startup on Windows."
|
||||
echo "- Full E2E runs separately after publication and cannot change the release result."
|
||||
echo "- Terminal rendering release evidence is diagnostic/non-blocking."
|
||||
echo ""
|
||||
@@ -871,11 +871,15 @@ jobs:
|
||||
|
||||
- name: Run terminal rendering golden on Linux
|
||||
if: runner.os == 'Linux'
|
||||
run: xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
run: |
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden
|
||||
|
||||
- name: Run terminal rendering golden on macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
run: |
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden
|
||||
env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:posix-profile-index-golden
|
||||
|
||||
- name: Run fresh-startup golden on Windows
|
||||
if: runner.os == 'Windows'
|
||||
|
||||
@@ -80,6 +80,7 @@ describe('Electron runtime package contract', () => {
|
||||
'build:linux',
|
||||
'test:e2e',
|
||||
'test:e2e:terminal-rendering-golden',
|
||||
'test:e2e:posix-profile-index-golden',
|
||||
'test:e2e:terminal-rendering-release-evidence',
|
||||
'test:e2e:headful'
|
||||
]
|
||||
@@ -543,6 +544,15 @@ describe('Electron runtime package contract', () => {
|
||||
expect(packageScripts['test:e2e:windows-fresh-startup-golden']).toContain(
|
||||
'@windows-fresh-startup-golden'
|
||||
)
|
||||
expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain(
|
||||
'golden-posix-profile-index-fsync.spec.ts'
|
||||
)
|
||||
expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain(
|
||||
'golden-posix-fresh-startup.spec.ts'
|
||||
)
|
||||
expect(packageScripts['test:e2e:posix-profile-index-golden']).toContain(
|
||||
'@posix-profile-index-golden'
|
||||
)
|
||||
expect(packageScripts['test:e2e:terminal-rendering-release-evidence']).toContain(
|
||||
'terminal-opencode-emoji-table-rendering.spec.ts'
|
||||
)
|
||||
@@ -557,7 +567,11 @@ describe('Electron runtime package contract', () => {
|
||||
expect(goldenRunSteps.get('linux')?.run).toContain(
|
||||
'pnpm run test:e2e:terminal-rendering-golden'
|
||||
)
|
||||
expect(goldenRunSteps.get('linux')?.run).toContain(
|
||||
'pnpm run test:e2e:posix-profile-index-golden'
|
||||
)
|
||||
expect(goldenRunSteps.get('mac')?.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
|
||||
expect(goldenRunSteps.get('mac')?.run).toContain('pnpm run test:e2e:posix-profile-index-golden')
|
||||
expect(goldenRunSteps.get('windows')).toMatchObject({
|
||||
if: "runner.os == 'Windows'",
|
||||
shell: 'pwsh'
|
||||
@@ -574,9 +588,16 @@ describe('Electron runtime package contract', () => {
|
||||
expect(publishReleaseNeeds).not.toContain('terminal-rendering-release-evidence')
|
||||
expect(releaseGoldenJob['continue-on-error']).toBeUndefined()
|
||||
expect(releaseGoldenMatrix).toEqual(goldenMatrix)
|
||||
expect(releaseGoldenJob.steps.map((step) => step.run ?? '')).toContain(
|
||||
'xvfb-run --auto-servernum env SKIP_BUILD=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm run test:e2e:terminal-rendering-golden'
|
||||
const releaseLinuxRunStep = releaseGoldenJob.steps.find(
|
||||
(step) => step.name === 'Run terminal rendering golden on Linux'
|
||||
)
|
||||
expect(releaseLinuxRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
|
||||
expect(releaseLinuxRunStep.run).toContain('pnpm run test:e2e:posix-profile-index-golden')
|
||||
const releaseMacRunStep = releaseGoldenJob.steps.find(
|
||||
(step) => step.name === 'Run terminal rendering golden on macOS'
|
||||
)
|
||||
expect(releaseMacRunStep.run).toContain('pnpm run test:e2e:terminal-rendering-golden')
|
||||
expect(releaseMacRunStep.run).toContain('pnpm run test:e2e:posix-profile-index-golden')
|
||||
const releaseWindowsRunStep = releaseGoldenJob.steps.find(
|
||||
(step) => step.name === 'Run fresh-startup golden on Windows'
|
||||
)
|
||||
|
||||
@@ -87,6 +87,7 @@
|
||||
"test:e2e:multi-client-navigation": "node config/scripts/run-multi-client-navigation-e2e.mjs",
|
||||
"test:e2e:floating-mobile-emulator": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/floating-mobile-emulator-tab.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"test:e2e:terminal-rendering-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts tests/e2e/terminal-webgl-atlas-budget.spec.ts --grep @terminal-rendering-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"test:e2e:posix-profile-index-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-posix-fresh-startup.spec.ts tests/e2e/golden-posix-profile-index-fsync.spec.ts --grep @posix-profile-index-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"test:e2e:windows-fresh-startup-golden": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/golden-windows-fresh-startup.spec.ts --grep @windows-fresh-startup-golden --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"test:e2e:terminal-rendering-release-evidence": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-opencode-emoji-table-rendering.spec.ts tests/e2e/terminal-long-table-scroll-restore.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
|
||||
"test:e2e:terminal-perf": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/terminal-typing-latency.spec.ts tests/e2e/terminal-foreground-redraw-freeze.spec.ts tests/e2e/terminal-output-scheduler.spec.ts tests/e2e/terminal-hidden-tui-visual-restore.spec.ts tests/e2e/artificial-opencode-terminal-load.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=2",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import {
|
||||
createDefaultLocalOrcaProfile,
|
||||
DEFAULT_LOCAL_ORCA_PROFILE_ID,
|
||||
DEFAULT_LOCAL_ORCA_PROFILE_NAME,
|
||||
ORCA_PROFILE_INDEX_SCHEMA_VERSION,
|
||||
@@ -154,6 +155,27 @@ describe('profile index store', () => {
|
||||
expect(() => setActiveOrcaProfile('missing-profile')).toThrow('unknown_orca_profile')
|
||||
})
|
||||
|
||||
const posixIt = process.platform === 'win32' ? it.skip : it
|
||||
posixIt('writes a fresh profile index when umask removes owner-write permission', async () => {
|
||||
const store = await loadProfileIndexStore()
|
||||
const indexPath = store.getOrcaProfileIndexPath()
|
||||
const profile = createDefaultLocalOrcaProfile(1)
|
||||
const index: OrcaProfileIndex = {
|
||||
schemaVersion: ORCA_PROFILE_INDEX_SCHEMA_VERSION,
|
||||
activeProfileId: profile.id,
|
||||
profiles: [profile]
|
||||
}
|
||||
const originalUmask = process.umask(0o200)
|
||||
|
||||
try {
|
||||
expect(() => store.writeProfileIndex(indexPath, index)).not.toThrow()
|
||||
} finally {
|
||||
process.umask(originalUmask)
|
||||
}
|
||||
|
||||
expect(readJson(indexPath)).toEqual(index)
|
||||
})
|
||||
|
||||
it('recovers a corrupted profile index from the backup copy', async () => {
|
||||
const store = await loadProfileIndexStore()
|
||||
store.ensureActiveOrcaProfile()
|
||||
|
||||
+131
-10
@@ -39,6 +39,20 @@ const COLOR_SCHEME_REPORT_PREFIX = '\x1b[?997'
|
||||
const ARM_2031 = '\x1b[?2031h'
|
||||
const WITHDRAW_2031 = '\x1b[?2031l'
|
||||
const LEAF_1 = '11111111-1111-4111-8111-111111111111'
|
||||
const DA1_REPLY = '\x1b[?62;4;6;22c'
|
||||
const DSR_REPLY = '\x1b[1;1R'
|
||||
const OSC_10_REPLY = '\x1b]10;rgb:eeee/eeee/eeee\x1b\\'
|
||||
const OSC_11_REPLY = '\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\'
|
||||
const TERMINAL_QUERY_REPLIES: readonly (readonly [string, string])[] = [
|
||||
['\x1b[0c', DA1_REPLY],
|
||||
['\x1b[c', DA1_REPLY],
|
||||
['\x1b[6n', DSR_REPLY],
|
||||
['\x1b]10;?\x07', OSC_10_REPLY],
|
||||
['\x1b]10;?\x1b\\', OSC_10_REPLY],
|
||||
['\x1b]11;?\x07', OSC_11_REPLY],
|
||||
['\x1b]11;?\x1b\\', OSC_11_REPLY]
|
||||
]
|
||||
const QUERY_CARRY_LENGTH = Math.max(...TERMINAL_QUERY_REPLIES.map(([query]) => query.length)) - 1
|
||||
|
||||
type MutableState = Record<string, unknown>
|
||||
let mockStoreState: MutableState = {}
|
||||
@@ -216,6 +230,114 @@ async function waitUntil(predicate: () => boolean, timeoutMs: number): Promise<b
|
||||
return false
|
||||
}
|
||||
|
||||
function createTerminalQueryResponder(write: (reply: string) => void): {
|
||||
accept: (chunk: string) => void
|
||||
getCarryLength: () => number
|
||||
} {
|
||||
let carry = ''
|
||||
return {
|
||||
accept: (chunk) => {
|
||||
const carriedLength = carry.length
|
||||
const scan = carry + chunk
|
||||
carry = scan.slice(-QUERY_CARRY_LENGTH)
|
||||
for (let at = 0; at < scan.length; at += 1) {
|
||||
for (const [query, reply] of TERMINAL_QUERY_REPLIES) {
|
||||
if (!scan.startsWith(query, at)) {
|
||||
continue
|
||||
}
|
||||
if (at + query.length > carriedLength) {
|
||||
write(reply)
|
||||
}
|
||||
at += query.length - 1
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
getCarryLength: () => carry.length
|
||||
}
|
||||
}
|
||||
|
||||
function allChunkPartitions(input: string): string[][] {
|
||||
if (input.length === 0) {
|
||||
return [[]]
|
||||
}
|
||||
const partitions: string[][] = []
|
||||
for (let end = 1; end <= input.length; end += 1) {
|
||||
for (const suffix of allChunkPartitions(input.slice(end))) {
|
||||
partitions.push([input.slice(0, end), ...suffix])
|
||||
}
|
||||
}
|
||||
return partitions
|
||||
}
|
||||
|
||||
function collectTerminalQueryReplies(chunks: readonly string[]): string[] {
|
||||
const replies: string[] = []
|
||||
const responder = createTerminalQueryResponder((reply) => replies.push(reply))
|
||||
chunks.forEach(responder.accept)
|
||||
return replies
|
||||
}
|
||||
|
||||
describe('terminal query responder', () => {
|
||||
it.each([
|
||||
['OSC 10 BEL', '\x1b]10;?\x07', OSC_10_REPLY],
|
||||
['OSC 10 ST', '\x1b]10;?\x1b\\', OSC_10_REPLY],
|
||||
['OSC 11 BEL', '\x1b]11;?\x07', OSC_11_REPLY],
|
||||
['OSC 11 ST', '\x1b]11;?\x1b\\', OSC_11_REPLY]
|
||||
])('answers each complete %s query once across every partition', (_label, query, reply) => {
|
||||
for (const chunks of allChunkPartitions(query)) {
|
||||
expect(collectTerminalQueryReplies(chunks)).toEqual([reply])
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['DA1 with omitted parameter', '\x1b[c', DA1_REPLY],
|
||||
['DA1 with zero parameter', '\x1b[0c', DA1_REPLY],
|
||||
['DSR cursor position', '\x1b[6n', DSR_REPLY]
|
||||
])('preserves %s across every partition', (_label, query, reply) => {
|
||||
for (const chunks of allChunkPartitions(query)) {
|
||||
expect(collectTerminalQueryReplies(chunks)).toEqual([reply])
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
['unterminated OSC 10', '\x1b]10;?'],
|
||||
['unterminated OSC 11 ST', '\x1b]11;?\x1b'],
|
||||
['OSC 10 extra query marker', '\x1b]10;??\x07'],
|
||||
['OSC 11 stacked query', '\x1b]11;?;?\x07'],
|
||||
['OSC 10 malformed body', '\x1b]10;?x\x07'],
|
||||
['OSC 11 malformed ST body', '\x1b]11;?x\x1b\\'],
|
||||
['OSC slot lookalike', '\x1b]110;?\x07']
|
||||
])('rejects %s across every partition', (_label, input) => {
|
||||
for (const chunks of allChunkPartitions(input)) {
|
||||
expect(collectTerminalQueryReplies(chunks)).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
it('answers concatenated queries once each in source order across every partition', () => {
|
||||
const input = '\x1b]10;?\x07\x1b]11;?\x07'
|
||||
for (const chunks of allChunkPartitions(input)) {
|
||||
expect(collectTerminalQueryReplies(chunks)).toEqual([OSC_10_REPLY, OSC_11_REPLY])
|
||||
}
|
||||
})
|
||||
|
||||
it('bounds carry while rejecting a long unterminated query lookalike', () => {
|
||||
const replies: string[] = []
|
||||
const responder = createTerminalQueryResponder((reply) => replies.push(reply))
|
||||
let maxCarryLength = 0
|
||||
for (const fragment of `\x1b]10;?${'x'.repeat(10_000)}`) {
|
||||
responder.accept(fragment)
|
||||
maxCarryLength = Math.max(maxCarryLength, responder.getCarryLength())
|
||||
}
|
||||
for (const fragment of '\x1b]10;?\x07') {
|
||||
responder.accept(fragment)
|
||||
}
|
||||
|
||||
expect(maxCarryLength).toBe(QUERY_CARRY_LENGTH)
|
||||
expect(responder.getCarryLength()).toBeLessThanOrEqual(QUERY_CARRY_LENGTH)
|
||||
expect(replies).toEqual([OSC_10_REPLY])
|
||||
})
|
||||
})
|
||||
|
||||
describe('fish never receives a color-scheme report it did not query (#9993)', () => {
|
||||
let configHome: string | null = null
|
||||
|
||||
@@ -375,7 +497,8 @@ describe('fish never receives a color-scheme report it did not query (#9993)', (
|
||||
" if (!buffered.includes('\\n')) return\n" +
|
||||
" process.stdout.write('CHILD-READ:' + JSON.stringify(buffered) + '\\n')\n" +
|
||||
' process.exit(0)\n' +
|
||||
'})\n'
|
||||
'})\n' +
|
||||
"process.stdout.write('CHILD-READY\\n')\n"
|
||||
)
|
||||
|
||||
const term = nodePty.spawn(FISH_BIN as string, ['-l', '-i'], {
|
||||
@@ -398,20 +521,16 @@ describe('fish never receives a color-scheme report it did not query (#9993)', (
|
||||
|
||||
let rendered = ''
|
||||
const { transport, sent, emit } = createPtyBackedTransport((data) => term.write(data))
|
||||
const answerTerminalQueries = createTerminalQueryResponder((reply) => term.write(reply))
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
// Why answered here: no real xterm is attached, and fish blocks its first prompt ~10s
|
||||
// on DA1 and re-probes every prompt. These are harness bytes, never renderer output.
|
||||
term.onData((chunk) => {
|
||||
rendered += chunk
|
||||
if (chunk.includes('\x1b[0c') || chunk.includes('\x1b[c')) {
|
||||
term.write('\x1b[?62;4;6;22c')
|
||||
}
|
||||
if (chunk.includes('\x1b[6n')) {
|
||||
term.write('\x1b[1;1R')
|
||||
}
|
||||
if (chunk.includes('\x1b]10;?') || chunk.includes('\x1b]11;?')) {
|
||||
term.write('\x1b]11;rgb:1e1e/1e1e/1e1e\x1b\\')
|
||||
// Maximal fragmentation keeps query handling independent of node-pty chunk boundaries.
|
||||
for (const fragment of chunk) {
|
||||
answerTerminalQueries.accept(fragment)
|
||||
}
|
||||
emit(chunk)
|
||||
})
|
||||
@@ -444,8 +563,10 @@ describe('fish never receives a color-scheme report it did not query (#9993)', (
|
||||
// Withdrawal #1: `sleep` owns the tty now, so the next line is typed ahead.
|
||||
expect(await waitUntil(() => countOf(rendered, WITHDRAW_2031) >= 1, 5_000)).toBe(true)
|
||||
term.write('"$ORCA_NODE_BIN" "$ORCA_CHILD_SCRIPT"\r')
|
||||
// Withdrawal #2: fish re-armed for the prompt and handed the tty to the child.
|
||||
// Withdrawal #2: fish re-armed for the prompt and accepted the child command.
|
||||
expect(await waitUntil(() => countOf(rendered, WITHDRAW_2031) >= 2, 5_000)).toBe(true)
|
||||
// DECSET withdrawal precedes fish's child spawn; the child's marker is the ownership signal.
|
||||
expect(await waitUntil(() => rendered.includes('CHILD-READY'), 5_000)).toBe(true)
|
||||
|
||||
const renderedBeforeChildInput = rendered.length
|
||||
// Canonical mode buffers this in the tty, so it queues behind anything already
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import type * as NodeFs from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const openedPaths = vi.hoisted(() => [] as { path: string; flags: string | number }[])
|
||||
const openedPaths = vi.hoisted(
|
||||
() => [] as { path: string; flags: string | number; ownerWritable: boolean }[]
|
||||
)
|
||||
|
||||
vi.mock('node:fs', async () => {
|
||||
const actual = await vi.importActual<typeof NodeFs>('node:fs')
|
||||
return {
|
||||
...actual,
|
||||
openSync: (path: NodeFs.PathLike, flags: string | number, mode?: NodeFs.Mode) => {
|
||||
openedPaths.push({ path: String(path), flags })
|
||||
openedPaths.push({
|
||||
path: String(path),
|
||||
flags,
|
||||
ownerWritable: Boolean(actual.statSync(path).mode & 0o200)
|
||||
})
|
||||
return actual.openSync(path, flags, mode)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import { bestEffortFsyncDirectorySync, fsyncFileSync } from './secure-file'
|
||||
import {
|
||||
bestEffortFsyncDirectorySync,
|
||||
fsyncFileSync,
|
||||
writeDurableSecureJsonFile
|
||||
} from './secure-file'
|
||||
|
||||
const createdPaths: string[] = []
|
||||
|
||||
@@ -29,7 +39,8 @@ afterEach(() => {
|
||||
})
|
||||
|
||||
describe('secure file fsync flags', () => {
|
||||
it('opens files read/write before fsync', () => {
|
||||
const windowsIt = process.platform === 'win32' ? it : it.skip
|
||||
windowsIt('opens files read/write before fsync on Windows', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-file-fsync-'))
|
||||
createdPaths.push(directory)
|
||||
const path = join(directory, 'record.json')
|
||||
@@ -37,16 +48,42 @@ describe('secure file fsync flags', () => {
|
||||
|
||||
fsyncFileSync(path)
|
||||
|
||||
expect(openedPaths).toEqual([{ path, flags: 'r+' }])
|
||||
expect(openedPaths).toMatchObject([{ path, flags: 'r+' }])
|
||||
})
|
||||
|
||||
const posixIt = process.platform === 'win32' ? it.skip : it
|
||||
posixIt('fsyncs files without owner-write permission', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-file-fsync-read-only-'))
|
||||
createdPaths.push(directory)
|
||||
const path = join(directory, 'record.json')
|
||||
writeFileSync(path, '{}')
|
||||
chmodSync(path, 0o400)
|
||||
|
||||
expect(() => fsyncFileSync(path)).not.toThrow()
|
||||
expect(openedPaths).toEqual([{ path, flags: 'r', ownerWritable: false }])
|
||||
})
|
||||
|
||||
posixIt('durably writes when umask removes owner-write from the temporary file', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-durable-file-fsync-read-only-'))
|
||||
createdPaths.push(directory)
|
||||
const path = join(directory, 'record.json')
|
||||
const originalUmask = process.umask(0o200)
|
||||
|
||||
try {
|
||||
expect(() => writeDurableSecureJsonFile(path, { ok: true })).not.toThrow()
|
||||
} finally {
|
||||
process.umask(originalUmask)
|
||||
}
|
||||
|
||||
expect(openedPaths[0]).toMatchObject({ flags: 'r', ownerWritable: false })
|
||||
})
|
||||
|
||||
posixIt('opens directories read-only before fsync', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'orca-directory-fsync-'))
|
||||
createdPaths.push(directory)
|
||||
|
||||
bestEffortFsyncDirectorySync(directory)
|
||||
|
||||
expect(openedPaths).toEqual([{ path: directory, flags: 'r' }])
|
||||
expect(openedPaths).toMatchObject([{ path: directory, flags: 'r' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -144,7 +144,7 @@ function fsyncPathSync(path: string, flags: 'r' | 'r+'): void {
|
||||
|
||||
export function fsyncFileSync(path: string): void {
|
||||
// FlushFileBuffers requires a write-capable handle on Windows.
|
||||
fsyncPathSync(path, 'r+')
|
||||
fsyncPathSync(path, process.platform === 'win32' ? 'r+' : 'r')
|
||||
}
|
||||
|
||||
export function bestEffortFsyncDirectorySync(directory: string): void {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
|
||||
test.skip(process.platform === 'win32', 'POSIX fresh-startup golden; Windows has its own suite')
|
||||
|
||||
test.describe('POSIX fresh startup golden', () => {
|
||||
test.use({ dismissOnboarding: false, seedTestRepo: false })
|
||||
|
||||
test('fresh profile reaches onboarding normally @posix-profile-index-golden', async ({
|
||||
orcaPage
|
||||
}) => {
|
||||
await expect(orcaPage.getByRole('heading', { name: /Pick your default agent/i })).toBeVisible({
|
||||
timeout: 30_000
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { readFileSync, rmSync, statSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
|
||||
test.use({ seedTestRepo: false })
|
||||
test.skip(process.platform === 'win32', 'Restrictive-umask fsync regression is POSIX-only')
|
||||
|
||||
test('recreates a fresh profile index on disk with a restrictive umask @posix-profile-index-golden', async ({
|
||||
electronApp,
|
||||
orcaPage
|
||||
}) => {
|
||||
const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData'))
|
||||
const indexPath = path.join(userDataDir, 'orca-profile-index.json')
|
||||
// Why: the index tmp file is written with 0o666 & ~umask, so a umask that clears
|
||||
// owner-write is what made the pre-fix fsync open(path, 'r+') fail with EACCES.
|
||||
const originalUmask = await electronApp.evaluate(() => process.umask(0o200))
|
||||
|
||||
try {
|
||||
rmSync(indexPath, { force: true })
|
||||
rmSync(`${indexPath}.bak`, { force: true })
|
||||
// Why: orcaProfiles:list reads the index from disk on every call and rebuilds
|
||||
// it when missing, so this drives the real create + fsync + rename path.
|
||||
const listed = await orcaPage.evaluate(async () => {
|
||||
const result = await window.api.orcaProfiles.list()
|
||||
window.__store!.getState().openSettingsPage()
|
||||
return result
|
||||
})
|
||||
expect(listed.profiles.length).toBeGreaterThan(0)
|
||||
|
||||
// The rebuilt index must be on disk and complete — a cached in-memory list is
|
||||
// not proof that the restrictive-umask write survived fsync.
|
||||
const persisted = JSON.parse(readFileSync(indexPath, 'utf-8'))
|
||||
expect(persisted.activeProfileId).toBe(listed.activeProfileId)
|
||||
expect(persisted.profiles.map((profile: { id: string }) => profile.id)).toEqual(
|
||||
listed.profiles.map((profile) => profile.id)
|
||||
)
|
||||
// Owner-write cleared proves the file was created under the restrictive umask
|
||||
// rather than left over from startup's normal-umask write.
|
||||
expect(statSync(indexPath).mode & 0o200).toBe(0)
|
||||
} finally {
|
||||
await electronApp.evaluate((_electron, umask) => process.umask(umask), originalUmask)
|
||||
}
|
||||
|
||||
await expect(orcaPage.getByPlaceholder('Search settings')).toBeVisible()
|
||||
})
|
||||
Reference in New Issue
Block a user