diff --git a/.changeset/calm-wallets-lock.md b/.changeset/calm-wallets-lock.md new file mode 100644 index 00000000..15bbe55e --- /dev/null +++ b/.changeset/calm-wallets-lock.md @@ -0,0 +1,5 @@ +--- +'accounts': patch +--- + +Prevented concurrent CLI store writers from restoring retired access-key credentials. diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 21d4bf80..4f072dbb 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -35,6 +35,23 @@ jobs: - name: Check types run: pnpm check:types + test-windows-storage: + name: Test Windows storage + runs-on: windows-latest + timeout-minutes: 10 + + steps: + - name: Clone repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Install dependencies + uses: ./.github/actions/install-dependencies + + - name: Run storage tests + run: pnpm test src/cli/storage.test.ts --run + test-localnet: name: 'Test Runtime (env: localnet, tag: ${{ matrix.tag }})' runs-on: ubuntu-latest diff --git a/package.json b/package.json index 1b6d8bf2..44e45c83 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "hono": "catalog:", "idb-keyval": "^6.2.2", "jose": "^6.2.3", + "koffi": "3.1.2", "mipd": "^0.0.7", "mppx": "catalog:", "ox": "~0.14.30", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb559a58..19e9d276 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -123,6 +123,9 @@ importers: jose: specifier: ^6.2.3 version: 6.2.3 + koffi: + specifier: 3.1.2 + version: 3.1.2 mipd: specifier: ^0.0.7 version: 0.0.7(typescript@5.9.3) @@ -2735,6 +2738,81 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@koromix/koffi-darwin-arm64@3.1.2': + resolution: {integrity: sha512-32pU4pNZABIz+l9DNJl51Y+jur4vv+SF4Ip2CSF4OUg1xUyefoLpX0NttDmzGITIrneUEVSEN+dT22524ESKBw==} + cpu: [arm64] + os: [darwin] + + '@koromix/koffi-darwin-x64@3.1.2': + resolution: {integrity: sha512-S+H6LQgUoMj77BqDegwlRaxwLXDfwvSJGuceOqtH0I5V8rzKLmu/hC7NBlxOoAlvKlcV63FtdNiE2E9YSltffg==} + cpu: [x64] + os: [darwin] + + '@koromix/koffi-freebsd-arm64@3.1.2': + resolution: {integrity: sha512-fD0ow2PBE60nw7K6xcbala6qwXxfcYeU62tduNeIPvx0KoWhU2rMKZiDNe+iI5TQb3rxYYjjP+aF2Sdm9y6EXQ==} + cpu: [arm64] + os: [freebsd] + + '@koromix/koffi-freebsd-ia32@3.1.2': + resolution: {integrity: sha512-t8OmL+hoJGDLZDnuLjgLemSYrXX99M7Md+zJX8bMHOtiNbFtkGXn/mV21Pb1ik9JhBXjwK1r4hvBPNlqTMGrHg==} + cpu: [ia32] + os: [freebsd] + + '@koromix/koffi-freebsd-x64@3.1.2': + resolution: {integrity: sha512-axbLgiM4Y2vyDOTqlXCI8vkg9wqjwSRsmoWXSKreA5YFJwnYA6Sc4aHMz+qZgUSfFei52Qrv1RGhDyo4kHvqhA==} + cpu: [x64] + os: [freebsd] + + '@koromix/koffi-linux-arm64@3.1.2': + resolution: {integrity: sha512-f0hqAIlFcL9wlRGJ/uCfyfspqnGaASk2gLx1UAP3RBgMQl68D1e+fiHNdXa7g9d76ttmpA8/PGNAqc1X4Byy1Q==} + cpu: [arm64] + os: [linux] + + '@koromix/koffi-linux-ia32@3.1.2': + resolution: {integrity: sha512-UGLPuqeOV/UArsK6oeB5yI/XjSWkFqFlBTC9rUbezBuHJhSibk1EMv7QC0cvtDMu18bo+ucqXWPzh42oT5yYlw==} + cpu: [ia32] + os: [linux] + + '@koromix/koffi-linux-loong64@3.1.2': + resolution: {integrity: sha512-jI0+gM2oDsJ7reOt3XPyO7lyQtZ1CT6NR2uqGQcQVM43cyXBAVYYCUxEH3LHCbgumFaZ+LueIUgbMSwb9pHBxQ==} + cpu: [loong64] + os: [linux] + + '@koromix/koffi-linux-riscv64@3.1.2': + resolution: {integrity: sha512-yB99adXBRd5T+xXG+f6nnUkC3jCI0iXvPU6RqD9Kx7aZP4Y4NNUWJ5Q4FaP9jb1XmZLY4pGBUiHt8u03Yl7NyA==} + cpu: [riscv64] + os: [linux] + + '@koromix/koffi-linux-x64@3.1.2': + resolution: {integrity: sha512-Oxvo6F3Edzy/Jm2EtbHWkJ2xRB0mXDAe63k5+USL5uiGE5xZjwEUDOBKIhv2BpCZSOAJrfoojFFogj6+ICKQhw==} + cpu: [x64] + os: [linux] + + '@koromix/koffi-openbsd-ia32@3.1.2': + resolution: {integrity: sha512-SSWzUhL8Ex84JTsO67+MdWZrdwgOzoOrQ0+ZbB+UsivHoAxmWLHKWZaSafNqyBZtxGY1EgtR8AIPouWE9U+Zfw==} + cpu: [ia32] + os: [openbsd] + + '@koromix/koffi-openbsd-x64@3.1.2': + resolution: {integrity: sha512-0ZuI4St7chq3M0d3VivvKIqacZ7RhgohdR476V3HpJkaNdfIywsJIw+GBvqkQahu+4A2Rpu6yQJpWSrfk/Z+Jw==} + cpu: [x64] + os: [openbsd] + + '@koromix/koffi-win32-arm64@3.1.2': + resolution: {integrity: sha512-8Wn6phw7y53uI52+aBPAqEfZ5pj/HCjg/YtdthqSWYHy+d0MhyASKlcmuP0B5raxQnnA1Bm9LC8UO3M3RojeBw==} + cpu: [arm64] + os: [win32] + + '@koromix/koffi-win32-ia32@3.1.2': + resolution: {integrity: sha512-FkKaPBMawgHMNnp1FwLldXMNvEa139GXkxPi9JD9xU71Kh/ZmuEYHGSD6JwZDmDr4jekVrBrr+eGZ+j6C2mkXg==} + cpu: [ia32] + os: [win32] + + '@koromix/koffi-win32-x64@3.1.2': + resolution: {integrity: sha512-FeFC59UU1XX4J3ZaqKrsrEzczzB5qksMJo7/R45vIg8mGNVSLMVE85JRiZpjcp9i5Lbav5Vw47QvwFzBgIfvlw==} + cpu: [x64] + os: [win32] + '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} @@ -8358,6 +8436,9 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + koffi@3.1.2: + resolution: {integrity: sha512-wVwuE21TBl8/si6E0hPorKR2PJ2q33mEWVETANrtSp3kFM8fi2FcD/J5wmxu0T4TBcqmMQ4xKuF1X1ayFmphzw==} + lan-network@0.2.1: resolution: {integrity: sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==} hasBin: true @@ -13802,6 +13883,51 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} + '@koromix/koffi-darwin-arm64@3.1.2': + optional: true + + '@koromix/koffi-darwin-x64@3.1.2': + optional: true + + '@koromix/koffi-freebsd-arm64@3.1.2': + optional: true + + '@koromix/koffi-freebsd-ia32@3.1.2': + optional: true + + '@koromix/koffi-freebsd-x64@3.1.2': + optional: true + + '@koromix/koffi-linux-arm64@3.1.2': + optional: true + + '@koromix/koffi-linux-ia32@3.1.2': + optional: true + + '@koromix/koffi-linux-loong64@3.1.2': + optional: true + + '@koromix/koffi-linux-riscv64@3.1.2': + optional: true + + '@koromix/koffi-linux-x64@3.1.2': + optional: true + + '@koromix/koffi-openbsd-ia32@3.1.2': + optional: true + + '@koromix/koffi-openbsd-x64@3.1.2': + optional: true + + '@koromix/koffi-win32-arm64@3.1.2': + optional: true + + '@koromix/koffi-win32-ia32@3.1.2': + optional: true + + '@koromix/koffi-win32-x64@3.1.2': + optional: true + '@kwsites/file-exists@1.1.1': dependencies: debug: 4.4.3(supports-color@10.2.2) @@ -22479,6 +22605,24 @@ snapshots: kleur@4.1.5: {} + koffi@3.1.2: + optionalDependencies: + '@koromix/koffi-darwin-arm64': 3.1.2 + '@koromix/koffi-darwin-x64': 3.1.2 + '@koromix/koffi-freebsd-arm64': 3.1.2 + '@koromix/koffi-freebsd-ia32': 3.1.2 + '@koromix/koffi-freebsd-x64': 3.1.2 + '@koromix/koffi-linux-arm64': 3.1.2 + '@koromix/koffi-linux-ia32': 3.1.2 + '@koromix/koffi-linux-loong64': 3.1.2 + '@koromix/koffi-linux-riscv64': 3.1.2 + '@koromix/koffi-linux-x64': 3.1.2 + '@koromix/koffi-openbsd-ia32': 3.1.2 + '@koromix/koffi-openbsd-x64': 3.1.2 + '@koromix/koffi-win32-arm64': 3.1.2 + '@koromix/koffi-win32-ia32': 3.1.2 + '@koromix/koffi-win32-x64': 3.1.2 + lan-network@0.2.1: {} layout-base@1.0.2: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f5dc8b1f..3b771fd0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,6 +12,7 @@ allowBuilds: cpu-features: true es5-ext: true esbuild: true + koffi: true keccak: false protobufjs: true sharp: true diff --git a/src/cli/storage.test.ts b/src/cli/storage.test.ts index 1928bb99..ddd398aa 100644 --- a/src/cli/storage.test.ts +++ b/src/cli/storage.test.ts @@ -1,9 +1,13 @@ +import { spawn } from 'node:child_process' +import { once } from 'node:events' import { chmod, mkdtemp, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' +import { setTimeout } from 'node:timers/promises' import { describe, expect, expectTypeOf, test } from 'vp/test' import type * as CoreStorage from '../core/Storage.js' +import * as Store from '../core/Store.js' import * as Storage from './storage.js' async function createPath() { @@ -119,4 +123,251 @@ describe('filesystem', () => { ] `) }) + + test('behavior: preserves the order of access keys added together', async () => { + const path = await createPath() + const storage = Storage.filesystem({ key: 'test', path }) + const store = Store.create({ chainId: 1, storage }) + await Store.waitForHydration(store) + const access = '0x0000000000000000000000000000000000000001' + const accessKeys = [ + { + access, + address: '0x0000000000000000000000000000000000000002', + chainId: 1, + keyType: 'secp256k1', + privateKey: `0x${'11'.repeat(32)}`, + }, + { + access, + address: '0x0000000000000000000000000000000000000003', + chainId: 1, + keyType: 'secp256k1', + privateKey: `0x${'22'.repeat(32)}`, + }, + ] as const + + store.setState({ accessKeys }) + + await expect(storage.getItem('store')).resolves.toEqual({ + state: { accessKeys, accounts: [], activeAccount: 0, chainId: 1 }, + version: 0, + }) + }) + + test('behavior: waits for another process and rereads after locking', async () => { + const path = await createPath() + const child = spawn( + process.execPath, + [ + '--input-type=module', + '--eval', + ` + import Koffi from 'koffi' + import { closeSync, openSync, writeFileSync } from 'node:fs' + import { setTimeout } from 'node:timers/promises' + let unlock + let close + if (process.platform === 'win32') { + const handle = Koffi.pointer('HANDLE', Koffi.opaque()) + const overlapped = Koffi.struct('OVERLAPPED', { + Internal: 'uintptr_t', + InternalHigh: 'uintptr_t', + Offset: 'uint32_t', + OffsetHigh: 'uint32_t', + hEvent: handle, + }) + const pointer = Koffi.pointer(overlapped) + const kernel = Koffi.load('kernel32.dll') + const createFile = kernel.func('__stdcall', 'CreateFileW', handle, ['str16', 'uint32_t', 'uint32_t', 'void *', 'uint32_t', 'uint32_t', handle]) + const lockFile = kernel.func('__stdcall', 'LockFileEx', 'int32_t', [handle, 'uint32_t', 'uint32_t', 'uint32_t', 'uint32_t', pointer]) + const unlockFile = kernel.func('__stdcall', 'UnlockFileEx', 'int32_t', [handle, 'uint32_t', 'uint32_t', 'uint32_t', pointer]) + const closeHandle = kernel.func('__stdcall', 'CloseHandle', 'int32_t', [handle]) + const nativeHandle = createFile(${JSON.stringify(`${path}.lock`)}, 0xc0000000, 7, null, 4, 0x80, null) + const range = 0xffffffff + const createOverlapped = () => ({ Internal: 0, InternalHigh: 0, Offset: 0, OffsetHigh: 0, hEvent: null }) + if (!lockFile(nativeHandle, 2, 0, range, range, createOverlapped())) throw new Error('failed to lock') + unlock = () => unlockFile(nativeHandle, 0, range, range, createOverlapped()) !== 0 + close = () => closeHandle(nativeHandle) + } else { + const fd = openSync(${JSON.stringify(`${path}.lock`)}, 'a+') + const library = Koffi.load(process.platform === 'darwin' ? '/usr/lib/libSystem.B.dylib' : 'libc.so.6') + const flock = library.func('flock', 'int', ['int', 'int']) + if (flock(fd, 2) !== 0) throw new Error('failed to lock') + unlock = () => flock(fd, 8) === 0 + close = () => closeSync(fd) + } + process.stdout.write('locked') + await setTimeout(100) + writeFileSync(${JSON.stringify(path)}, JSON.stringify({ 'external.store': { state: { chainId: 1 }, version: 0 } })) + if (!unlock()) throw new Error('failed to unlock') + close() + `, + ], + { stdio: ['ignore', 'pipe', 'inherit'] }, + ) + const exit = once(child, 'exit') + await once(child.stdout!, 'data') + let complete = false + const write = Promise.resolve( + Storage.filesystem({ key: 'local', path }).setItem('store', { + state: { chainId: 2 }, + version: 0, + }), + ).then(() => { + complete = true + }) + await setTimeout(30) + const blocked = !complete + const [code] = await exit + expect({ blocked, code }).toMatchInlineSnapshot(` + { + "blocked": true, + "code": 0, + } + `) + await write + + await expect( + Promise.all([ + Storage.filesystem({ key: 'external', path }).getItem('store'), + Storage.filesystem({ key: 'local', path }).getItem('store'), + ]), + ).resolves.toEqual([ + { state: { chainId: 1 }, version: 0 }, + { state: { chainId: 2 }, version: 0 }, + ]) + }) + + test('behavior: does not restore credentials from stale hydrated state', async () => { + const path = await createPath() + const storage = Storage.filesystem({ key: 'test', path }) + const accessKey = { + access: '0x0000000000000000000000000000000000000001', + address: '0x0000000000000000000000000000000000000002', + chainId: 1, + handle: 'stale-handle', + keyType: 'secp256k1', + keyPair: { privateKey: 'stale-key-pair' }, + privateKey: `0x${'11'.repeat(32)}`, + } as const + await storage.setItem('store', { + state: { accessKeys: [accessKey], accounts: [], activeAccount: 0, chainId: 1 }, + version: 0, + }) + const store = Store.create({ chainId: 1, storage }) + await Store.waitForHydration(store) + + const { handle: _, keyPair: __, privateKey: ___, ...retired } = accessKey + await Storage.filesystem({ key: 'test', path }).setItem('store', { + state: { accessKeys: [retired], accounts: [], activeAccount: 0, chainId: 1 }, + version: 0, + }) + store.setState({ chainId: 2 }) + + await expect(storage.getItem('store')).resolves.toEqual({ + state: { accessKeys: [retired], accounts: [], activeAccount: 0, chainId: 2 }, + version: 0, + }) + }) + + test('behavior: retires credentials across address casing differences', async () => { + const path = await createPath() + const storage = Storage.filesystem({ key: 'test', path }) + const accessKey = { + access: '0x00000000000000000000000000000000000000AB', + address: '0x00000000000000000000000000000000000000CD', + chainId: 1, + keyType: 'secp256k1', + privateKey: `0x${'11'.repeat(32)}`, + } as const + await storage.setItem('store', { + state: { accessKeys: [accessKey], accounts: [], activeAccount: 0, chainId: 1 }, + version: 0, + }) + const store = Store.create({ chainId: 1, storage }) + await Store.waitForHydration(store) + + const current = { + ...accessKey, + access: accessKey.access.toLowerCase(), + address: accessKey.address.toLowerCase(), + } + await Storage.filesystem({ key: 'test', path }).setItem('store', { + state: { accessKeys: [current], accounts: [], activeAccount: 0, chainId: 1 }, + version: 0, + }) + const { privateKey: _, ...retired } = accessKey + store.setState({ accessKeys: [retired] }) + + await expect(storage.getItem('store')).resolves.toMatchInlineSnapshot(` + { + "state": { + "accessKeys": [ + { + "access": "0x00000000000000000000000000000000000000ab", + "address": "0x00000000000000000000000000000000000000cd", + "chainId": 1, + "keyType": "secp256k1", + }, + ], + "accounts": [], + "activeAccount": 0, + "chainId": 1, + }, + "version": 0, + } + `) + }) + + test('behavior: does not restore credentials after clearing storage', async () => { + const path = await createPath() + const storage = Storage.filesystem({ key: 'test', path }) + const accessKey = { + access: '0x0000000000000000000000000000000000000001', + address: '0x0000000000000000000000000000000000000002', + chainId: 1, + keyType: 'secp256k1', + privateKey: `0x${'11'.repeat(32)}`, + } as const + await storage.setItem('store', { + state: { accessKeys: [accessKey], accounts: [], activeAccount: 0, chainId: 1 }, + version: 0, + }) + const store = Store.create({ chainId: 1, storage }) + await Store.waitForHydration(store) + + await store.persist.clearStorage() + store.setState({ chainId: 2 }) + + await expect(storage.getItem('store')).resolves.toEqual({ + state: { accessKeys: [], accounts: [], activeAccount: 0, chainId: 2 }, + version: 0, + }) + }) + + test('behavior: preserves a store created after empty hydration', async () => { + const path = await createPath() + const storage = Storage.filesystem({ key: 'test', path }) + const store = Store.create({ chainId: 1, storage }) + await Store.waitForHydration(store) + const accessKey = { + access: '0x0000000000000000000000000000000000000001', + address: '0x0000000000000000000000000000000000000002', + chainId: 1, + keyType: 'secp256k1', + privateKey: `0x${'22'.repeat(32)}`, + } as const + await Storage.filesystem({ key: 'test', path }).setItem('store', { + state: { accessKeys: [accessKey], accounts: [], activeAccount: 0, chainId: 1 }, + version: 0, + }) + + store.setState({ chainId: 2 }) + + await expect(storage.getItem('store')).resolves.toEqual({ + state: { accessKeys: [accessKey], accounts: [], activeAccount: 0, chainId: 2 }, + version: 0, + }) + }) }) diff --git a/src/cli/storage.ts b/src/cli/storage.ts index 8203d8da..34f5790e 100644 --- a/src/cli/storage.ts +++ b/src/cli/storage.ts @@ -1,6 +1,8 @@ +import * as Koffi from 'koffi' import { chmod, mkdir, open, readFile, rename, unlink } from 'node:fs/promises' import { homedir } from 'node:os' import { dirname, join } from 'node:path' +import { setTimeout } from 'node:timers/promises' import { Json } from 'ox' import * as Storage from '../core/Storage.js' @@ -8,6 +10,21 @@ import * as Storage from '../core/Storage.js' const mode_directory = 0o700 const mode_file = 0o600 const operations = new Map>() +const lock_exclusive = 2 +const lock_nonblocking = 4 +const lock_unlock = 8 +const lock_retry_ms = 10 +const windows_error_lock_violation = 33 +const windows_file_attribute_normal = 0x80 +const windows_file_open_always = 4 +const windows_file_read_write = 0xc0000000 +const windows_file_share = 7 +const windows_lock_exclusive = 2 +const windows_lock_nonblocking = 1 +const windows_lock_length = 0xffffffff + +let flock_: ((fd: number, operation: number) => number) | undefined +let windows_lock_: WindowsLock | undefined /** Returns the default CLI provider storage path. */ export function defaultPath(): string { @@ -33,28 +50,43 @@ export function filesystem(options: filesystem.Options = {}): Storage.Storage { } return Storage.from( - { - async getItem(name: string): Promise { - return await enqueue(async () => { - const value = await read(path) - return (value[name] as value | undefined) ?? null - }) + Storage.withUpdate( + { + async getItem(name: string): Promise { + return await enqueue(async () => { + const value = await read(path) + return (value[name] as value | undefined) ?? null + }) + }, + async removeItem(name) { + await enqueue(async () => { + await withLock(path, async () => { + const value = await read(path) + delete value[name] + await write(path, value) + }) + }) + }, + async setItem(name, item) { + await enqueue(async () => { + await withLock(path, async () => { + const value = await read(path) + value[name] = item + await write(path, value) + }) + }) + }, }, - async removeItem(name) { + async (name: string, update: (value: value | null) => value) => { await enqueue(async () => { - const value = await read(path) - delete value[name] - await write(path, value) + await withLock(path, async () => { + const value = await read(path) + value[name] = update((value[name] as value | undefined) ?? null) + await write(path, value) + }) }) }, - async setItem(name, item) { - await enqueue(async () => { - const value = await read(path) - value[name] = item - await write(path, value) - }) - }, - }, + ), { key: options.key ?? 'tempo-cli' }, ) } @@ -87,6 +119,229 @@ async function ensureDirectory(path: string) { await chmod(dir, mode_directory) } +async function withLock(path: string, fn: () => Promise): Promise { + await ensureDirectory(path) + const path_lock = `${path}.lock` + if (process.platform === 'win32') return withWindowsLock(path_lock, fn) + + const handle = await open(path_lock, 'a+', mode_file) + try { + await chmod(path_lock, mode_file) + await lock(handle.fd, path_lock) + try { + return await fn() + } finally { + await unlock(handle.fd, path_lock) + } + } finally { + await handle.close() + } +} + +async function withWindowsLock(path: string, fn: () => Promise): Promise { + const windows = windowsLock() + const { error, handle } = windows.open(path) + if (error !== 0) + throw new FilesystemStorageError(path, `Failed to open CLI storage lock (error ${error}).`) + let value: value + try { + await chmod(path, mode_file) + await lockWindows(handle, path) + try { + value = await fn() + } finally { + unlockWindows(handle, path) + } + } catch (error) { + windows.close(handle) + throw error + } + const closed = windows.close(handle) + if (closed.result === 0) + throw new FilesystemStorageError( + path, + `Failed to close CLI storage lock (error ${closed.error}).`, + ) + return value +} + +async function lock(fd: number, path: string): Promise { + for (;;) { + const result = flock()(fd, lock_exclusive | lock_nonblocking) + if (result === 0) return + const errno = Koffi.errno() + if (errno !== Koffi.os.errno.EAGAIN && errno !== Koffi.os.errno.EWOULDBLOCK) + throw new FilesystemStorageError(path, `Failed to acquire CLI storage lock (errno ${errno}).`) + await setTimeout(lock_retry_ms) + } +} + +async function lockWindows(handle: unknown, path: string): Promise { + for (;;) { + const { error, result } = windowsLock().lock(handle) + if (result !== 0) return + if (error !== windows_error_lock_violation) + throw new FilesystemStorageError(path, `Failed to acquire CLI storage lock (error ${error}).`) + await setTimeout(lock_retry_ms) + } +} + +async function unlock(fd: number, path: string): Promise { + if (flock()(fd, lock_unlock) === 0) return + const errno = Koffi.errno() + throw new FilesystemStorageError(path, `Failed to release CLI storage lock (errno ${errno}).`) +} + +function unlockWindows(handle: unknown, path: string): void { + const { error, result } = windowsLock().unlock(handle) + if (result !== 0) return + throw new FilesystemStorageError(path, `Failed to release CLI storage lock (error ${error}).`) +} + +function flock(): (fd: number, operation: number) => number { + if (flock_) return flock_ + if (process.platform !== 'darwin' && process.platform !== 'linux') + throw new FilesystemStorageError( + '', + `CLI storage locking is not supported on ${process.platform}.`, + ) + const library = Koffi.load( + process.platform === 'darwin' ? '/usr/lib/libSystem.B.dylib' : 'libc.so.6', + ) + flock_ = library.func('flock', 'int', ['int', 'int']) as unknown as ( + fd: number, + operation: number, + ) => number + return flock_ +} + +type WindowsLock = { + close: (handle: unknown) => { error: number; result: number } + lock: (handle: unknown) => { error: number; result: number } + open: (path: string) => { error: number; handle: unknown } + unlock: (handle: unknown) => { error: number; result: number } +} + +function windowsLock(): WindowsLock { + if (windows_lock_) return windows_lock_ + const kernel = Koffi.load('kernel32.dll') + const handle = Koffi.pointer('HANDLE', Koffi.opaque()) + const overlapped = Koffi.struct('OVERLAPPED', { + Internal: 'uintptr_t', + InternalHigh: 'uintptr_t', + Offset: 'uint32_t', + OffsetHigh: 'uint32_t', + hEvent: handle, + }) + const overlapped_pointer = Koffi.pointer(overlapped) + const closeHandle = kernel.func('__stdcall', 'CloseHandle', 'int32_t', [handle]) as unknown as ( + handle: unknown, + ) => number + const createFile = kernel.func('__stdcall', 'CreateFileW', handle, [ + 'str16', + 'uint32_t', + 'uint32_t', + 'void *', + 'uint32_t', + 'uint32_t', + handle, + ]) as unknown as ( + path: string, + access: number, + share: number, + security: null, + creation: number, + attributes: number, + template: null, + ) => unknown + const lockFile = kernel.func('__stdcall', 'LockFileEx', 'int32_t', [ + handle, + 'uint32_t', + 'uint32_t', + 'uint32_t', + 'uint32_t', + overlapped_pointer, + ]) as unknown as ( + handle: unknown, + flags: number, + reserved: number, + length_low: number, + length_high: number, + overlapped: Record, + ) => number + const unlockFile = kernel.func('__stdcall', 'UnlockFileEx', 'int32_t', [ + handle, + 'uint32_t', + 'uint32_t', + 'uint32_t', + overlapped_pointer, + ]) as unknown as ( + handle: unknown, + reserved: number, + length_low: number, + length_high: number, + overlapped: Record, + ) => number + const getLastError = kernel.func( + '__stdcall', + 'GetLastError', + 'uint32_t', + [], + ) as unknown as () => number + const invalid_handle = (1n << BigInt(Koffi.sizeof(handle) * 8)) - 1n + const createOverlapped = () => ({ + Internal: 0, + InternalHigh: 0, + Offset: 0, + OffsetHigh: 0, + hEvent: null, + }) + + windows_lock_ = { + close(handle) { + const result = closeHandle(handle) + return { error: result === 0 ? getLastError() : 0, result } + }, + lock(handle) { + const result = lockFile( + handle, + windows_lock_exclusive | windows_lock_nonblocking, + 0, + windows_lock_length, + windows_lock_length, + createOverlapped(), + ) + return { error: result === 0 ? getLastError() : 0, result } + }, + open(path) { + const handle = createFile( + path, + windows_file_read_write, + windows_file_share, + null, + windows_file_open_always, + windows_file_attribute_normal, + null, + ) + return { + error: Koffi.address(handle) === invalid_handle ? getLastError() : 0, + handle, + } + }, + unlock(handle) { + const result = unlockFile( + handle, + 0, + windows_lock_length, + windows_lock_length, + createOverlapped(), + ) + return { error: result === 0 ? getLastError() : 0, result } + }, + } + return windows_lock_ +} + async function read(path: string): Promise> { let text: string try { diff --git a/src/core/Storage.ts b/src/core/Storage.ts index dea85524..ca47b2f2 100644 --- a/src/core/Storage.ts +++ b/src/core/Storage.ts @@ -4,6 +4,9 @@ import { Json } from 'ox' import type { MaybePromise } from '../internal/types.js' const supportsStructuredClone = Symbol.for('accounts.storage.supportsStructuredClone') +const update = Symbol.for('accounts.storage.update') + +type Update = (name: string, update: (value: value | null) => value) => MaybePromise /** Pluggable storage adapter. */ export type Storage = { @@ -13,6 +16,8 @@ export type Storage = { setItem: (name: string, value: unknown) => MaybePromise /** Removes a stored value. */ removeItem: (name: string) => MaybePromise + /** Atomically updates a stored value when supported by the adapter. */ + [update]?: Update | undefined } type StructuredStorage = Storage & { [supportsStructuredClone]?: true | undefined } @@ -25,6 +30,12 @@ export function from(storage: Storage, options: from.Options = {}): Storage { getItem: (name: string) => storage.getItem(`${prefix}${name}`), setItem: (name: string, value: unknown) => storage.setItem(`${prefix}${name}`, value), removeItem: (name: string) => storage.removeItem(`${prefix}${name}`), + ...(storage[update] + ? { + [update]: (name: string, fn: (value: value | null) => value) => + storage[update]!(`${prefix}${name}`, fn), + } + : {}), } if (canStructuredClone(storage)) return markStructuredClone(scoped) return scoped @@ -46,6 +57,29 @@ function markStructuredClone(storage: Storage): Storage { return Object.assign(storage, { [supportsStructuredClone]: true as const }) } +/** Atomically updates a value when the storage adapter supports transactions. */ +export function updateItem( + storage: Storage, + name: string, + fn: (value: value | null) => value, +): MaybePromise { + const update_ = storage[update] + if (update_) return update_(name, fn) + return Promise.resolve(storage.getItem(name)).then((value) => + storage.setItem(name, fn(value)), + ) +} + +/** Returns whether a storage adapter supports atomic updates. */ +export function supportsUpdate(storage: Storage): boolean { + return typeof storage[update] === 'function' +} + +/** Adds atomic update support to a storage implementation. */ +export function withUpdate(storage: Storage, update_: Update): Storage { + return Object.assign(storage, { [update]: update_ }) +} + /** * Combines multiple storage adapters into one. Reads return the first * non-null result; writes propagate to all storages (failures are isolated diff --git a/src/core/Store.test.ts b/src/core/Store.test.ts index 34584ade..d65d3372 100644 --- a/src/core/Store.test.ts +++ b/src/core/Store.test.ts @@ -467,6 +467,42 @@ describe('persistence', () => { expect((await getPersistedState(storage))?.chainId).toMatchInlineSnapshot(`789`) }) + + test('behavior: retries changes after a failed transactional update', async () => { + const memory = Storage.memory() + let fail = true + const storage = Storage.withUpdate( + memory, + (name: string, update: (value: value | null) => value) => { + if (fail) { + fail = false + throw new Error('write failed') + } + const current = memory.getItem(name) + if (current instanceof Promise) throw new Error('unexpected asynchronous storage') + memory.setItem(name, update(current)) + }, + ) + const { store } = await setup({ storage }) + + expect(() => + store.setState({ accounts: [{ address: account }], chainId: 456 }), + ).toThrowErrorMatchingInlineSnapshot(`[Error: write failed]`) + store.setState({ chainId: 789 }) + + expect(await getPersistedState(storage)).toMatchInlineSnapshot(` + { + "accessKeys": [], + "accounts": [ + { + "address": "0x0000000000000000000000000000000000000001", + }, + ], + "activeAccount": 0, + "chainId": 789, + } + `) + }) }) describe('waitForHydration', () => { diff --git a/src/core/Store.ts b/src/core/Store.ts index ce61b3b3..e4303b6b 100644 --- a/src/core/Store.ts +++ b/src/core/Store.ts @@ -1,3 +1,4 @@ +import { Json } from 'ox' import * as z from 'zod/mini' import type { Mutate, StoreApi } from 'zustand' import { persist } from 'zustand/middleware' @@ -105,29 +106,32 @@ export function create(options: Options): Store { : Storage.memory({ key: 'tempo' }), } = options + const initial = { accessKeys: [], accounts: [], activeAccount: 0, chainId } + const persisted_initial = { + state: serialize(initial, { + keystores, + maxAccounts, + persistCredentials, + structuredClone: canStructuredClone(storage), + }), + version: 0, + } + const storage_transactional = transactional(storage, persisted_initial) const state = createStore( subscribeWithSelector( - persist( - () => ({ - accessKeys: [], - accounts: [], - activeAccount: 0, - chainId, - }), - { - merge: (persisted, current) => hydrate(persisted, current, { schema }), - name: 'store', - partialize: (state) => - serialize(state, { - keystores, - maxAccounts, - persistCredentials, - structuredClone: canStructuredClone(storage), - }), - storage, - version: 0, - }, - ), + persist(() => initial, { + merge: (persisted, current) => hydrate(persisted, current, { schema }), + name: 'store', + partialize: (state) => + serialize(state, { + keystores, + maxAccounts, + persistCredentials, + structuredClone: canStructuredClone(storage), + }), + storage: storage_transactional, + version: 0, + }), ), ) as ZustandStore const store = state as Store @@ -137,6 +141,120 @@ export function create(options: Options): Store { return store } +type PersistedValue = { state: Persisted; version?: number | undefined } + +function transactional(storage: Storage.Storage, initial: PersistedValue): Storage.Storage { + if (!Storage.supportsUpdate(storage)) return storage + const previous = new Map([['store', initial]]) + return { + async getItem(name: string) { + const value = await storage.getItem(name) + if (value !== null) previous.set(name, value) + return value + }, + removeItem: (name) => storage.removeItem(name), + setItem(name, value) { + const before = previous.get(name) + const result = Storage.updateItem(storage, name, (current) => + mergePersisted(before, value, current, initial), + ) + if (result instanceof Promise) + return result.then(() => { + previous.set(name, value) + }) + previous.set(name, value) + }, + } +} + +function mergePersisted( + previous: unknown, + next: unknown, + current: unknown, + initial: PersistedValue, +): unknown { + if (!isPersistedValue(previous) || !isPersistedValue(next)) + throw new Error('Cannot transactionally update malformed persisted state.') + const current_ = current === null ? initial : current + if (!isPersistedValue(current_)) + throw new Error('Cannot transactionally update malformed persisted state.') + + const state = { ...current_.state } + for (const name of ['accounts', 'activeAccount', 'auth', 'chainId'] as const) { + if (equal(previous.state[name], next.state[name])) continue + if (typeof next.state[name] === 'undefined') delete state[name] + else (state as Record)[name] = next.state[name] + } + state.accessKeys = mergeAccessKeys( + previous.state.accessKeys, + next.state.accessKeys, + current_.state.accessKeys, + ) + return { ...current_, ...next, state } +} + +function mergeAccessKeys( + previous: Persisted['accessKeys'], + next: Persisted['accessKeys'], + current: Persisted['accessKeys'], +): Persisted['accessKeys'] { + if (equal(previous, next)) return current + if (!Array.isArray(previous) || !Array.isArray(next) || !Array.isArray(current)) return next + if (next.length === 0) return [] + + const result = [...current] + for (const before of previous) { + const index_next = next.findIndex((key) => sameAccessKey(key, before)) + const index_current = result.findIndex((key) => sameAccessKey(key, before)) + if (index_next === -1) { + if (index_current !== -1) result.splice(index_current, 1) + continue + } + if (index_current === -1) continue + result[index_current] = patch(result[index_current], before, next[index_next]) + } + const added = next.filter((key) => !previous.some((before) => sameAccessKey(before, key))) + return [...added, ...result] +} + +function patch(current: unknown, previous: unknown, next: unknown): unknown { + if (!isObject(current) || !isObject(previous) || !isObject(next)) return next + const result = { ...current } + for (const name of new Set([...Object.keys(previous), ...Object.keys(next)])) { + if (equal(previous[name], next[name])) continue + if (typeof next[name] === 'undefined') delete result[name] + else result[name] = next[name] + } + return result +} + +function sameAccessKey(a: unknown, b: unknown): boolean { + if (!isObject(a) || !isObject(b)) return false + return ( + sameHex(a.address, b.address) && + sameHex(a.access, b.access) && + a.chainId === b.chainId && + a.keyType === b.keyType + ) +} + +function sameHex(a: unknown, b: unknown): boolean { + if (typeof a !== 'string' || typeof b !== 'string') return a === b + return a.toLowerCase() === b.toLowerCase() +} + +function equal(a: unknown, b: unknown): boolean { + return Json.stringify(a) === Json.stringify(b) +} + +function isObject(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function isPersistedValue(value: unknown): value is PersistedValue { + return isObject(value) && isObject(value.state) +} + /** Converts runtime provider state into the persisted refresh snapshot. */ function serialize(state: State, options: serialize.Options = {}): Persisted { const { keystores, maxAccounts, persistCredentials = true, structuredClone = false } = options