fix(browser): retire cookie snapshots when preparation fails (#26218)

This commit is contained in:
Neil
2026-10-07 22:41:43 -07:00
committed by GitHub
parent efb071b286
commit 7e60664ced
2 changed files with 469 additions and 175 deletions
@@ -142,185 +142,198 @@ export async function prepareChromiumCookieImport(
}
}
// Why: Chromium timestamps (µs since 1601) can exceed Number.MAX_SAFE_INTEGER; readBigInts avoids precision loss.
sourceDb = new DatabaseSync(sourceSnapshot.databasePath, { readOnly: true, readBigInts: true })
let targetColumnInfo: ChromiumCookieColumnInfo[] | null = null
let colList: string | null = null
let placeholders: string | null = null
if (stagingAvailable) {
// Why: the staged file is Orca's own partition DB, also named "Cookies", so the same
// transient AV handle can make opening it throw — degrade instead of killing the import.
try {
stagingDb = new DatabaseSync(stagingCookiesPath)
// Why (STA-4797): a new-format stage must be one self-contained file. Otherwise a lost WAL
// can erase its scope marker and make cold-start replay mistake it for a legacy whole-image
// import, restoring the unrelated-cookie data loss this format is meant to prevent.
stagingDb.exec('PRAGMA journal_mode = DELETE')
targetColumnInfo = stagingDb
.prepare('PRAGMA table_info(cookies)')
.all() as ChromiumCookieColumnInfo[]
const targetCols = targetColumnInfo.map((row) => row.name)
colList = targetCols.join(', ')
placeholders = targetCols.map(() => '?').join(', ')
} catch (err) {
diag(` staging database unusable, restart fallback disabled: ${String(err)}`)
stagingAvailable = false
targetColumnInfo = null
colList = null
placeholders = null
closeStagingDb()
// Why: the copy holds real partition cookies; discard it now rather than at the exit branches.
discardStagingFile()
}
}
// Why (STA-4300): the partition columns drift across Chromium versions, so read the source
// schema rather than assuming a row's missing column means "unpartitioned".
const sourceColumns = new Set(
(sourceDb.prepare('PRAGMA table_info(cookies)').all() as ChromiumCookieColumnInfo[]).map(
(column) => column.name
)
)
const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record<
string,
unknown
>[]
sourceDb.close()
sourceDb = null
diag(` source has ${sourceRows.length} cookies`)
if (sourceRows.length === 0) {
closeStagingDb()
discardStagingFile()
return { result: { ok: false, reason: `No cookies found in ${browser.label}.` } }
}
// Why (STA-4300): partition fidelity is a property of the source row, even when its value
// cannot be decrypted. Plan first so decryption failure cannot discard a family's skip.
const partitionCandidates = sourceRows.flatMap((sourceRow) => {
const domain = sourceRow.host_key as string
const name = sourceRow.name as string
return isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain)
? []
: [{ sourceRow, domain, partition: readChromiumRowPartition(sourceRow, sourceColumns) }]
})
const nativePlan = planImportWrites(partitionCandidates)
const plannedSourceRows = new Set(nativePlan.writes.map((candidate) => candidate.sourceRow))
const partitionBySourceRow = new Map(
partitionCandidates.map((candidate) => [candidate.sourceRow, candidate.partition])
)
// Why (§4.3c): a family we cannot name is one we cannot exclude from the clear, and clearing a
// family we cannot protect is the P0. Refuse before the jar is touched.
if (nativePlan.hasUnrepresentableSkip) {
closeStagingDb()
discardStagingFile()
return {
result: {
ok: false,
reason:
'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.'
let ownershipTransferred = false
try {
// Why: Chromium timestamps (µs since 1601) can exceed Number.MAX_SAFE_INTEGER; readBigInts avoids precision loss.
sourceDb = new DatabaseSync(sourceSnapshot.databasePath, { readOnly: true, readBigInts: true })
let targetColumnInfo: ChromiumCookieColumnInfo[] | null = null
let colList: string | null = null
let placeholders: string | null = null
if (stagingAvailable) {
// Why: the staged file is Orca's own partition DB, also named "Cookies", so the same
// transient AV handle can make opening it throw — degrade instead of killing the import.
try {
stagingDb = new DatabaseSync(stagingCookiesPath)
// Why (STA-4797): a new-format stage must be one self-contained file. Otherwise a lost WAL
// can erase its scope marker and make cold-start replay mistake it for a legacy whole-image
// import, restoring the unrelated-cookie data loss this format is meant to prevent.
stagingDb.exec('PRAGMA journal_mode = DELETE')
targetColumnInfo = stagingDb
.prepare('PRAGMA table_info(cookies)')
.all() as ChromiumCookieColumnInfo[]
const targetCols = targetColumnInfo.map((row) => row.name)
colList = targetCols.join(', ')
placeholders = targetCols.map(() => '?').join(', ')
} catch (err) {
diag(` staging database unusable, restart fallback disabled: ${String(err)}`)
stagingAvailable = false
targetColumnInfo = null
colList = null
placeholders = null
closeStagingDb()
// Why: the copy holds real partition cookies; discard it now rather than at the exit branches.
discardStagingFile()
}
}
}
const needsSourceKey = sourceRows.some((sourceRow) => {
const encrypted = sourceRow.encrypted_value
if (!(encrypted instanceof Uint8Array) || encrypted.length === 0) {
return false
}
return (
!isGoogleSourceBoundCookie(sourceRow.name as string, sourceRow.host_key as string) &&
!isNonTransplantableCookieDomain(sourceRow.host_key as string)
)
})
const sourceKey = needsSourceKey
? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser)
: null
if (needsSourceKey && !sourceKey) {
closeStagingDb()
// Why: key denial happens after staging, so clean up the target DB copy or retries pile up.
discardStagingFile()
return {
result: {
ok: false,
reason: `Could not access ${browser.label} encryption key. The OS may have denied access.`
}
}
}
// Why: staging only backs the cold-restart replay, so any failure writing it disables that
// fallback instead of aborting an import whose in-memory half still works.
let insertStmt: ChromiumImportContext['insertStmt'] = null
const context: ChromiumImportContext = {
browser,
targetPartition,
options,
targetSession,
stagingCookiesPath,
stagingAvailable,
sourceSnapshot,
sourceDb,
stagingDb,
targetColumnInfo,
colList,
placeholders,
sourceColumns,
sourceRows,
nativePlan,
plannedSourceRows,
partitionBySourceRow,
sourceKey,
imported: 0,
skipped: 0,
decryptFailed: 0,
appBoundFailed: 0,
keyringUnavailableFailed: 0,
integritySkipped: 0,
nonTransplantableSkipped: 0,
partitionSkipped: nativePlan.skips.length,
googleCookiesSkipped: 0,
memoryLoaded: 0,
memoryFailed: 0,
domainSet: new Set<string>(),
decryptedCookies: [],
// Why: the staging insert needs the RAW source row, so each scanned candidate carries it.
// A plan record holding only the derived fields compiles fine and then cannot stage.
scanned: [],
sourceDomainValidity: new Map<string, boolean>(),
insertStmt,
importScope: {
exact: new Set<string>(),
ancestors: new Set<string>(),
descendantRoots: new Set<string>()
},
closeStagingDb,
discardStagingFile,
disableStaging: (reason: string): void => {
diag(` staging disabled, restart fallback unavailable: ${reason}`)
context.stagingAvailable = false
context.insertStmt = null
context.closeStagingDb()
context.discardStagingFile()
}
} satisfies ChromiumImportContext
if (context.stagingDb && context.colList && context.placeholders) {
try {
context.insertStmt = context.stagingDb.prepare(
`INSERT OR REPLACE INTO cookies (${context.colList}) VALUES (${context.placeholders})`
// Why (STA-4300): the partition columns drift across Chromium versions, so read the source
// schema rather than assuming a row's missing column means "unpartitioned".
const sourceColumns = new Set(
(sourceDb.prepare('PRAGMA table_info(cookies)').all() as ChromiumCookieColumnInfo[]).map(
(column) => column.name
)
context.stagingDb.exec('BEGIN TRANSACTION')
} catch (err) {
context.disableStaging(String(err))
}
} else if (context.stagingAvailable) {
context.disableStaging('staged database exposed no cookies columns')
}
// Why: keep the existing conservative fallback boundary for family-level omissions. Expanding
// partial-import restart behavior is separate from narrowing what a staged replay may replace.
if (context.nativePlan.skippedFamilies.size > 0) {
context.disableStaging(
`${context.nativePlan.skippedFamilies.size} preserved cookie families cannot be represented in a staged image`
)
const sourceRows = sourceDb.prepare('SELECT * FROM cookies ORDER BY rowid').all() as Record<
string,
unknown
>[]
sourceDb.close()
sourceDb = null
diag(` source has ${sourceRows.length} cookies`)
if (sourceRows.length === 0) {
return { result: { ok: false, reason: `No cookies found in ${browser.label}.` } }
}
// Why (STA-4300): partition fidelity is a property of the source row, even when its value
// cannot be decrypted. Plan first so decryption failure cannot discard a family's skip.
const partitionCandidates = sourceRows.flatMap((sourceRow) => {
const domain = sourceRow.host_key as string
const name = sourceRow.name as string
return isGoogleSourceBoundCookie(name, domain) || isNonTransplantableCookieDomain(domain)
? []
: [{ sourceRow, domain, partition: readChromiumRowPartition(sourceRow, sourceColumns) }]
})
const nativePlan = planImportWrites(partitionCandidates)
const plannedSourceRows = new Set(nativePlan.writes.map((candidate) => candidate.sourceRow))
const partitionBySourceRow = new Map(
partitionCandidates.map((candidate) => [candidate.sourceRow, candidate.partition])
)
// Why (§4.3c): a family we cannot name is one we cannot exclude from the clear, and clearing a
// family we cannot protect is the P0. Refuse before the jar is touched.
if (nativePlan.hasUnrepresentableSkip) {
return {
result: {
ok: false,
reason:
'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.'
}
}
}
const needsSourceKey = sourceRows.some((sourceRow) => {
const encrypted = sourceRow.encrypted_value
if (!(encrypted instanceof Uint8Array) || encrypted.length === 0) {
return false
}
return (
!isGoogleSourceBoundCookie(sourceRow.name as string, sourceRow.host_key as string) &&
!isNonTransplantableCookieDomain(sourceRow.host_key as string)
)
})
const sourceKey = needsSourceKey
? getEncryptionKey(browser.keychainService!, browser.keychainAccount!, browser)
: null
if (needsSourceKey && !sourceKey) {
return {
result: {
ok: false,
reason: `Could not access ${browser.label} encryption key. The OS may have denied access.`
}
}
}
// Why: staging only backs the cold-restart replay, so any failure writing it disables that
// fallback instead of aborting an import whose in-memory half still works.
let insertStmt: ChromiumImportContext['insertStmt'] = null
const context: ChromiumImportContext = {
browser,
targetPartition,
options,
targetSession,
stagingCookiesPath,
stagingAvailable,
sourceSnapshot,
sourceDb,
stagingDb,
targetColumnInfo,
colList,
placeholders,
sourceColumns,
sourceRows,
nativePlan,
plannedSourceRows,
partitionBySourceRow,
sourceKey,
imported: 0,
skipped: 0,
decryptFailed: 0,
appBoundFailed: 0,
keyringUnavailableFailed: 0,
integritySkipped: 0,
nonTransplantableSkipped: 0,
partitionSkipped: nativePlan.skips.length,
googleCookiesSkipped: 0,
memoryLoaded: 0,
memoryFailed: 0,
domainSet: new Set<string>(),
decryptedCookies: [],
// Why: the staging insert needs the RAW source row, so each scanned candidate carries it.
// A plan record holding only the derived fields compiles fine and then cannot stage.
scanned: [],
sourceDomainValidity: new Map<string, boolean>(),
insertStmt,
importScope: {
exact: new Set<string>(),
ancestors: new Set<string>(),
descendantRoots: new Set<string>()
},
closeStagingDb,
discardStagingFile,
disableStaging: (reason: string): void => {
diag(` staging disabled, restart fallback unavailable: ${reason}`)
context.stagingAvailable = false
context.insertStmt = null
context.closeStagingDb()
context.discardStagingFile()
}
} satisfies ChromiumImportContext
if (context.stagingDb && context.colList && context.placeholders) {
try {
context.insertStmt = context.stagingDb.prepare(
`INSERT OR REPLACE INTO cookies (${context.colList}) VALUES (${context.placeholders})`
)
context.stagingDb.exec('BEGIN TRANSACTION')
} catch (err) {
context.disableStaging(String(err))
}
} else if (context.stagingAvailable) {
context.disableStaging('staged database exposed no cookies columns')
}
// Why: keep the existing conservative fallback boundary for family-level omissions. Expanding
// partial-import restart behavior is separate from narrowing what a staged replay may replace.
if (context.nativePlan.skippedFamilies.size > 0) {
context.disableStaging(
`${context.nativePlan.skippedFamilies.size} preserved cookie families cannot be represented in a staged image`
)
}
// Why: the importer owns successful preparation, including any restart fallback.
ownershipTransferred = true
return { context }
} finally {
if (!ownershipTransferred) {
try {
sourceDb?.close()
} catch {
/* best-effort */
}
closeStagingDb()
discardStagingFile()
try {
sourceSnapshot.cleanup()
} catch (err) {
diag(` Chromium snapshot cleanup failed: ${String(err)}`)
}
}
}
return { context }
}
@@ -0,0 +1,281 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as NodeSqlite from 'node:sqlite'
import type * as ChromiumCookieSnapshotModule from './chromium-cookie-snapshot'
import type { DetectedBrowser } from './browser-cookie-detection-types'
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'
import { basename, dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
const {
appGetPathMock,
sessionFromPartitionMock,
runProcessSyncMock,
snapshotRootMock,
setPendingCookieImportMock,
clearPendingCookieImportMock,
writeCookieIdentityMock,
openedDatabases,
cleanupStates
} = vi.hoisted(() => {
const openedDatabases: { path: string; database: NodeSqlite.DatabaseSync }[] = []
const cleanupStates: boolean[][] = []
return {
appGetPathMock: vi.fn(),
sessionFromPartitionMock: vi.fn(),
runProcessSyncMock: vi.fn(),
snapshotRootMock: vi.fn(),
setPendingCookieImportMock: vi.fn(),
clearPendingCookieImportMock: vi.fn(),
writeCookieIdentityMock: vi.fn(),
openedDatabases,
cleanupStates
}
})
vi.mock('electron', () => ({
app: { getPath: appGetPathMock },
session: { fromPartition: sessionFromPartitionMock }
}))
vi.mock('../../shared/child-process/run-process', () => ({
runProcessSync: runProcessSyncMock
}))
vi.mock('./browser-session-registry', () => ({
browserSessionRegistry: {
setPendingCookieImport: setPendingCookieImportMock,
clearPendingCookieImport: clearPendingCookieImportMock
}
}))
vi.mock('node:sqlite', async (importOriginal) => {
const actual = await importOriginal<typeof NodeSqlite>()
return {
...actual,
DatabaseSync: class extends actual.DatabaseSync {
constructor(...args: ConstructorParameters<typeof actual.DatabaseSync>) {
super(...args)
openedDatabases.push({ path: String(args[0]), database: this })
}
}
}
})
vi.mock('./chromium-cookie-snapshot', async (importOriginal) => {
const actual = await importOriginal<typeof ChromiumCookieSnapshotModule>()
return {
...actual,
createChromiumCookieSnapshot: (sourcePath: string) => {
const snapshot = actual.createChromiumCookieSnapshot(sourcePath, {
tempRoot: snapshotRootMock()
})
return {
...snapshot,
cleanup: () => {
cleanupStates.push(
openedDatabases
.filter(
({ path }) =>
path === snapshot.databasePath || path.includes('cookie-import-staging')
)
.map(({ database }) => database.isOpen)
)
snapshot.cleanup()
}
}
}
}
})
vi.mock('./browser-cookie-clear-store', () => ({
openCookieClearStore: (targetSession: {
cookies: { get: () => Promise<unknown>; remove: (url: string, name: string) => Promise<void> }
}) => ({
get: () => targetSession.cookies.get(),
remove: (url: string, name: string) => targetSession.cookies.remove(url, name),
snapshotClearIdentities: async () => [],
restoreClearIdentities: async () => undefined,
writeCookieIdentity: writeCookieIdentityMock,
dispose: () => undefined
})
}))
import { importChromiumCookies } from './browser-cookie-chromium-import'
import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database'
import { DatabaseSync } from 'node:sqlite'
const PARTITION = 'persist:test'
function chromeBrowser(cookiesPath: string): DetectedBrowser {
return {
family: 'chrome',
label: 'Google Chrome',
cookiesPath,
keychainService: 'Chrome Safe Storage',
keychainAccount: 'Chrome',
profiles: [{ name: 'Default', directory: 'Default' }],
selectedProfile: 'Default'
}
}
describe('Chromium import snapshot ownership', () => {
let root: string
let sourcePath: string
let targetPath: string
let snapshotRoot: string
let stagingRoot: string
let sourceDatabase: NodeSqlite.DatabaseSync
let targetBefore: Buffer
let cookiesRemoveMock: ReturnType<typeof vi.fn>
let platformSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'orca-cookie-snapshot-owner-test-'))
snapshotRoot = root
sourcePath = join(root, 'Chrome', 'Default', 'Network', 'Cookies')
targetPath = join(root, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
stagingRoot = join(root, 'userData', 'cookie-import-staging')
sourceDatabase = createChromiumCookieTestDatabase(sourcePath, [], { journalMode: 'wal' })
createChromiumCookieTestDatabase(targetPath, [
{ domain: '.other.test', name: 'old', value: 'target' }
]).close()
targetBefore = readFileSync(targetPath)
cookiesRemoveMock = vi.fn().mockResolvedValue(undefined)
appGetPathMock.mockReturnValue(join(root, 'userData'))
snapshotRootMock.mockReturnValue(snapshotRoot)
sessionFromPartitionMock.mockReturnValue({
getStoragePath: () => dirname(dirname(targetPath)),
cookies: {
flushStore: vi.fn().mockResolvedValue(undefined),
get: vi.fn().mockResolvedValue([]),
remove: cookiesRemoveMock,
set: vi.fn().mockRejectedValue(new Error('Unexpected target initialization'))
}
})
runProcessSyncMock.mockReturnValue({
code: 1,
signal: null,
stdout: '',
stderr: '',
timedOut: false
})
writeCookieIdentityMock.mockResolvedValue(undefined)
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
cleanupStates.length = 0
})
afterEach(() => {
for (const { database } of openedDatabases) {
if (database.isOpen) {
database.close()
}
}
openedDatabases.length = 0
platformSpy.mockRestore()
vi.clearAllMocks()
rmSync(root, { recursive: true, force: true })
})
function snapshotDirectories() {
return readdirSync(snapshotRoot).filter((name) => name.startsWith('orca-cookie-import-'))
}
function expectRefusalCleanup() {
expect(snapshotDirectories()).toEqual([])
expect(readdirSync(stagingRoot)).toEqual([])
expect(readFileSync(targetPath)).toEqual(targetBefore)
expect(writeCookieIdentityMock).not.toHaveBeenCalled()
expect(cookiesRemoveMock).not.toHaveBeenCalled()
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
expect(cleanupStates.every((states) => states.every((open) => !open))).toBe(true)
}
function insertSourceCookie(encrypted: boolean) {
sourceDatabase.exec(`INSERT INTO cookies (
creation_utc, host_key, name, value, encrypted_value, path, expires_utc,
is_secure, is_httponly, samesite
) VALUES (133000000000000, '.example.test', 'sid',
${encrypted ? "''" : "'source-value'"},
${encrypted ? "X'763130656E63727970746564'" : "X''"}, '/', 0, 0, 0, -1)`)
}
it('retires an empty live-WAL source snapshot before returning its existing refusal', async () => {
expect(existsSync(`${sourcePath}-wal`)).toBe(true)
expect(await importChromiumCookies(chromeBrowser(sourcePath), PARTITION)).toEqual({
ok: false,
reason: 'No cookies found in Google Chrome.'
})
expect(cleanupStates).toHaveLength(1)
expectRefusalCleanup()
})
it('retires every snapshot when denied credential access is retried', async () => {
insertSourceCookie(true)
for (let attempt = 0; attempt < 3; attempt++) {
expect(await importChromiumCookies(chromeBrowser(sourcePath), PARTITION)).toEqual({
ok: false,
reason: 'Could not access Google Chrome encryption key. The OS may have denied access.'
})
expectRefusalCleanup()
}
expect(runProcessSyncMock).toHaveBeenCalledTimes(3)
expect(cleanupStates).toHaveLength(3)
})
it('retires the snapshot when an unreadable partition has no preservable family', async () => {
insertSourceCookie(false)
sourceDatabase.exec(
"UPDATE cookies SET host_key = 'com', top_frame_site_key = 'https://top.test', has_cross_site_ancestor = 2"
)
expect(await importChromiumCookies(chromeBrowser(sourcePath), PARTITION)).toEqual({
ok: false,
reason:
'Could not import: a cookie with an unreadable site partition has no registrable domain, so its existing session cannot be protected.'
})
expect(cleanupStates).toHaveLength(1)
expectRefusalCleanup()
})
it('closes private databases before cleanup when source schema preparation throws', async () => {
sourceDatabase.exec('DROP TABLE cookies; CREATE TABLE other_data(value TEXT)')
expect(await importChromiumCookies(chromeBrowser(sourcePath), PARTITION)).toEqual({
ok: false,
reason: expect.stringContaining('no such table: cookies')
})
expect(cleanupStates).toHaveLength(1)
expect(cleanupStates[0].length).toBeGreaterThanOrEqual(2)
expectRefusalCleanup()
})
it('hands successful preparation to the importer for ordinary completion cleanup', async () => {
insertSourceCookie(false)
const result = await importChromiumCookies(chromeBrowser(sourcePath), PARTITION)
expect(result).toMatchObject({ ok: true, summary: { totalCookies: 1, importedCookies: 1 } })
expect(writeCookieIdentityMock).toHaveBeenCalledWith(
expect.objectContaining({ name: 'sid', value: 'source-value' })
)
expect(snapshotDirectories()).toEqual([])
expect(readdirSync(stagingRoot)).toEqual([])
expect(cleanupStates).toEqual([[false, false]])
})
it('removes the source snapshot while preserving a usable stage owned by restart replay', async () => {
insertSourceCookie(false)
writeCookieIdentityMock.mockRejectedValue(new Error('Cookie rejected'))
expect(await importChromiumCookies(chromeBrowser(sourcePath), PARTITION)).toMatchObject({
ok: true
})
expect(snapshotDirectories()).toEqual([])
expect(cleanupStates).toEqual([[false, false]])
expect(setPendingCookieImportMock).toHaveBeenCalledTimes(1)
const stagedPath: unknown = setPendingCookieImportMock.mock.calls[0][1]
if (typeof stagedPath !== 'string') {
throw new Error('No staged replay path')
}
expect(readdirSync(stagingRoot)).toEqual([basename(stagedPath)])
const stage = new DatabaseSync(stagedPath, { readOnly: true })
try {
const row = stage
.prepare("SELECT CAST(value AS TEXT) AS value FROM cookies WHERE name = 'sid'")
.get()
expect(row).toEqual({ value: 'source-value' })
} finally {
stage.close()
}
})
})