mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 16:02:46 +00:00
fix(browser): retire cookie snapshots when preparation fails (#26218)
This commit is contained in:
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user