From 6de900fcc653eadda591d043edcc143049720bc8 Mon Sep 17 00:00:00 2001 From: Neil Date: Sat, 10 Oct 2026 15:26:35 -0700 Subject: [PATCH] test(zcode): verify rollback with real Windows file permissions --- .../workflows/zcode-credential-contract.yml | 34 +++++ ...-plan-api-key-store-native-windows.test.ts | 141 ++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 .github/workflows/zcode-credential-contract.yml create mode 100644 src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts diff --git a/.github/workflows/zcode-credential-contract.yml b/.github/workflows/zcode-credential-contract.yml new file mode 100644 index 00000000000..80fcc911f9e --- /dev/null +++ b/.github/workflows/zcode-credential-contract.yml @@ -0,0 +1,34 @@ +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 + - 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 diff --git a/src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts b/src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts new file mode 100644 index 00000000000..7e069327e89 --- /dev/null +++ b/src/main/zcode/zcode-plan-api-key-store-native-windows.test.ts @@ -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()), + homedir: () => state.home +})) +vi.mock('node:fs', async (original) => { + const real = await original() + return { + ...real, + writeFileSync: (...args: Parameters) => { + if (++state.writes === 2 && state.fault === 'before-restore') { + throw new Error('Synthetic restore staging failure') + } + return real.writeFileSync(...args) + }, + renameSync: (...args: Parameters) => { + 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() + const files = await vi.importActual('node:fs') + return { + ...real, + restrictWindowsPathSync: (...args: Parameters) => { + 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 + ) +})