Skip to content
Closed
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
121 changes: 121 additions & 0 deletions src/main/rate-limits/cursor-desktop-state-db.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { mkdtempSync, rmSync, statSync, truncateSync, writeFileSync } from 'node:fs'
import type * as NodeFs from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CURSOR_DESKTOP_WAL_SKIP_BYTES, readCursorDesktopProfile } from './cursor-desktop-state-db'

vi.mock('node:fs', async () => {
const actual = await vi.importActual<typeof NodeFs>('node:fs')
return {
...actual,
statSync: (...args: Parameters<typeof NodeFs.statSync>) => {
if (String(args[0]).endsWith('stat-denied.vscdb-wal')) {
throw Object.assign(new Error('denied'), { code: 'EACCES' })
}
return actual.statSync(...args)
}
}
})

const dirs: string[] = []

afterEach(() => {
for (const dir of dirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})

function tempDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'cursor-desktop-'))
dirs.push(dir)
return dir
}

function seedDatabase(dir: string): string {
const dbPath = join(dir, 'state.vscdb')
const db = new DatabaseSync(dbPath)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
db.exec('CREATE TABLE ItemTable (key TEXT, value TEXT)')
db.prepare('INSERT INTO ItemTable (key, value) VALUES (?, ?)').run(
'cursorAuth/accessToken',
'token-1'
)
db.close()
return dbPath
}

function seedWalDatabase(dir: string): { dbPath: string; close: () => void } {
const dbPath = join(dir, 'state.vscdb')
const db = new DatabaseSync(dbPath)
db.exec('PRAGMA journal_mode = WAL')
db.exec('CREATE TABLE ItemTable (key TEXT, value TEXT)')
db.prepare('INSERT INTO ItemTable (key, value) VALUES (?, ?)').run(
'cursorAuth/accessToken',
'wal-token'
)
return { dbPath, close: () => db.close() }
}

describe('readCursorDesktopProfile', () => {
it('reads a desktop login from a normal database', () => {
const read = readCursorDesktopProfile(seedDatabase(tempDir()))
expect(read).toEqual({
status: 'ok',
profile: {
accessToken: 'token-1',
email: null,
membershipType: null,
subscriptionStatus: null
}
})
})

it('reports a missing database without opening one', () => {
expect(readCursorDesktopProfile(join(tempDir(), 'missing.vscdb'))).toEqual({
status: 'missing'
})
})

it('reads a login from a WAL database that is below the skip limit', () => {
const seeded = seedWalDatabase(tempDir())
try {
expect(statSync(`${seeded.dbPath}-wal`).isFile()).toBe(true)
expect(statSync(`${seeded.dbPath}-wal`).size).toBeLessThanOrEqual(CURSOR_DESKTOP_WAL_SKIP_BYTES)
expect(readCursorDesktopProfile(seeded.dbPath)).toEqual({
status: 'ok',
profile: {
accessToken: 'wal-token',
email: null,
membershipType: null,
subscriptionStatus: null
}
})
} finally {
seeded.close()
}
})

it('does not open a database whose WAL is large enough to stall the main thread', () => {
const dbPath = join(tempDir(), 'state.vscdb')
writeFileSync(dbPath, 'not-a-database')
const walPath = `${dbPath}-wal`
writeFileSync(walPath, '')
truncateSync(walPath, CURSOR_DESKTOP_WAL_SKIP_BYTES + 1)

expect(readCursorDesktopProfile(dbPath)).toEqual({
status: 'skipped',
reason: 'wal-too-large'
})
})

it('does not open a database when the WAL size cannot be read', () => {
const dbPath = join(tempDir(), 'stat-denied.vscdb')
writeFileSync(dbPath, 'not-a-database')

expect(readCursorDesktopProfile(dbPath)).toEqual({
status: 'skipped',
reason: 'wal-unreadable'
})
})
})
27 changes: 26 additions & 1 deletion src/main/rate-limits/cursor-desktop-state-db.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync } from 'node:fs'
import { existsSync, statSync } from 'node:fs'
import { z } from 'zod'
import SyncDatabase from '../sqlite/sync-database'

Expand All @@ -7,6 +7,9 @@ const EMAIL_KEY = 'cursorAuth/cachedEmail'
const MEMBERSHIP_KEY = 'cursorAuth/stripeMembershipType'
const SUBSCRIPTION_KEY = 'cursorAuth/stripeSubscriptionStatus'
const OPEN_TIMEOUT_MS = 250
// Why: node:sqlite's timeout only bounds a lock wait. Opening a multi-GB WAL
// replays it on the main thread before the first prepare returns (#24360).
export const CURSOR_DESKTOP_WAL_SKIP_BYTES = 32 * 1024 * 1024

export type CursorDesktopProfile = {
accessToken: string | null
Expand All @@ -18,6 +21,7 @@ export type CursorDesktopProfile = {
export type CursorDesktopProfileReadResult =
| { status: 'missing' }
| { status: 'error'; error: string }
| { status: 'skipped'; reason: 'wal-too-large' | 'wal-unreadable' }
| { status: 'ok'; profile: CursorDesktopProfile }

const rowsSchema = z.array(z.object({ key: z.unknown(), value: z.unknown() }).partial())
Expand All @@ -32,11 +36,32 @@ function valueAsString(value: unknown): string | null {
return null
}

function isMissingPath(error: unknown): boolean {
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'
}

/** Size of the sibling `-wal` file. A missing file is 0. Any other stat failure is unreadable. */
function cursorDesktopWalBytes(dbPath: string): number | null {
try {
const stat = statSync(`${dbPath}-wal`)
return stat.isFile() ? stat.size : 0
} catch (error) {
return isMissingPath(error) ? 0 : null
}
}

/** Reads the Cursor IDE's stored session. Opens read-only in place; state.vscdb can be multi-GB. */
export function readCursorDesktopProfile(dbPath: string): CursorDesktopProfileReadResult {
if (!existsSync(dbPath)) {
return { status: 'missing' }
}
const walBytes = cursorDesktopWalBytes(dbPath)
if (walBytes === null) {
return { status: 'skipped', reason: 'wal-unreadable' }
}
if (walBytes > CURSOR_DESKTOP_WAL_SKIP_BYTES) {
return { status: 'skipped', reason: 'wal-too-large' }
}
let db: SyncDatabase | null = null
try {
db = new SyncDatabase(dbPath, {
Expand Down
Loading