Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1213,6 +1213,7 @@ jobs:
- name: Test Windows-specific boundaries
run: >-
pnpm exec vitest run --config config/vitest.config.ts
src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts
config/scripts/rebuild-native-deps.test.mjs
config/scripts/rebuild-native-deps-windows-process-tree.test.mjs
config/scripts/rebuild-native-deps-node-pty.test.mjs
Expand Down
37 changes: 37 additions & 0 deletions .github/workflows/zcode-credential-contract.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Coding Plan credential contract
on:
pull_request:
paths:
- 'src/main/zcode/**'
- 'src/shared/secure-file*'
- 'src/shared/secure-path-windows-acl*'
- 'src/shared/windows-security-descriptor*'
- '.github/workflows/zcode-credential-contract.yml'
workflow_dispatch:
permissions:
contents: read
concurrency:
group: coding-plan-contract-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
credential-files:
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, macos-15, windows-2022]
runs-on: ${{ matrix.os }}
timeout-minutes: 20
env:
ORCA_BACKGROUND_LAUNCH: '1'
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: ./.github/actions/install-node-dependencies
with:
native-runtime: node
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: config/.bun-version
- name: Verify rollback and native Windows permissions
run: pnpm test src/main/zcode/zcode-plan-api-key-store.test.ts src/main/zcode/zcode-plan-api-key-store-plaintext-rollback.test.ts src/main/zcode/zcode-plan-api-key-store-publication-race.test.ts src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts src/shared/secure-file.test.ts
1 change: 1 addition & 0 deletions config/scripts/pr-code-change-scope.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ const LINUX_PACKAGE_TESTS = [
]

const WINDOWS_PACKAGE_TESTS = [
'src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts',
...LINUX_PACKAGE_TESTS,
'config/scripts/rebuild-native-deps.test.mjs',
'config/scripts/rebuild-native-deps-windows-process-tree.test.mjs',
Expand Down
141 changes: 141 additions & 0 deletions src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import * as fs from 'node:fs'
import * as os from 'node:os'
import { join } from 'node:path'
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
import type * as WindowsAcl from '../../shared/secure-path-windows-acl'
import type * as Store from './zcode-plan-api-key-store'

type NativeCase = {
home: string
fault: 'none' | 'before-restore' | 'after-restore' | 'competing'
reject: boolean
writes: number
renames: number
stages: number
nativeOutcomes: boolean[]
directories: boolean[]
}
const state = vi.hoisted((): NativeCase => ({
home: '',
fault: 'none',
reject: false,
writes: 0,
renames: 0,
stages: 0,
nativeOutcomes: [],
directories: []
}))
vi.mock('electron', () => ({ safeStorage: { isEncryptionAvailable: () => false } }))
vi.mock('node:os', async (original) => ({
...(await original<typeof os>()),
homedir: () => state.home
}))
vi.mock('node:fs', async (original) => {
const real = await original<typeof fs>()
return {
...real,
writeFileSync: (...args: Parameters<typeof fs.writeFileSync>) => {
if (++state.writes === 2 && state.fault === 'before-restore') {
throw new Error('Synthetic restore staging failure')
}
return real.writeFileSync(...args)
},
renameSync: (...args: Parameters<typeof fs.renameSync>) => {
real.renameSync(...args)
if (++state.renames === 2 && state.fault === 'after-restore') {
throw new Error('Synthetic failure after restore publication')
}
}
}
})
vi.mock('../../shared/secure-path-windows-acl', async (original) => {
const real = await original<typeof WindowsAcl>()
const files = await vi.importActual<typeof fs>('node:fs')
return {
...real,
restrictWindowsPathSync: (...args: Parameters<typeof WindowsAcl.restrictWindowsPathSync>) => {
const outcome = real.restrictWindowsPathSync(...args)
state.nativeOutcomes.push(outcome)
if (state.fault === 'competing' && args[0].endsWith('.tmp') && ++state.stages === 2) {
const competing = join(state.home, '.orca', 'competing-key')
files.writeFileSync(competing, envelope('synthetic-competing'))
expect(real.restrictWindowsPathSync(competing, false)).toBe(true)
files.renameSync(competing, join(state.home, '.orca', 'zcode-plan-api-key.enc'))
}
return state.reject ? false : outcome
},
bestEffortRestrictWindowsPath: (
path: string,
directory: boolean,
settled?: (restricted: boolean) => void
) =>
real.bestEffortRestrictWindowsPath(path, directory, (restricted) => {
state.directories.push(restricted)
settled?.(restricted)
})
}
})

const envelope = (key: string) =>
`orca-zcode-plan-api-key:v1:plaintext:${Buffer.from(key).toString('base64')}`
let store: typeof Store
let keyPath: string

describe.skipIf(process.platform !== 'win32')('native Windows Coding Plan rollback', () => {
beforeEach(async () => {
state.home = fs.mkdtempSync(join(os.tmpdir(), 'orca-native-plan-'))
state.fault = 'none'
state.reject = false
state.writes = 0
state.renames = 0
state.stages = 0
state.nativeOutcomes = []
state.directories = []
keyPath = join(state.home, '.orca', 'zcode-plan-api-key.enc')
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
vi.resetModules()
store = await import('./zcode-plan-api-key-store')
store.saveZcodePlanApiKey('synthetic-previous')
expect(fs.readFileSync(keyPath, 'utf8')).toBe(envelope('synthetic-previous'))
expect(state.nativeOutcomes.length).toBeGreaterThan(0)
expect(state.nativeOutcomes.every(Boolean)).toBe(true)
await vi.waitFor(
() => {
expect(state.directories.length).toBeGreaterThan(0)
expect(state.directories.every(Boolean)).toBe(true)
},
{ timeout: 15_000 }
)
state.writes = 0
state.renames = 0
state.stages = 0
state.reject = true
})

afterEach(() => {
vi.restoreAllMocks()
fs.rmSync(state.home, { recursive: true, force: true })
})

it.each(['before-restore', 'after-restore', 'competing'] as const)(
'preserves the file contract after %s with real file and directory ACLs',
(fault) => {
state.fault = fault
expect(() => store.saveZcodePlanApiKey('synthetic-rejected')).toThrow(
'could not be stored securely'
)
if (fault === 'before-restore') {
expect(fs.existsSync(keyPath)).toBe(false)
} else {
expect(fs.readFileSync(keyPath, 'utf8')).toBe(
envelope(fault === 'competing' ? 'synthetic-competing' : 'synthetic-previous')
)
}
expect(fs.readdirSync(join(state.home, '.orca')).some((name) => name.endsWith('.tmp'))).toBe(
false
)
expect(state.nativeOutcomes.every(Boolean)).toBe(true)
},
30_000
)
})
Loading
Loading