mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
Revert "fix(browser): route every cookie-import write through CDP identities …" (#14942)
This reverts commit bf6dc6fcba.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Cookie } from 'electron'
|
||||
import {
|
||||
cdpSetCookieParamsFromIdentity,
|
||||
cdpRestoreParamsFromIdentity,
|
||||
cookieClearIdentitiesFromCdp
|
||||
} from './browser-cookie-clear-store'
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('cookie clear CDP identities', () => {
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
})
|
||||
])
|
||||
expect(cdpSetCookieParamsFromIdentity(identities[0])).toEqual(
|
||||
expect(cdpRestoreParamsFromIdentity(identities[0])).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'chips-auth',
|
||||
sameSite: 'None',
|
||||
@@ -72,8 +72,8 @@ describe('cookie clear CDP identities', () => {
|
||||
expect.objectContaining({ value: 'host', domain: 'example.com', hostOnly: true }),
|
||||
expect.objectContaining({ value: 'domain', domain: '.example.com', hostOnly: false })
|
||||
])
|
||||
expect(cdpSetCookieParamsFromIdentity(identities[0])).not.toHaveProperty('domain')
|
||||
expect(cdpSetCookieParamsFromIdentity(identities[1])).toHaveProperty('domain', '.example.com')
|
||||
expect(cdpRestoreParamsFromIdentity(identities[0])).not.toHaveProperty('domain')
|
||||
expect(cdpRestoreParamsFromIdentity(identities[1])).toHaveProperty('domain', '.example.com')
|
||||
})
|
||||
|
||||
it('indexes CDP cookies once instead of rescanning the jar for every cookie', () => {
|
||||
@@ -99,7 +99,7 @@ describe('cookie clear CDP identities', () => {
|
||||
|
||||
it('does not turn an unspecified SameSite policy into explicit Lax', () => {
|
||||
expect(
|
||||
cdpSetCookieParamsFromIdentity({
|
||||
cdpRestoreParamsFromIdentity({
|
||||
url: 'https://example.com/',
|
||||
name: 'unspecified',
|
||||
value: 'value',
|
||||
|
||||
@@ -4,8 +4,7 @@ import { normalizeCookieDomain } from './browser-cookie-import-policy'
|
||||
import type {
|
||||
CookieClearIdentity,
|
||||
CookieClearPartitionKey,
|
||||
CookieClearStore,
|
||||
CookieImportWriteStore
|
||||
CookieClearStore
|
||||
} from './browser-cookie-import-clear'
|
||||
|
||||
type CdpCookiePartitionKey = {
|
||||
@@ -121,24 +120,25 @@ function identityFromCdpCookie(url: string, cdpCookie: CdpCookie): CookieClearId
|
||||
}
|
||||
}
|
||||
|
||||
function openHiddenCookieWindow(targetSession: Session): BrowserWindow {
|
||||
return new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
session: targetSession,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function leaseHiddenCookieDebugger(targetSession: Session): Promise<CookieClearSession> {
|
||||
const window = openHiddenCookieWindow(targetSession)
|
||||
async function attachCookieClearSession(targetSession: Session): Promise<CookieClearSession> {
|
||||
const existing = findPartitionWebContents(targetSession)
|
||||
const window = existing
|
||||
? null
|
||||
: new BrowserWindow({
|
||||
show: false,
|
||||
webPreferences: {
|
||||
session: targetSession,
|
||||
sandbox: true,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false
|
||||
}
|
||||
})
|
||||
try {
|
||||
await window.loadURL('data:text/html,<!doctype html><title>cookie-clear</title>')
|
||||
const contents = window.webContents
|
||||
if (contents.isDestroyed()) {
|
||||
if (window) {
|
||||
await window.loadURL('data:text/html,<!doctype html><title>cookie-clear</title>')
|
||||
}
|
||||
const contents = existing ?? window?.webContents
|
||||
if (!contents || contents.isDestroyed()) {
|
||||
throw new Error('Could not attach to the cookie session for an atomic clear')
|
||||
}
|
||||
const lease = acquireElectronDebugger(contents)
|
||||
@@ -146,30 +146,15 @@ async function leaseHiddenCookieDebugger(targetSession: Session): Promise<Cookie
|
||||
debugger: contents.debugger,
|
||||
dispose: () => {
|
||||
lease.release()
|
||||
window.destroy()
|
||||
window?.destroy()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
window.destroy()
|
||||
window?.destroy()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function attachCookieClearSession(targetSession: Session): Promise<CookieClearSession> {
|
||||
const existing = findPartitionWebContents(targetSession)
|
||||
if (!existing) {
|
||||
return leaseHiddenCookieDebugger(targetSession)
|
||||
}
|
||||
try {
|
||||
const lease = acquireElectronDebugger(existing)
|
||||
return { debugger: existing.debugger, dispose: () => lease.release() }
|
||||
} catch {
|
||||
// Why (STA-4300): every cookie write now needs this channel, and attaching to a live tab fails
|
||||
// outright when DevTools already owns its debugger. A hidden window of our own always can.
|
||||
return leaseHiddenCookieDebugger(targetSession)
|
||||
}
|
||||
}
|
||||
|
||||
export function cookieClearIdentitiesFromCdp(
|
||||
cookies: readonly { cookie: Cookie; url: string }[],
|
||||
cdpCookies: readonly CdpCookie[]
|
||||
@@ -206,7 +191,7 @@ export function cookieClearIdentitiesFromCdp(
|
||||
return identities
|
||||
}
|
||||
|
||||
export function cdpSetCookieParamsFromIdentity(
|
||||
export function cdpRestoreParamsFromIdentity(
|
||||
identity: CookieClearIdentity
|
||||
): Record<string, unknown> {
|
||||
const sameSite = cdpSameSite(identity.sameSite)
|
||||
@@ -247,34 +232,24 @@ async function snapshotClearIdentitiesFromCdp(
|
||||
return cookieClearIdentitiesFromCdp(cookies, cdpCookiesFromCommand(result))
|
||||
}
|
||||
|
||||
async function writeIdentityWithCdp(
|
||||
cookieDebugger: CookieClearDebugger,
|
||||
identity: CookieClearIdentity,
|
||||
failureLabel: string
|
||||
): Promise<void> {
|
||||
const result = await cookieDebugger.sendCommand(
|
||||
'Network.setCookie',
|
||||
cdpSetCookieParamsFromIdentity(identity)
|
||||
)
|
||||
// Why: Network.setCookie reports rejection in the reply rather than throwing, so an unchecked
|
||||
// call reads as a successful write of a cookie that was never stored.
|
||||
if (!cdpSetCookieSucceeded(result)) {
|
||||
throw new Error(`Could not ${failureLabel} cookie ${identity.name}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreClearIdentitiesWithCdp(
|
||||
cookieDebugger: CookieClearDebugger,
|
||||
identities: readonly CookieClearIdentity[]
|
||||
): Promise<void> {
|
||||
for (const identity of identities) {
|
||||
await writeIdentityWithCdp(cookieDebugger, identity, 'restore')
|
||||
const result = await cookieDebugger.sendCommand(
|
||||
'Network.setCookie',
|
||||
cdpRestoreParamsFromIdentity(identity)
|
||||
)
|
||||
if (!cdpSetCookieSucceeded(result)) {
|
||||
throw new Error(`Could not restore cookie ${identity.name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function openCookieClearStore(
|
||||
targetSession: Session
|
||||
): CookieClearStore & CookieImportWriteStore & { dispose: () => void } {
|
||||
): CookieClearStore & { dispose: () => void } {
|
||||
let attached: CookieClearSession | null = null
|
||||
let pendingAttach: Promise<CookieClearSession> | null = null
|
||||
let disposed = false
|
||||
@@ -312,8 +287,6 @@ export function openCookieClearStore(
|
||||
snapshotClearIdentitiesFromCdp((await attach()).debugger, cookies),
|
||||
restoreClearIdentities: async (identities) =>
|
||||
restoreClearIdentitiesWithCdp((await attach()).debugger, identities),
|
||||
writeCookieIdentity: async (identity) =>
|
||||
writeIdentityWithCdp((await attach()).debugger, identity, 'import'),
|
||||
dispose: () => {
|
||||
disposed = true
|
||||
pendingAttach = null
|
||||
|
||||
@@ -35,13 +35,6 @@ export type CookieClearStore = Pick<Cookies, 'get' | 'remove'> & {
|
||||
restoreClearIdentities(identities: readonly CookieClearIdentity[]): Promise<void>
|
||||
}
|
||||
|
||||
// Why (STA-4300): the import writes go through this store, and 'set' stays out of it for the same
|
||||
// reason it stays out of the clear path — cookies.set() drops partitionKey silently, so a CHIPS
|
||||
// cookie imported through it is downgraded on the success path with nothing to report it.
|
||||
export type CookieImportWriteStore = Pick<Cookies, 'get' | 'remove'> & {
|
||||
writeCookieIdentity(identity: CookieClearIdentity): Promise<void>
|
||||
}
|
||||
|
||||
// Why (STA-4061): 'set' stays out so the lossy partition-dropping reconstruction cannot return.
|
||||
export type CookieClearSession = {
|
||||
cookies: Pick<Cookies, 'get' | 'remove'>
|
||||
|
||||
@@ -40,10 +40,6 @@ vi.mock('./browser-cookie-clear-store', () => ({
|
||||
}) => ({
|
||||
get: (filter: object) => targetSession.cookies.get(filter),
|
||||
remove: (url: string, name: string) => targetSession.cookies.remove(url, name),
|
||||
// Why (STA-4300): the import writes go through CDP identities; route them to the same spy so
|
||||
// a missing method cannot silently reroute every write down the rejected-cookie path.
|
||||
writeCookieIdentity: (identity: Record<string, unknown>) =>
|
||||
targetSession.cookies.set!(identity),
|
||||
snapshotClearIdentities: async (
|
||||
items: {
|
||||
cookie: {
|
||||
@@ -221,8 +217,7 @@ describe('native Chromium import excludes the Google cookie family', () => {
|
||||
remove: cookiesRemoveMock,
|
||||
set: cookiesSetMock
|
||||
},
|
||||
clearData: clearDataMock,
|
||||
getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test')
|
||||
clearData: clearDataMock
|
||||
})
|
||||
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
})
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as NodeFs from 'node:fs'
|
||||
|
||||
const {
|
||||
appGetPathMock,
|
||||
copyFileSyncMock,
|
||||
execFileSyncMock,
|
||||
sessionFromPartitionMock,
|
||||
setPendingCookieImportMock,
|
||||
clearPendingCookieImportMock,
|
||||
writeCookieIdentityMock
|
||||
} = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(),
|
||||
copyFileSyncMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn(),
|
||||
sessionFromPartitionMock: vi.fn(),
|
||||
setPendingCookieImportMock: vi.fn(),
|
||||
clearPendingCookieImportMock: vi.fn(),
|
||||
writeCookieIdentityMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./browser-session-registry', () => ({
|
||||
browserSessionRegistry: {
|
||||
setPendingCookieImport: setPendingCookieImportMock,
|
||||
clearPendingCookieImport: clearPendingCookieImportMock
|
||||
}
|
||||
}))
|
||||
vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock }))
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof NodeFs>()
|
||||
return {
|
||||
...actual,
|
||||
copyFileSync: (...args: Parameters<typeof actual.copyFileSync>) => {
|
||||
copyFileSyncMock(...args)
|
||||
return actual.copyFileSync(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.mock('electron', () => ({
|
||||
app: { getPath: appGetPathMock },
|
||||
BrowserWindow: { fromWebContents: vi.fn() },
|
||||
dialog: { showOpenDialog: vi.fn() },
|
||||
session: { fromPartition: sessionFromPartitionMock }
|
||||
}))
|
||||
vi.mock('./browser-cookie-clear-store', () => ({
|
||||
openCookieClearStore: (targetSession: {
|
||||
cookies: {
|
||||
get: (filter: object) => Promise<unknown>
|
||||
remove: (url: string, name: string) => Promise<void>
|
||||
}
|
||||
}) => ({
|
||||
get: (filter: object) => targetSession.cookies.get(filter),
|
||||
remove: (url: string, name: string) => targetSession.cookies.remove(url, name),
|
||||
snapshotClearIdentities: async (items: { cookie: Record<string, unknown>; url: string }[]) =>
|
||||
items.map(({ cookie, url }) => ({ url, ...cookie })),
|
||||
restoreClearIdentities: async () => undefined,
|
||||
// Why (STA-4300): the import writes land here, not on cookies.set. A store mock missing this
|
||||
// method would throw a TypeError the per-cookie catch swallows, quietly routing every write
|
||||
// down the rejected-cookie path while the suite still looked green.
|
||||
writeCookieIdentity: writeCookieIdentityMock,
|
||||
dispose: () => undefined
|
||||
})
|
||||
}))
|
||||
|
||||
import { importCookiesFromBrowser, importCookiesFromFile } from './browser-cookie-import'
|
||||
import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import type { DetectedBrowser } from './browser-cookie-import'
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
function firefoxBrowser(cookiesPath: string): DetectedBrowser {
|
||||
return {
|
||||
family: 'firefox',
|
||||
label: 'Firefox',
|
||||
cookiesPath,
|
||||
profiles: [{ name: 'default-release', directory: 'default-release' }],
|
||||
selectedProfile: 'default-release'
|
||||
}
|
||||
}
|
||||
|
||||
// Why (STA-4300): cookies.set() silently drops partitionKey, so no user cookie may reach it. Only
|
||||
// the __init probe — which writes no user data — is allowed through; anything else is the
|
||||
// downgrade returning, and it must fail the test rather than quietly succeed.
|
||||
const unreachableCookieSet = vi.fn(async (details: { name: string }) => {
|
||||
if (details.name !== '__init') {
|
||||
throw new Error(`cookies.set was called for user cookie ${details.name}`)
|
||||
}
|
||||
})
|
||||
|
||||
describe('validated import partition fidelity', () => {
|
||||
let tmpDir: string
|
||||
let cookieWriteMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-partition-fidelity-file-'))
|
||||
cookieWriteMock = writeCookieIdentityMock
|
||||
cookieWriteMock.mockReset()
|
||||
cookieWriteMock.mockResolvedValue(undefined)
|
||||
sessionFromPartitionMock.mockReset()
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
cookies: {
|
||||
get: vi.fn().mockResolvedValue([]),
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
set: unreachableCookieSet
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function writeCookieFile(cookies: unknown[]): string {
|
||||
const filePath = join(tmpDir, 'cookies.json')
|
||||
writeFileSync(filePath, JSON.stringify(cookies))
|
||||
return filePath
|
||||
}
|
||||
|
||||
// Why (STA-4300): a JSON export that carries a full CHIPS partition must reach the jar with it.
|
||||
// cookies.set() drops partitionKey silently, so this only works through the CDP identity store.
|
||||
it('carries a complete partitionKey from a JSON export through to the write', async () => {
|
||||
const filePath = writeCookieFile([
|
||||
{
|
||||
domain: '.app.example',
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
secure: true,
|
||||
sameSite: 'None',
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
}
|
||||
])
|
||||
|
||||
const result = await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(cookieWriteMock).toHaveBeenCalledTimes(1)
|
||||
expect(cookieWriteMock.mock.calls[0][0]).toMatchObject({
|
||||
name: 'chips-auth',
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
})
|
||||
expect(result.ok && result.summary.partitionSkippedCookies).toBeUndefined()
|
||||
})
|
||||
|
||||
// Why (STA-4300): exporters that emit only topLevelSite carry no ancestor bit. Writing the cookie
|
||||
// unpartitioned would report a clean success the site cannot use, so it is skipped and counted.
|
||||
it('skips and reports a cookie whose partitionKey cannot be read faithfully', async () => {
|
||||
const filePath = writeCookieFile([
|
||||
{
|
||||
domain: '.app.example',
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
secure: true,
|
||||
partitionKey: { topLevelSite: 'https://top.example' }
|
||||
},
|
||||
{ domain: '.plain.example', name: 'plain', value: 'ok' }
|
||||
])
|
||||
|
||||
const result = await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
// The lossy write is not merely unused — only the readable cookie was ever attempted.
|
||||
expect(cookieWriteMock).toHaveBeenCalledTimes(1)
|
||||
expect(cookieWriteMock.mock.calls[0][0].name).toBe('plain')
|
||||
expect(result.ok && result.summary.partitionSkippedCookies).toBe(1)
|
||||
expect(result.ok && result.summary.importedCookies).toBe(1)
|
||||
expect(result.ok && result.summary.skippedCookies).toBe(1)
|
||||
expect(result.ok && result.summary.domains).toEqual(['plain.example'])
|
||||
})
|
||||
|
||||
it('does not replace existing cookies for a domain whose only source cookie is skipped', async () => {
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
cookies: {
|
||||
get: vi.fn().mockResolvedValue([
|
||||
{
|
||||
name: 'existing-session',
|
||||
value: 'still-valid',
|
||||
domain: '.app.example',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
hostOnly: false,
|
||||
session: true,
|
||||
sameSite: 'lax'
|
||||
}
|
||||
]),
|
||||
remove,
|
||||
set: unreachableCookieSet
|
||||
}
|
||||
})
|
||||
const filePath = writeCookieFile([
|
||||
{
|
||||
domain: '.app.example',
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
secure: true,
|
||||
partitionKey: { topLevelSite: 'https://top.example' }
|
||||
}
|
||||
])
|
||||
|
||||
const result = await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(result.ok && result.summary.partitionSkippedCookies).toBe(1)
|
||||
expect(cookieWriteMock).not.toHaveBeenCalled()
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('native Chromium import partition fidelity', () => {
|
||||
let tmpDir: string
|
||||
let cookieWriteMock: ReturnType<typeof vi.fn>
|
||||
let platformSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-partition-fidelity-native-'))
|
||||
cookieWriteMock = writeCookieIdentityMock
|
||||
cookieWriteMock.mockReset()
|
||||
cookieWriteMock.mockResolvedValue(undefined)
|
||||
appGetPathMock.mockReset().mockReturnValue(join(tmpDir, 'userData'))
|
||||
copyFileSyncMock.mockClear()
|
||||
setPendingCookieImportMock.mockClear()
|
||||
clearPendingCookieImportMock.mockClear()
|
||||
execFileSyncMock.mockReset().mockImplementation(() => {
|
||||
throw new Error('OS credential commands are unavailable in this test')
|
||||
})
|
||||
sessionFromPartitionMock.mockReset().mockReturnValue({
|
||||
cookies: {
|
||||
get: vi.fn().mockResolvedValue([]),
|
||||
set: unreachableCookieSet,
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
flushStore: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
clearData: vi.fn().mockResolvedValue(undefined),
|
||||
setUserAgent: vi.fn(),
|
||||
getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test')
|
||||
})
|
||||
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
platformSpy.mockRestore()
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// Why (STA-4300): the native path's in-memory load used cookies.set(), which drops partitionKey
|
||||
// silently. This pins the identity that actually reaches the jar; the end-to-end proof that
|
||||
// Chromium stores it is browser-cookie-import-partition-success.electron.test.ts.
|
||||
it('carries a partitioned source row through to the write with both partition halves', async () => {
|
||||
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
||||
createChromiumCookieTestDatabase(sourceCookiesPath, [
|
||||
{
|
||||
domain: '.app.acme-chips.test',
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
isSecure: 1,
|
||||
sameSite: 1,
|
||||
topFrameSiteKey: 'https://top.example',
|
||||
hasCrossSiteAncestor: 1
|
||||
},
|
||||
{ domain: '.plain.example', name: 'plain', value: 'plain-ok', isSecure: 1 }
|
||||
]).close()
|
||||
createChromiumCookieTestDatabase(
|
||||
join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies'),
|
||||
[]
|
||||
).close()
|
||||
|
||||
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(cookieWriteMock).toHaveBeenCalledTimes(2)
|
||||
const written = cookieWriteMock.mock.calls.map((call) => call[0])
|
||||
expect(written.find((cookie) => cookie.name === 'chips-auth')).toMatchObject({
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
})
|
||||
expect(written.find((cookie) => cookie.name === 'plain')).not.toHaveProperty('partitionKey')
|
||||
expect(result.ok && result.summary?.partitionSkippedCookies).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never stages a partitioned row whose ancestor bit is unreadable', async () => {
|
||||
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
||||
mkdirSync(dirname(sourceCookiesPath), { recursive: true })
|
||||
const legacyDb = new DatabaseSync(sourceCookiesPath)
|
||||
legacyDb.exec(`
|
||||
CREATE TABLE cookies (
|
||||
creation_utc INTEGER NOT NULL,
|
||||
host_key TEXT NOT NULL,
|
||||
top_frame_site_key TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
encrypted_value BLOB NOT NULL DEFAULT X'',
|
||||
path TEXT NOT NULL,
|
||||
expires_utc INTEGER NOT NULL,
|
||||
is_secure INTEGER NOT NULL,
|
||||
is_httponly INTEGER NOT NULL,
|
||||
samesite INTEGER NOT NULL,
|
||||
source_scheme INTEGER NOT NULL DEFAULT 0,
|
||||
source_port INTEGER NOT NULL DEFAULT -1,
|
||||
last_update_utc INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
`)
|
||||
legacyDb.exec(`
|
||||
INSERT INTO cookies VALUES
|
||||
(133000000000000, '.app.acme-chips.test', 'https://top.example', 'chips-auth', 'keep-me', X'', '/', 0, 1, 0, 1, 0, -1, 0),
|
||||
(133000000000001, '.plain.example', '', 'plain', 'plain-ok', X'', '/', 0, 1, 0, 0, 0, -1, 0)
|
||||
`)
|
||||
legacyDb.close()
|
||||
createChromiumCookieTestDatabase(
|
||||
join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies'),
|
||||
[]
|
||||
).close()
|
||||
cookieWriteMock.mockRejectedValueOnce(new Error('plain cookie needs restart'))
|
||||
|
||||
const blocked = await importCookiesFromBrowser(
|
||||
chromeBrowser(sourceCookiesPath),
|
||||
'persist:test',
|
||||
{
|
||||
canReportPartitionSkippedCookies: false
|
||||
}
|
||||
)
|
||||
expect(blocked.ok).toBe(false)
|
||||
expect(blocked.ok || blocked.reason).toContain('cannot report')
|
||||
expect(cookieWriteMock).not.toHaveBeenCalled()
|
||||
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
|
||||
const blockedSession = sessionFromPartitionMock.mock.results[0].value
|
||||
expect(blockedSession.clearData).not.toHaveBeenCalled()
|
||||
expect(blockedSession.cookies.remove).not.toHaveBeenCalled()
|
||||
|
||||
const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test')
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
// Only the readable cookie was ever attempted — the unreadable one reached no writer at all.
|
||||
expect(cookieWriteMock).toHaveBeenCalledTimes(1)
|
||||
expect(cookieWriteMock.mock.calls[0][0].name).toBe('plain')
|
||||
expect(result.ok && result.summary?.partitionSkippedCookies).toBe(1)
|
||||
expect(result.ok && result.summary?.importedCookies).toBe(1)
|
||||
expect(result.ok && result.summary?.skippedCookies).toBe(1)
|
||||
expect(setPendingCookieImportMock).toHaveBeenCalledOnce()
|
||||
const stagedPath = setPendingCookieImportMock.mock.calls[0][1] as string
|
||||
const stagedDb = new DatabaseSync(stagedPath, { readOnly: true })
|
||||
const stagedNames = stagedDb.prepare('SELECT name FROM cookies ORDER BY name').all()
|
||||
stagedDb.close()
|
||||
expect(stagedNames).toEqual([{ name: 'plain' }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Firefox import partition fidelity', () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-partition-fidelity-firefox-'))
|
||||
writeCookieIdentityMock.mockReset().mockResolvedValue(undefined)
|
||||
sessionFromPartitionMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('leaves the target jar untouched when the client cannot report a partition skip', async () => {
|
||||
const sourceCookiesPath = join(tmpDir, 'cookies.sqlite')
|
||||
const sourceDb = new DatabaseSync(sourceCookiesPath)
|
||||
sourceDb.exec(`
|
||||
CREATE TABLE moz_cookies (
|
||||
name TEXT,
|
||||
value TEXT,
|
||||
host TEXT,
|
||||
path TEXT,
|
||||
expiry INTEGER,
|
||||
isSecure INTEGER,
|
||||
isHttpOnly INTEGER,
|
||||
sameSite INTEGER,
|
||||
originAttributes TEXT,
|
||||
isPartitionedAttributeSet INTEGER
|
||||
);
|
||||
INSERT INTO moz_cookies VALUES
|
||||
('chips-auth', 'keep-me', '.app.example', '/', 0, 1, 1, 0,
|
||||
'^partitionKey=(https,top.example)', 1),
|
||||
('dfpi', 'dfpi-ok', '.dfpi.example', '/', 0, 1, 0, 0,
|
||||
'^partitionKey=(https,top.example,f)', 0)
|
||||
`)
|
||||
sourceDb.close()
|
||||
const get = vi.fn().mockResolvedValue([
|
||||
{
|
||||
name: 'existing-session',
|
||||
value: 'still-valid',
|
||||
domain: '.app.example',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
hostOnly: false,
|
||||
session: true,
|
||||
sameSite: 'lax'
|
||||
}
|
||||
])
|
||||
const remove = vi.fn().mockResolvedValue(undefined)
|
||||
sessionFromPartitionMock.mockReturnValue({ cookies: { get, remove } })
|
||||
|
||||
const result = await importCookiesFromBrowser(
|
||||
firefoxBrowser(sourceCookiesPath),
|
||||
'persist:test',
|
||||
{ canReportPartitionSkippedCookies: false }
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.ok || result.reason).toContain('cannot report')
|
||||
expect(get).not.toHaveBeenCalled()
|
||||
expect(remove).not.toHaveBeenCalled()
|
||||
expect(writeCookieIdentityMock).not.toHaveBeenCalled()
|
||||
|
||||
get.mockResolvedValue([])
|
||||
const supportedResult = await importCookiesFromBrowser(
|
||||
firefoxBrowser(sourceCookiesPath),
|
||||
'persist:test'
|
||||
)
|
||||
|
||||
expect(supportedResult.ok).toBe(true)
|
||||
expect(supportedResult.ok && supportedResult.summary?.partitionSkippedCookies).toBe(1)
|
||||
expect(supportedResult.ok && supportedResult.summary?.importedCookies).toBe(1)
|
||||
expect(writeCookieIdentityMock).toHaveBeenCalledOnce()
|
||||
expect(writeCookieIdentityMock.mock.calls[0][0].name).toBe('dfpi')
|
||||
})
|
||||
})
|
||||
@@ -1,252 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { build as buildVite } from 'vite'
|
||||
import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database'
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
const fixtureRoots: string[] = []
|
||||
|
||||
afterAll(() => {
|
||||
for (const root of fixtureRoots) {
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
})
|
||||
|
||||
type CdpCookie = {
|
||||
name: string
|
||||
value: string
|
||||
partitionKey: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
type ImportResult = {
|
||||
ok: boolean
|
||||
reason?: string
|
||||
summary?: {
|
||||
totalCookies: number
|
||||
importedCookies: number
|
||||
skippedCookies: number
|
||||
domains: string[]
|
||||
}
|
||||
}
|
||||
|
||||
type FixtureResult = {
|
||||
step: string
|
||||
error?: string
|
||||
beforeCookieCount: number
|
||||
importResult: ImportResult
|
||||
afterChips: CdpCookie[]
|
||||
afterPlain: CdpCookie[]
|
||||
}
|
||||
|
||||
type SourceChipsRow = {
|
||||
host_key: string
|
||||
name: string
|
||||
value: string
|
||||
top_frame_site_key: string
|
||||
has_cross_site_ancestor: number
|
||||
}
|
||||
|
||||
// Why: the correct behavior — the CDP object form Network.setCookie writes and
|
||||
// Network.getAllCookies returns, as proven by the rollback fixture on this Electron.
|
||||
const EXPECTED_PARTITION_KEY = {
|
||||
topLevelSite: 'https://top.example',
|
||||
hasCrossSiteAncestor: true
|
||||
}
|
||||
|
||||
const SOURCE_DB_RELATIVE_PATH = 'source-cookies.db'
|
||||
|
||||
function buildFixtureMain(bundlePath: string, resultPath: string, sourceDbPath: string): string {
|
||||
return `
|
||||
const { app, BrowserWindow, session } = require('electron')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const { importCookiesFromBrowser } = require(${JSON.stringify(bundlePath)})
|
||||
const resultPath = ${JSON.stringify(resultPath)}
|
||||
let currentStep = 'starting'
|
||||
const mark = (step) => {
|
||||
currentStep = step
|
||||
writeFileSync(resultPath, JSON.stringify({ step }))
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const timeout = setTimeout(() => {
|
||||
writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep }))
|
||||
app.exit(1)
|
||||
}, 30000)
|
||||
await app.whenReady()
|
||||
mark('ready')
|
||||
const partition = 'persist:partition-success-cookie-test'
|
||||
const targetSession = session.fromPartition(partition)
|
||||
const window = new BrowserWindow({ show: false, webPreferences: { partition } })
|
||||
mark('window created')
|
||||
await window.loadURL('data:text/html,<title>cookie partition success fixture</title>')
|
||||
mark('window loaded')
|
||||
const debug = window.webContents.debugger
|
||||
debug.attach('1.3')
|
||||
mark('debugger attached')
|
||||
|
||||
// Why: only CDP can observe partitionKey; prove the jar is empty before the import
|
||||
// so every cookie read afterwards can only have come from the import itself.
|
||||
const beforeCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
|
||||
mark('jar read before import')
|
||||
|
||||
const importResult = await importCookiesFromBrowser(
|
||||
{
|
||||
family: 'chrome',
|
||||
label: 'Google Chrome',
|
||||
cookiesPath: ${JSON.stringify(sourceDbPath)},
|
||||
profiles: [],
|
||||
selectedProfile: ''
|
||||
},
|
||||
partition
|
||||
)
|
||||
mark('import finished')
|
||||
|
||||
const afterCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
|
||||
const project = (name) => afterCookies
|
||||
.filter((cookie) => cookie.name === name)
|
||||
.map((cookie) => ({
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
partitionKey: cookie.partitionKey ?? null
|
||||
}))
|
||||
clearTimeout(timeout)
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
step: currentStep,
|
||||
beforeCookieCount: beforeCookies.length,
|
||||
importResult,
|
||||
afterChips: project('chips-auth'),
|
||||
afterPlain: project('plain')
|
||||
}))
|
||||
debug.detach()
|
||||
window.destroy()
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) }))
|
||||
app.exit(1)
|
||||
})
|
||||
`
|
||||
}
|
||||
|
||||
function readSourceChipsRow(sourceDbPath: string): SourceChipsRow {
|
||||
const db = new DatabaseSync(sourceDbPath, { readOnly: true })
|
||||
try {
|
||||
const rows = db
|
||||
.prepare(
|
||||
'SELECT host_key, name, value, top_frame_site_key, has_cross_site_ancestor FROM cookies ORDER BY rowid'
|
||||
)
|
||||
.all() as SourceChipsRow[]
|
||||
const row = rows.find((candidate) => candidate.name === 'chips-auth')
|
||||
if (!row) {
|
||||
throw new Error('source DB has no chips-auth row')
|
||||
}
|
||||
return row
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function runFixture(): Promise<{ fixture: FixtureResult; sourceChips: SourceChipsRow }> {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-partition-success-'))
|
||||
fixtureRoots.push(root)
|
||||
const bundlePath = join(root, 'cookie-import-success.cjs')
|
||||
const bundleEntryPath = join(root, 'cookie-import-success.ts')
|
||||
const resultPath = join(root, 'result.json')
|
||||
const fixturePath = join(root, 'main.cjs')
|
||||
const sourceDbPath = join(root, SOURCE_DB_RELATIVE_PATH)
|
||||
const sourceDb = createChromiumCookieTestDatabase(sourceDbPath, [
|
||||
// Why: the CHIPS row is a genuine partitioned cookie: top_frame_site_key names the
|
||||
// top-level site and has_cross_site_ancestor=1 means it was set cross-site.
|
||||
{
|
||||
domain: '.app.acme-chips.test',
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
isSecure: 1,
|
||||
sameSite: 1,
|
||||
topFrameSiteKey: EXPECTED_PARTITION_KEY.topLevelSite,
|
||||
hasCrossSiteAncestor: 1
|
||||
},
|
||||
// Why: an ordinary row with identical attributes minus the partition columns proves
|
||||
// the import still works for the unpartitioned case.
|
||||
{
|
||||
domain: '.plain.example',
|
||||
name: 'plain',
|
||||
value: 'plain-ok',
|
||||
isSecure: 1,
|
||||
sameSite: 1
|
||||
}
|
||||
])
|
||||
sourceDb.close()
|
||||
const sourceChips = readSourceChipsRow(sourceDbPath)
|
||||
writeFileSync(
|
||||
bundleEntryPath,
|
||||
`export { importCookiesFromBrowser } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-import.ts'))}`
|
||||
)
|
||||
await buildVite({
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: bundleEntryPath,
|
||||
formats: ['cjs'],
|
||||
fileName: () => 'cookie-import-success.cjs'
|
||||
},
|
||||
outDir: root,
|
||||
target: 'node20',
|
||||
rollupOptions: { external: ['electron', /^node:/] }
|
||||
}
|
||||
})
|
||||
writeFileSync(fixturePath, buildFixtureMain(bundlePath, resultPath, sourceDbPath))
|
||||
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
|
||||
const electronArgs = [fixturePath, `--user-data-dir=${join(root, 'profile')}`]
|
||||
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
|
||||
const args =
|
||||
process.platform === 'linux'
|
||||
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
|
||||
: electronArgs
|
||||
const run = spawnSync(executable, args, {
|
||||
encoding: 'utf8',
|
||||
env,
|
||||
timeout: 90_000
|
||||
})
|
||||
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
|
||||
expect(run.error).toBeUndefined()
|
||||
expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0)
|
||||
return { fixture: JSON.parse(fixtureResult) as FixtureResult, sourceChips }
|
||||
}
|
||||
|
||||
describe('STA-4300: partitioned (CHIPS) cookies on the native Chromium import success path', () => {
|
||||
it('preserves the source partitionKey through importCookiesFromBrowser', async () => {
|
||||
const { fixture, sourceChips } = await runFixture()
|
||||
|
||||
// Non-vacuity breadcrumbs: the fixture reached the import, the source row really was
|
||||
// partitioned before import, and the import reported success with both cookies.
|
||||
expect(fixture.step).toBe('import finished')
|
||||
expect(fixture.beforeCookieCount).toBe(0)
|
||||
expect(sourceChips).toEqual({
|
||||
host_key: '.app.acme-chips.test',
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
top_frame_site_key: 'https://top.example',
|
||||
has_cross_site_ancestor: 1
|
||||
})
|
||||
expect(fixture.importResult.ok).toBe(true)
|
||||
expect(fixture.importResult.summary?.importedCookies).toBe(2)
|
||||
|
||||
// Ordinary unpartitioned import still works end to end.
|
||||
expect(fixture.afterPlain).toEqual([{ name: 'plain', value: 'plain-ok', partitionKey: null }])
|
||||
|
||||
// The partitioned cookie must keep its partition — cookies.set() silently drops it, so
|
||||
// this is the assertion that fails on current main and goes green once writes go through CDP.
|
||||
expect(fixture.afterChips).toHaveLength(1)
|
||||
expect(fixture.afterChips[0].value).toBe('keep-me')
|
||||
expect(fixture.afterChips[0].partitionKey).toEqual(EXPECTED_PARTITION_KEY)
|
||||
}, 120_000)
|
||||
})
|
||||
@@ -38,15 +38,10 @@ vi.mock('./browser-cookie-clear-store', () => ({
|
||||
cookies: {
|
||||
get: (filter: object) => Promise<unknown>
|
||||
remove: (url: string, name: string) => Promise<void>
|
||||
set?: (details: Record<string, unknown>) => Promise<void>
|
||||
}
|
||||
}) => ({
|
||||
get: (filter: object) => targetSession.cookies.get(filter),
|
||||
remove: (url: string, name: string) => targetSession.cookies.remove(url, name),
|
||||
// Why (STA-4300): the import writes go through CDP identities; route them to the same spy so
|
||||
// a missing method cannot silently reroute every write down the rejected-cookie path.
|
||||
writeCookieIdentity: (identity: Record<string, unknown>) =>
|
||||
targetSession.cookies.set!(identity),
|
||||
snapshotClearIdentities: snapshotClearIdentitiesMock,
|
||||
restoreClearIdentities: restoreClearIdentitiesMock,
|
||||
dispose: disposeClearStoreMock
|
||||
@@ -253,8 +248,7 @@ describe('native Chromium integrity-cookie accounting', () => {
|
||||
remove: vi.fn().mockResolvedValue(undefined),
|
||||
set: cookiesSetMock
|
||||
},
|
||||
clearData: clearDataMock,
|
||||
getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test')
|
||||
clearData: clearDataMock
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -8,12 +8,6 @@ type ChromiumCookieTestRow = {
|
||||
name: string
|
||||
value: string
|
||||
encryptedValue?: Buffer
|
||||
// Why: default '' matches a real unpartitioned row; CHIPS fixtures set a top-level site.
|
||||
topFrameSiteKey?: string
|
||||
hasCrossSiteAncestor?: 0 | 1
|
||||
isSecure?: 0 | 1
|
||||
isHttpOnly?: 0 | 1
|
||||
sameSite?: 0 | 1 | 2 | 3
|
||||
}
|
||||
|
||||
export function createChromiumCookieTestDatabase(
|
||||
@@ -63,21 +57,16 @@ export function createChromiumCookieTestDatabase(
|
||||
source_port,
|
||||
last_update_utc,
|
||||
has_cross_site_ancestor
|
||||
) VALUES (?, ?, ?, ?, ?, ?, '/', 0, ?, ?, ?, 0, -1, ?, ?)
|
||||
) VALUES (?, ?, '', ?, ?, ?, '/', 0, 0, 0, 0, 0, -1, ?, 0)
|
||||
`)
|
||||
rows.forEach((row, index) => {
|
||||
insert.run(
|
||||
133_000_000_000_000 + index,
|
||||
row.domain ?? '.example.com',
|
||||
row.topFrameSiteKey ?? '',
|
||||
row.name,
|
||||
row.value,
|
||||
row.encryptedValue ?? Buffer.alloc(0),
|
||||
row.isSecure ?? 0,
|
||||
row.isHttpOnly ?? 0,
|
||||
row.sameSite ?? 0,
|
||||
0,
|
||||
row.hasCrossSiteAncestor ?? 0
|
||||
0
|
||||
)
|
||||
})
|
||||
return database
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterAll, describe, expect, it } from 'vitest'
|
||||
import { build as buildVite } from 'vite'
|
||||
|
||||
const electronBinary = createRequire(import.meta.url)('electron') as string
|
||||
const fixtureRoots: string[] = []
|
||||
|
||||
afterAll(() => {
|
||||
for (const root of fixtureRoots) {
|
||||
rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||
}
|
||||
})
|
||||
|
||||
type CdpCookie = {
|
||||
name: string
|
||||
value: string
|
||||
partitionKey: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
type FixtureResult = {
|
||||
step: string
|
||||
error?: string
|
||||
beforeCookieCount: number
|
||||
importResult: {
|
||||
ok: boolean
|
||||
reason?: string
|
||||
summary?: {
|
||||
importedCookies: number
|
||||
skippedCookies: number
|
||||
partitionSkippedCookies?: number
|
||||
domains: string[]
|
||||
}
|
||||
}
|
||||
afterChips: CdpCookie[]
|
||||
afterHostChips: CdpCookie[]
|
||||
afterPlain: CdpCookie[]
|
||||
afterUnreadable: CdpCookie[]
|
||||
}
|
||||
|
||||
const EXPECTED_PARTITION_KEY = {
|
||||
topLevelSite: 'https://top.example',
|
||||
hasCrossSiteAncestor: true
|
||||
}
|
||||
|
||||
// Why (STA-4300): the JSON/paste import writes through the same CDP identity store as the native
|
||||
// one. These four cookies are the failure-mode table's validated-path row: ordinary, host-prefixed,
|
||||
// partitioned, and a partition the exporter described incompletely.
|
||||
const SOURCE_COOKIES = [
|
||||
{
|
||||
domain: '.app.acme-chips.test',
|
||||
name: 'chips-auth',
|
||||
value: 'keep-me',
|
||||
path: '/',
|
||||
secure: true,
|
||||
sameSite: 'None',
|
||||
partitionKey: EXPECTED_PARTITION_KEY
|
||||
},
|
||||
{
|
||||
domain: 'host.acme-chips.test',
|
||||
name: '__Host-chips-session',
|
||||
value: 'host-keep-me',
|
||||
path: '/',
|
||||
secure: true,
|
||||
sameSite: 'None',
|
||||
partitionKey: EXPECTED_PARTITION_KEY
|
||||
},
|
||||
{
|
||||
domain: '.plain.example',
|
||||
name: 'plain',
|
||||
value: 'plain-ok',
|
||||
path: '/',
|
||||
secure: true
|
||||
},
|
||||
{
|
||||
// Only topLevelSite — the shape exporters emit without the cross-site-ancestor bit.
|
||||
domain: '.partial.example',
|
||||
name: 'partial-chips',
|
||||
value: 'must-not-land',
|
||||
path: '/',
|
||||
secure: true,
|
||||
sameSite: 'None',
|
||||
partitionKey: { topLevelSite: 'https://top.example' }
|
||||
}
|
||||
]
|
||||
|
||||
function buildFixtureMain(bundlePath: string, resultPath: string, cookieFilePath: string): string {
|
||||
return `
|
||||
const { app, BrowserWindow, session } = require('electron')
|
||||
const { writeFileSync } = require('node:fs')
|
||||
const { importCookiesFromFile } = require(${JSON.stringify(bundlePath)})
|
||||
const resultPath = ${JSON.stringify(resultPath)}
|
||||
let currentStep = 'starting'
|
||||
const mark = (step) => {
|
||||
currentStep = step
|
||||
writeFileSync(resultPath, JSON.stringify({ step }))
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const timeout = setTimeout(() => {
|
||||
writeFileSync(resultPath, JSON.stringify({ step: 'timed out after ' + currentStep }))
|
||||
app.exit(1)
|
||||
}, 30000)
|
||||
await app.whenReady()
|
||||
mark('ready')
|
||||
const partition = 'persist:validated-partition-cookie-test'
|
||||
const window = new BrowserWindow({ show: false, webPreferences: { partition } })
|
||||
mark('window created')
|
||||
await window.loadURL('data:text/html,<title>validated partition fixture</title>')
|
||||
mark('window loaded')
|
||||
const debug = window.webContents.debugger
|
||||
debug.attach('1.3')
|
||||
mark('debugger attached')
|
||||
|
||||
// Why: only CDP can observe partitionKey; an empty jar first means every cookie read
|
||||
// afterwards can only have come from the import.
|
||||
const beforeCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
|
||||
mark('jar read before import')
|
||||
|
||||
const importResult = await importCookiesFromFile(${JSON.stringify(cookieFilePath)}, partition)
|
||||
mark('import finished')
|
||||
|
||||
const afterCookies = (await debug.sendCommand('Network.getAllCookies')).cookies
|
||||
const project = (name) => afterCookies
|
||||
.filter((cookie) => cookie.name === name)
|
||||
.map((cookie) => ({
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
partitionKey: cookie.partitionKey ?? null
|
||||
}))
|
||||
clearTimeout(timeout)
|
||||
writeFileSync(resultPath, JSON.stringify({
|
||||
step: currentStep,
|
||||
beforeCookieCount: beforeCookies.length,
|
||||
importResult,
|
||||
afterChips: project('chips-auth'),
|
||||
afterHostChips: project('__Host-chips-session'),
|
||||
afterPlain: project('plain'),
|
||||
afterUnreadable: project('partial-chips')
|
||||
}))
|
||||
debug.detach()
|
||||
window.destroy()
|
||||
app.exit(0)
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
writeFileSync(resultPath, JSON.stringify({ step: currentStep, error: String(error?.stack || error) }))
|
||||
app.exit(1)
|
||||
})
|
||||
`
|
||||
}
|
||||
|
||||
async function runFixture(): Promise<FixtureResult> {
|
||||
const root = mkdtempSync(join(tmpdir(), 'orca-validated-partition-'))
|
||||
fixtureRoots.push(root)
|
||||
const bundlePath = join(root, 'cookie-import-validated.cjs')
|
||||
const bundleEntryPath = join(root, 'cookie-import-validated.ts')
|
||||
const resultPath = join(root, 'result.json')
|
||||
const fixturePath = join(root, 'main.cjs')
|
||||
const cookieFilePath = join(root, 'cookies.json')
|
||||
writeFileSync(cookieFilePath, JSON.stringify(SOURCE_COOKIES))
|
||||
writeFileSync(
|
||||
bundleEntryPath,
|
||||
`export { importCookiesFromFile } from ${JSON.stringify(join(process.cwd(), 'src/main/browser/browser-cookie-import.ts'))}`
|
||||
)
|
||||
await buildVite({
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
build: {
|
||||
emptyOutDir: false,
|
||||
lib: {
|
||||
entry: bundleEntryPath,
|
||||
formats: ['cjs'],
|
||||
fileName: () => 'cookie-import-validated.cjs'
|
||||
},
|
||||
outDir: root,
|
||||
target: 'node20',
|
||||
rollupOptions: { external: ['electron', /^node:/] }
|
||||
}
|
||||
})
|
||||
writeFileSync(fixturePath, buildFixtureMain(bundlePath, resultPath, cookieFilePath))
|
||||
const { ELECTRON_RUN_AS_NODE: _electronRunAsNode, ...env } = process.env
|
||||
const electronArgs = [fixturePath, `--user-data-dir=${join(root, 'profile')}`]
|
||||
const executable = process.platform === 'linux' ? 'xvfb-run' : electronBinary
|
||||
const args =
|
||||
process.platform === 'linux'
|
||||
? ['--auto-servernum', electronBinary, ...electronArgs, '--no-sandbox']
|
||||
: electronArgs
|
||||
const run = spawnSync(executable, args, { encoding: 'utf8', env, timeout: 90_000 })
|
||||
const fixtureResult = existsSync(resultPath) ? readFileSync(resultPath, 'utf8') : 'no result'
|
||||
expect(run.error).toBeUndefined()
|
||||
expect(run.status, `${fixtureResult}\n${run.stdout}\n${run.stderr}`).toBe(0)
|
||||
return JSON.parse(fixtureResult) as FixtureResult
|
||||
}
|
||||
|
||||
describe('STA-4300: partitioned cookies on the validated (file/paste) import success path', () => {
|
||||
it('stores CHIPS and __Host- CHIPS partitioned, and skips an unreadable partition', async () => {
|
||||
const result = await runFixture()
|
||||
|
||||
// Non-vacuity breadcrumbs: the fixture reached the import and the jar was empty before it.
|
||||
expect(result.step).toBe('import finished')
|
||||
expect(result.beforeCookieCount).toBe(0)
|
||||
expect(result.importResult.ok).toBe(true)
|
||||
|
||||
// Ordinary cookie: unchanged behavior, no partition invented for it.
|
||||
expect(result.afterPlain).toEqual([{ name: 'plain', value: 'plain-ok', partitionKey: null }])
|
||||
|
||||
// Partitioned cookie: Chromium stored the partition the export declared.
|
||||
expect(result.afterChips).toEqual([
|
||||
{ name: 'chips-auth', value: 'keep-me', partitionKey: EXPECTED_PARTITION_KEY }
|
||||
])
|
||||
|
||||
// A host-prefixed cookie can also be partitioned; the __Host- rules must not drop the partition.
|
||||
expect(result.afterHostChips).toEqual([
|
||||
{
|
||||
name: '__Host-chips-session',
|
||||
value: 'host-keep-me',
|
||||
partitionKey: EXPECTED_PARTITION_KEY
|
||||
}
|
||||
])
|
||||
|
||||
// The incompletely-described partition was skipped, not downgraded to an unpartitioned cookie.
|
||||
expect(result.afterUnreadable).toEqual([])
|
||||
expect(result.importResult.summary?.partitionSkippedCookies).toBe(1)
|
||||
expect(result.importResult.summary?.importedCookies).toBe(3)
|
||||
expect(result.importResult.summary?.domains).not.toContain('partial.example')
|
||||
}, 120_000)
|
||||
})
|
||||
@@ -1,199 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { CookieClearIdentity } from './browser-cookie-import-clear'
|
||||
import type { SourcePartitionRead } from './browser-cookie-source-partition'
|
||||
import { writeImportedCookies, type SourceCookieToWrite } from './browser-cookie-import-write'
|
||||
|
||||
const PARTITION_KEY = { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
|
||||
function sourceCookie(
|
||||
overrides: Partial<SourceCookieToWrite> & { name: string; partition: SourcePartitionRead }
|
||||
): SourceCookieToWrite {
|
||||
return {
|
||||
url: 'https://app.example/',
|
||||
value: 'v',
|
||||
domain: '.app.example',
|
||||
path: '/',
|
||||
secure: true,
|
||||
httpOnly: false,
|
||||
sameSite: 'no_restriction',
|
||||
expirationDate: undefined,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function recordingStore() {
|
||||
const writeCookieIdentity = vi.fn<(identity: CookieClearIdentity) => Promise<void>>(
|
||||
async () => undefined
|
||||
)
|
||||
return { writeCookieIdentity }
|
||||
}
|
||||
|
||||
const silentOptions = { stopOnFailure: false, log: () => undefined }
|
||||
|
||||
describe('writeImportedCookies', () => {
|
||||
it('writes a partitioned cookie through the identity store with its partition key intact', async () => {
|
||||
const store = recordingStore()
|
||||
|
||||
const phase = await writeImportedCookies(
|
||||
store,
|
||||
[
|
||||
sourceCookie({
|
||||
name: 'chips',
|
||||
partition: { status: 'partitioned', partitionKey: PARTITION_KEY }
|
||||
})
|
||||
],
|
||||
silentOptions
|
||||
)
|
||||
|
||||
// Why: assert the write was ATTEMPTED, not just that the counters look right — a store mock
|
||||
// missing this method would throw a TypeError the catch swallows, and every counter below
|
||||
// would still be reachable through the rejected path.
|
||||
expect(store.writeCookieIdentity).toHaveBeenCalledTimes(1)
|
||||
expect(store.writeCookieIdentity.mock.calls[0][0]).toMatchObject({
|
||||
name: 'chips',
|
||||
partitionKey: PARTITION_KEY
|
||||
})
|
||||
expect(phase.importedCount).toBe(1)
|
||||
expect(phase.partitionSkipped).toBe(0)
|
||||
expect(phase.writeRejected).toBe(0)
|
||||
})
|
||||
|
||||
it('writes an ordinary cookie with no partitionKey attribute at all', async () => {
|
||||
const store = recordingStore()
|
||||
|
||||
await writeImportedCookies(
|
||||
store,
|
||||
[sourceCookie({ name: 'plain', partition: { status: 'unpartitioned' } })],
|
||||
silentOptions
|
||||
)
|
||||
|
||||
expect(store.writeCookieIdentity).toHaveBeenCalledTimes(1)
|
||||
expect(store.writeCookieIdentity.mock.calls[0][0]).not.toHaveProperty('partitionKey')
|
||||
})
|
||||
|
||||
// Why (STA-4300): the whole point of the change — an unreadable partition must never reach the
|
||||
// store as an unpartitioned write.
|
||||
it('never writes a cookie whose partition could not be read, and counts it', async () => {
|
||||
const store = recordingStore()
|
||||
const logged: string[] = []
|
||||
|
||||
const phase = await writeImportedCookies(
|
||||
store,
|
||||
[
|
||||
sourceCookie({ name: 'chips', partition: { status: 'unreadable', reason: 'no ancestor' } }),
|
||||
sourceCookie({ name: 'plain', partition: { status: 'unpartitioned' } })
|
||||
],
|
||||
{ stopOnFailure: false, log: (message) => logged.push(message) }
|
||||
)
|
||||
|
||||
expect(store.writeCookieIdentity).toHaveBeenCalledTimes(1)
|
||||
expect(store.writeCookieIdentity.mock.calls[0][0].name).toBe('plain')
|
||||
expect(phase.partitionSkipped).toBe(1)
|
||||
expect(phase.importedCount).toBe(1)
|
||||
expect(phase.failure).toBeNull()
|
||||
expect(logged.join('\n')).toContain('unreadable partition')
|
||||
})
|
||||
|
||||
// Why: cookie values are secrets; a skip line that echoed one would leak it into the diag log.
|
||||
it('logs only the domain for a skipped cookie, never its name or value', async () => {
|
||||
const logged: string[] = []
|
||||
|
||||
await writeImportedCookies(
|
||||
recordingStore(),
|
||||
[
|
||||
sourceCookie({
|
||||
name: 'session-token',
|
||||
value: 'super-secret',
|
||||
partition: { status: 'unreadable', reason: 'no ancestor' }
|
||||
})
|
||||
],
|
||||
{ stopOnFailure: false, log: (message) => logged.push(message) }
|
||||
)
|
||||
|
||||
const line = logged.join('\n')
|
||||
expect(line).toContain('app.example')
|
||||
expect(line).not.toContain('super-secret')
|
||||
expect(line).not.toContain('session-token')
|
||||
})
|
||||
|
||||
it('writes __Host- cookies host-only at the root path so Chromium accepts them', async () => {
|
||||
const store = recordingStore()
|
||||
|
||||
await writeImportedCookies(
|
||||
store,
|
||||
[
|
||||
sourceCookie({
|
||||
name: '__Host-session',
|
||||
path: '/deep',
|
||||
partition: { status: 'partitioned', partitionKey: PARTITION_KEY }
|
||||
})
|
||||
],
|
||||
silentOptions
|
||||
)
|
||||
|
||||
expect(store.writeCookieIdentity.mock.calls[0][0]).toMatchObject({
|
||||
hostOnly: true,
|
||||
path: '/',
|
||||
// A host-prefixed cookie can still be partitioned; the prefix must not drop the partition.
|
||||
partitionKey: PARTITION_KEY
|
||||
})
|
||||
})
|
||||
|
||||
// Why: once existing cookies have been removed, the caller has to roll back, so the run must stop
|
||||
// at the first rejection instead of writing over a jar it is about to restore.
|
||||
it('stops at the first rejection when the caller must roll back', async () => {
|
||||
const store = recordingStore()
|
||||
store.writeCookieIdentity.mockRejectedValueOnce(new Error('rejected'))
|
||||
|
||||
const phase = await writeImportedCookies(
|
||||
store,
|
||||
[
|
||||
sourceCookie({ name: 'first', partition: { status: 'unpartitioned' } }),
|
||||
sourceCookie({ name: 'second', partition: { status: 'unpartitioned' } })
|
||||
],
|
||||
{ stopOnFailure: true, log: () => undefined }
|
||||
)
|
||||
|
||||
expect(store.writeCookieIdentity).toHaveBeenCalledTimes(1)
|
||||
expect(phase.writeRejected).toBe(1)
|
||||
expect(phase.importedCount).toBe(0)
|
||||
expect(phase.failure).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
// Why: the native path has a staged cold-start replay behind it, so one rejected cookie must not
|
||||
// stop the rest from loading.
|
||||
it('keeps going past a rejection when the caller has a restart fallback', async () => {
|
||||
const store = recordingStore()
|
||||
store.writeCookieIdentity.mockRejectedValueOnce(new Error('rejected'))
|
||||
|
||||
const phase = await writeImportedCookies(
|
||||
store,
|
||||
[
|
||||
sourceCookie({ name: 'first', partition: { status: 'unpartitioned' } }),
|
||||
sourceCookie({ name: 'second', partition: { status: 'unpartitioned' } })
|
||||
],
|
||||
silentOptions
|
||||
)
|
||||
|
||||
expect(store.writeCookieIdentity).toHaveBeenCalledTimes(2)
|
||||
expect(phase.writeRejected).toBe(1)
|
||||
expect(phase.importedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('reports the removal key at the identity path so a rollback can undo the write', async () => {
|
||||
const phase = await writeImportedCookies(
|
||||
recordingStore(),
|
||||
[
|
||||
sourceCookie({
|
||||
name: 'scoped',
|
||||
url: 'https://app.example/',
|
||||
path: '/settings',
|
||||
partition: { status: 'unpartitioned' }
|
||||
})
|
||||
],
|
||||
silentOptions
|
||||
)
|
||||
|
||||
expect(phase.importedKeys).toEqual([{ url: 'https://app.example/settings', name: 'scoped' }])
|
||||
})
|
||||
})
|
||||
@@ -1,163 +0,0 @@
|
||||
import type { Cookie } from 'electron'
|
||||
import type {
|
||||
CookieClearIdentity,
|
||||
CookieClearPartitionKey,
|
||||
CookieImportWriteStore
|
||||
} from './browser-cookie-import-clear'
|
||||
import type { SourcePartitionRead } from './browser-cookie-source-partition'
|
||||
|
||||
export type ImportedCookieFields = {
|
||||
url: string
|
||||
name: string
|
||||
value: string
|
||||
domain: string
|
||||
path: string
|
||||
secure: boolean
|
||||
httpOnly: boolean
|
||||
sameSite: Cookie['sameSite']
|
||||
expirationDate: number | undefined
|
||||
}
|
||||
|
||||
export type ImportWritePlan =
|
||||
| { status: 'write'; identity: CookieClearIdentity }
|
||||
| { status: 'skip'; reason: string }
|
||||
|
||||
const HOST_PREFIX = '__Host-'
|
||||
|
||||
export function importedCookieIdentity(
|
||||
cookie: ImportedCookieFields,
|
||||
partitionKey: CookieClearPartitionKey | undefined
|
||||
): CookieClearIdentity {
|
||||
// Why: Chromium rejects __Host- cookies unless they omit domain and use path=/; hostOnly is how
|
||||
// the identity says "omit domain", the same contract the CDP restore params already read.
|
||||
const isHostPrefixed = cookie.name.startsWith(HOST_PREFIX)
|
||||
return {
|
||||
url: cookie.url,
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
domain: cookie.domain,
|
||||
hostOnly: isHostPrefixed,
|
||||
path: isHostPrefixed ? '/' : cookie.path,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite,
|
||||
...(cookie.expirationDate === undefined ? {} : { expirationDate: cookie.expirationDate }),
|
||||
...(partitionKey ? { partitionKey } : {})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decides how one source cookie is written.
|
||||
*
|
||||
* Why (STA-4300): a cookie whose partition identity is unreadable is skipped and counted, never
|
||||
* written unpartitioned. Downgrading it would import a cookie the site cannot see while reporting a
|
||||
* clean success — the silent-loss shape behind STA-4013/4061/4090/4170.
|
||||
*/
|
||||
export function planImportedCookieWrite(
|
||||
cookie: ImportedCookieFields,
|
||||
partition: SourcePartitionRead
|
||||
): ImportWritePlan {
|
||||
if (partition.status === 'unreadable') {
|
||||
return { status: 'skip', reason: partition.reason }
|
||||
}
|
||||
return {
|
||||
status: 'write',
|
||||
identity: importedCookieIdentity(
|
||||
cookie,
|
||||
partition.status === 'partitioned' ? partition.partitionKey : undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the rollback removes by coordinate, and remove() is path-sensitive, so the key has to use
|
||||
// the identity's resolved path rather than the source cookie's.
|
||||
export function importedCookieRemovalKey(identity: CookieClearIdentity): {
|
||||
url: string
|
||||
name: string
|
||||
} {
|
||||
const removalUrl = new URL(identity.url)
|
||||
const path = identity.path ?? '/'
|
||||
removalUrl.pathname = path.startsWith('/') ? path : '/'
|
||||
return { url: removalUrl.toString(), name: identity.name }
|
||||
}
|
||||
|
||||
export type SourceCookieToWrite = ImportedCookieFields & { partition: SourcePartitionRead }
|
||||
|
||||
export type ImportWritePhase = {
|
||||
importedKeys: { url: string; name: string }[]
|
||||
importedCount: number
|
||||
writeRejected: number
|
||||
partitionSkipped: number
|
||||
domains: Set<string>
|
||||
failure: unknown
|
||||
}
|
||||
|
||||
export function emptyImportWritePhase(): ImportWritePhase {
|
||||
return {
|
||||
importedKeys: [],
|
||||
importedCount: 0,
|
||||
writeRejected: 0,
|
||||
partitionSkipped: 0,
|
||||
domains: new Set<string>(),
|
||||
failure: null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: cookie values are secret; only the domain is ever logged or summarized.
|
||||
function summaryDomain(domain: string): string {
|
||||
return domain.startsWith('.') ? domain.slice(1) : domain
|
||||
}
|
||||
|
||||
function firstNonPrintable(value: string): string {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index)
|
||||
if (code < 0x20 || code > 0x7e) {
|
||||
return `pos=${index} char=U+${code.toString(16).padStart(4, '0')}`
|
||||
}
|
||||
}
|
||||
return 'none found'
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes one import's cookies through the CDP identity store.
|
||||
*
|
||||
* `stopOnFailure` mirrors the replace path's contract: once existing cookies have been removed, the
|
||||
* first rejection has to stop the run so the caller can roll the whole thing back.
|
||||
*/
|
||||
export async function writeImportedCookies(
|
||||
store: Pick<CookieImportWriteStore, 'writeCookieIdentity'>,
|
||||
cookies: readonly SourceCookieToWrite[],
|
||||
options: { stopOnFailure: boolean; log: (message: string) => void }
|
||||
): Promise<ImportWritePhase> {
|
||||
const phase = emptyImportWritePhase()
|
||||
|
||||
for (const cookie of cookies) {
|
||||
const plan = planImportedCookieWrite(cookie, cookie.partition)
|
||||
if (plan.status === 'skip') {
|
||||
phase.partitionSkipped += 1
|
||||
options.log(
|
||||
` cookie skipped, unreadable partition: domain=${summaryDomain(cookie.domain)} ${plan.reason}`
|
||||
)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
await store.writeCookieIdentity(plan.identity)
|
||||
phase.importedKeys.push(importedCookieRemovalKey(plan.identity))
|
||||
phase.importedCount += 1
|
||||
phase.domains.add(summaryDomain(cookie.domain))
|
||||
} catch (err) {
|
||||
phase.writeRejected += 1
|
||||
phase.failure = err
|
||||
if (phase.writeRejected <= 5) {
|
||||
options.log(
|
||||
` cookie write REJECTED: domain=${summaryDomain(cookie.domain)} valLen=${cookie.value.length} badChar=${firstNonPrintable(cookie.value)} err=${String(err)}`
|
||||
)
|
||||
}
|
||||
if (options.stopOnFailure) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return phase
|
||||
}
|
||||
@@ -8,8 +8,7 @@ const {
|
||||
sessionFromPartitionMock,
|
||||
dialogShowOpenDialogMock,
|
||||
setPendingCookieImportMock,
|
||||
clearPendingCookieImportMock,
|
||||
writeCookieIdentityMock
|
||||
clearPendingCookieImportMock
|
||||
} = vi.hoisted(() => ({
|
||||
appGetPathMock: vi.fn(),
|
||||
copyFileSyncMock: vi.fn(),
|
||||
@@ -17,8 +16,7 @@ const {
|
||||
sessionFromPartitionMock: vi.fn(),
|
||||
dialogShowOpenDialogMock: vi.fn(),
|
||||
setPendingCookieImportMock: vi.fn(),
|
||||
clearPendingCookieImportMock: vi.fn(),
|
||||
writeCookieIdentityMock: vi.fn()
|
||||
clearPendingCookieImportMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('./browser-session-registry', () => ({
|
||||
@@ -58,10 +56,6 @@ vi.mock('./browser-cookie-clear-store', () => ({
|
||||
snapshotClearIdentities: async (items: { cookie: Record<string, unknown>; url: string }[]) =>
|
||||
items.map(({ cookie, url }) => ({ url, ...cookie })),
|
||||
restoreClearIdentities: async () => undefined,
|
||||
// Why (STA-4300): the import writes land here, not on cookies.set. A store mock missing this
|
||||
// method would throw a TypeError the per-cookie catch swallows, quietly turning every write
|
||||
// into a "rejected cookie" while the suite still looked green.
|
||||
writeCookieIdentity: writeCookieIdentityMock,
|
||||
dispose: () => undefined
|
||||
})
|
||||
}))
|
||||
@@ -103,15 +97,6 @@ function chromeBrowser(cookiesPath: string): DetectedBrowser {
|
||||
}
|
||||
}
|
||||
|
||||
// Why (STA-4300): cookies.set() silently drops partitionKey, so no user cookie may reach it. Only
|
||||
// the __init probe — which writes no user data — is allowed through; anything else is the
|
||||
// downgrade returning, and it must fail the test rather than quietly succeed.
|
||||
const unreachableCookieSet = vi.fn(async (details: { name: string }) => {
|
||||
if (details.name !== '__init') {
|
||||
throw new Error(`cookies.set was called for user cookie ${details.name}`)
|
||||
}
|
||||
})
|
||||
|
||||
const LARGE_SAFARI_COOKIE_COUNT = 150_000
|
||||
|
||||
describe('summarizeCookieImportError', () => {
|
||||
@@ -191,21 +176,19 @@ describe('importCookiesFromFile', () => {
|
||||
let tmpDir: string
|
||||
let cookiesGetMock: ReturnType<typeof vi.fn>
|
||||
let cookiesRemoveMock: ReturnType<typeof vi.fn>
|
||||
let cookieWriteMock: ReturnType<typeof vi.fn>
|
||||
let cookiesSetMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-test-'))
|
||||
cookiesGetMock = vi.fn().mockResolvedValue([])
|
||||
cookiesRemoveMock = vi.fn().mockResolvedValue(undefined)
|
||||
cookieWriteMock = writeCookieIdentityMock
|
||||
cookieWriteMock.mockReset()
|
||||
cookieWriteMock.mockResolvedValue(undefined)
|
||||
cookiesSetMock = vi.fn().mockResolvedValue(undefined)
|
||||
sessionFromPartitionMock.mockReset()
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
cookies: {
|
||||
get: cookiesGetMock,
|
||||
remove: cookiesRemoveMock,
|
||||
set: unreachableCookieSet
|
||||
set: cookiesSetMock
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -254,8 +237,8 @@ describe('importCookiesFromFile', () => {
|
||||
expect(result.summary.domains).toContain('github.com')
|
||||
expect(result.summary.domains).toContain('example.com')
|
||||
|
||||
expect(cookieWriteMock).toHaveBeenCalledTimes(2)
|
||||
const firstCall = cookieWriteMock.mock.calls[0][0]
|
||||
expect(cookiesSetMock).toHaveBeenCalledTimes(2)
|
||||
const firstCall = cookiesSetMock.mock.calls[0][0]
|
||||
expect(firstCall.name).toBe('_gh_sess')
|
||||
expect(firstCall.domain).toBe('.github.com')
|
||||
expect(firstCall.secure).toBe(true)
|
||||
@@ -279,18 +262,14 @@ describe('importCookiesFromFile', () => {
|
||||
const result = await importCookiesFromFile(filePath, 'persist:test')
|
||||
expect(result.ok).toBe(true)
|
||||
|
||||
const hostCall = cookieWriteMock.mock.calls
|
||||
const hostCall = cookiesSetMock.mock.calls
|
||||
.map((c) => c[0])
|
||||
.find((c) => c.name === '__Host-user_session_same_site')
|
||||
// __Host- prefix requires no Domain attribute and path=/, or Chromium drops it. hostOnly is how
|
||||
// the identity says "omit domain"; cdpSetCookieParamsFromIdentity drops it on the wire.
|
||||
expect(hostCall.hostOnly).toBe(true)
|
||||
// __Host- prefix requires no Domain attribute and path=/, or Chromium drops it.
|
||||
expect(hostCall).not.toHaveProperty('domain')
|
||||
expect(hostCall.path).toBe('/')
|
||||
|
||||
const normalCall = cookieWriteMock.mock.calls
|
||||
.map((c) => c[0])
|
||||
.find((c) => c.name === '_gh_sess')
|
||||
expect(normalCall.hostOnly).toBe(false)
|
||||
const normalCall = cookiesSetMock.mock.calls.map((c) => c[0]).find((c) => c.name === '_gh_sess')
|
||||
expect(normalCall.domain).toBe('.github.com')
|
||||
expect(normalCall.path).toBe('/settings')
|
||||
})
|
||||
@@ -384,11 +363,11 @@ describe('importCookiesFromFile', () => {
|
||||
|
||||
await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(cookieWriteMock.mock.calls[0][0].sameSite).toBe('no_restriction')
|
||||
expect(cookieWriteMock.mock.calls[1][0].sameSite).toBe('lax')
|
||||
expect(cookieWriteMock.mock.calls[2][0].sameSite).toBe('strict')
|
||||
expect(cookieWriteMock.mock.calls[3][0].sameSite).toBe('unspecified')
|
||||
expect(cookieWriteMock.mock.calls[4][0].sameSite).toBe('unspecified')
|
||||
expect(cookiesSetMock.mock.calls[0][0].sameSite).toBe('no_restriction')
|
||||
expect(cookiesSetMock.mock.calls[1][0].sameSite).toBe('lax')
|
||||
expect(cookiesSetMock.mock.calls[2][0].sameSite).toBe('strict')
|
||||
expect(cookiesSetMock.mock.calls[3][0].sameSite).toBe('unspecified')
|
||||
expect(cookiesSetMock.mock.calls[4][0].sameSite).toBe('unspecified')
|
||||
})
|
||||
|
||||
it('derives correct URL from domain and secure flag', async () => {
|
||||
@@ -400,13 +379,13 @@ describe('importCookiesFromFile', () => {
|
||||
|
||||
await importCookiesFromFile(filePath, 'persist:test')
|
||||
|
||||
expect(cookieWriteMock.mock.calls[0][0].url).toBe('https://secure.com/')
|
||||
expect(cookieWriteMock.mock.calls[1][0].url).toBe('http://insecure.com/')
|
||||
expect(cookieWriteMock.mock.calls[2][0].url).toBe('http://nodot.com/')
|
||||
expect(cookiesSetMock.mock.calls[0][0].url).toBe('https://secure.com/')
|
||||
expect(cookiesSetMock.mock.calls[1][0].url).toBe('http://insecure.com/')
|
||||
expect(cookiesSetMock.mock.calls[2][0].url).toBe('http://nodot.com/')
|
||||
})
|
||||
|
||||
it('rolls back replacement when a cookie fails to set', async () => {
|
||||
cookieWriteMock.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('set failed'))
|
||||
cookiesSetMock.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('set failed'))
|
||||
|
||||
const filePath = writeCookieFile([
|
||||
{ domain: '.a.com', name: 'ok', value: '1' },
|
||||
@@ -421,16 +400,14 @@ describe('importCookiesFromFile', () => {
|
||||
|
||||
describe('importCookiesFromBrowser Safari', () => {
|
||||
let tmpDir: string
|
||||
let cookieWriteMock: ReturnType<typeof vi.fn>
|
||||
let cookiesSetMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-safari-cookie-test-'))
|
||||
cookieWriteMock = writeCookieIdentityMock
|
||||
cookieWriteMock.mockReset()
|
||||
cookieWriteMock.mockResolvedValue(undefined)
|
||||
cookiesSetMock = vi.fn().mockResolvedValue(undefined)
|
||||
sessionFromPartitionMock.mockReset()
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
cookies: { set: unreachableCookieSet }
|
||||
cookies: { set: cookiesSetMock }
|
||||
})
|
||||
})
|
||||
|
||||
@@ -452,13 +429,13 @@ describe('importCookiesFromBrowser Safari', () => {
|
||||
const result = await importCookiesFromBrowser(browser, 'persist:test')
|
||||
|
||||
expect(result).toEqual({ ok: false, reason: 'All Safari cookies are expired.' })
|
||||
expect(cookieWriteMock).not.toHaveBeenCalled()
|
||||
expect(cookiesSetMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('importCookiesFromBrowser Chromium', () => {
|
||||
let tmpDir: string
|
||||
let cookieWriteMock: ReturnType<typeof vi.fn>
|
||||
let cookiesSetMock: ReturnType<typeof vi.fn>
|
||||
let cookiesRemoveMock: ReturnType<typeof vi.fn>
|
||||
let cookiesFlushStoreMock: ReturnType<typeof vi.fn>
|
||||
let clearDataMock: ReturnType<typeof vi.fn>
|
||||
@@ -466,9 +443,7 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'orca-chromium-cookie-test-'))
|
||||
cookieWriteMock = writeCookieIdentityMock
|
||||
cookieWriteMock.mockReset()
|
||||
cookieWriteMock.mockResolvedValue(undefined)
|
||||
cookiesSetMock = vi.fn().mockResolvedValue(undefined)
|
||||
cookiesRemoveMock = vi.fn().mockResolvedValue(undefined)
|
||||
cookiesFlushStoreMock = vi.fn().mockResolvedValue(undefined)
|
||||
clearDataMock = vi.fn().mockResolvedValue(undefined)
|
||||
@@ -486,19 +461,19 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
sessionFromPartitionMock.mockReturnValue({
|
||||
cookies: {
|
||||
get: vi.fn().mockResolvedValue([]),
|
||||
set: unreachableCookieSet,
|
||||
set: cookiesSetMock,
|
||||
remove: cookiesRemoveMock,
|
||||
flushStore: cookiesFlushStoreMock
|
||||
},
|
||||
clearData: clearDataMock,
|
||||
setUserAgent: setUserAgentMock,
|
||||
getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test')
|
||||
setUserAgent: setUserAgentMock
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('imports from a live Chromium source DB into a Network/Cookies target profile', async () => {
|
||||
const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies')
|
||||
const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies')
|
||||
@@ -532,7 +507,7 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(cookieWriteMock).toHaveBeenCalledWith(
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
domain: '.example.com',
|
||||
name: 'sid',
|
||||
@@ -593,7 +568,7 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
expect.any(Array),
|
||||
expect.any(Object)
|
||||
)
|
||||
expect(cookieWriteMock).toHaveBeenCalledWith(
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'sid', value: 'encrypted-value' })
|
||||
)
|
||||
} finally {
|
||||
@@ -648,7 +623,7 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(cookieWriteMock).toHaveBeenCalledWith(
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'sid', value: 'source-value' })
|
||||
)
|
||||
// The partial staging file is still discarded, so no stale DB replays on cold start.
|
||||
@@ -678,7 +653,7 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(cookieWriteMock).toHaveBeenCalledWith(
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'sid', value: 'source-value' })
|
||||
)
|
||||
// The summary counts importable cookies, not staged rows.
|
||||
@@ -700,7 +675,7 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
mkdirSync(dirname(targetCookiesPath), { recursive: true })
|
||||
writeFileSync(targetCookiesPath, 'not a sqlite database')
|
||||
// Forces the restart fallback to be the only way these cookies could ever land.
|
||||
cookieWriteMock.mockRejectedValue(new Error('cookie rejected'))
|
||||
cookiesSetMock.mockRejectedValue(new Error('cookie rejected'))
|
||||
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
try {
|
||||
@@ -742,7 +717,7 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
targetDb.close()
|
||||
// Why: without a memory failure, memoryFailed === 0 would suppress registration on its own and
|
||||
// the assertion below would pass even if the insert failure never disabled staging.
|
||||
cookieWriteMock.mockRejectedValue(new Error('cookie rejected'))
|
||||
cookiesSetMock.mockRejectedValue(new Error('cookie rejected'))
|
||||
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
try {
|
||||
@@ -752,7 +727,7 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(cookieWriteMock).toHaveBeenCalledWith(
|
||||
expect(cookiesSetMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'sid', value: 'source-value' })
|
||||
)
|
||||
expect(setPendingCookieImportMock).not.toHaveBeenCalled()
|
||||
@@ -798,7 +773,7 @@ describe('importCookiesFromBrowser Chromium', () => {
|
||||
{ name: 'sid', value: 'source-value' }
|
||||
]).close()
|
||||
createChromiumCookieTestDatabase(targetCookiesPath, []).close()
|
||||
cookieWriteMock.mockRejectedValue(new Error('cookie rejected'))
|
||||
cookiesSetMock.mockRejectedValue(new Error('cookie rejected'))
|
||||
|
||||
const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
try {
|
||||
|
||||
@@ -84,26 +84,8 @@ import {
|
||||
type CookieImportMode,
|
||||
type ReplacedImportedDomainCookies
|
||||
} from './browser-cookie-import-policy'
|
||||
import {
|
||||
removeTransplantableCookies,
|
||||
withCookieClearLock,
|
||||
type CookieClearStore,
|
||||
type CookieImportWriteStore
|
||||
} from './browser-cookie-import-clear'
|
||||
import { removeTransplantableCookies, withCookieClearLock } from './browser-cookie-import-clear'
|
||||
import { openCookieClearStore } from './browser-cookie-clear-store'
|
||||
import {
|
||||
readChromiumRowPartition,
|
||||
readFirefoxRowPartition,
|
||||
readJsonCookiePartition,
|
||||
type SourcePartitionRead
|
||||
} from './browser-cookie-source-partition'
|
||||
import {
|
||||
emptyImportWritePhase,
|
||||
writeImportedCookies,
|
||||
type ImportedCookieFields,
|
||||
type ImportWritePhase,
|
||||
type SourceCookieToWrite
|
||||
} from './browser-cookie-import-write'
|
||||
import {
|
||||
createChromiumCookieSnapshot,
|
||||
type ChromiumCookieSnapshot
|
||||
@@ -466,14 +448,18 @@ type RawCookieEntry = {
|
||||
httpOnly?: unknown
|
||||
sameSite?: unknown
|
||||
expirationDate?: unknown
|
||||
partitionKey?: unknown
|
||||
}
|
||||
|
||||
// Why (STA-4300): `partition` is required, not optional, so every source that builds a cookie has to
|
||||
// state what it read. An optional field would let a new source silently default to unpartitioned.
|
||||
type ValidatedCookie = ImportedCookieFields & {
|
||||
type ValidatedCookie = {
|
||||
url: string
|
||||
name: string
|
||||
value: string
|
||||
domain: string
|
||||
path: string
|
||||
secure: boolean
|
||||
httpOnly: boolean
|
||||
sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict'
|
||||
partition: SourcePartitionRead
|
||||
expirationDate: number | undefined
|
||||
}
|
||||
|
||||
// Why: Chromium's CookieSameSiteForStorage enum (0=Unspecified,1=None,2=Lax,3=Strict) differs from Firefox's numbering.
|
||||
@@ -523,7 +509,7 @@ function normalizeSameSite(raw: unknown): 'unspecified' | 'no_restriction' | 'la
|
||||
return 'unspecified'
|
||||
}
|
||||
|
||||
// Why: a cookie identity needs a url to scope it; derive it from domain + secure flag.
|
||||
// Why: cookies.set() needs a url to scope the cookie; derive it from domain + secure flag.
|
||||
function deriveUrl(domain: string, secure: boolean): string | null {
|
||||
const normalizedDomain = normalizeCookieDomain(domain)
|
||||
if (!normalizedDomain) {
|
||||
@@ -570,40 +556,16 @@ function validateCookieEntry(raw: RawCookieEntry): ValidatedCookie | null {
|
||||
secure,
|
||||
httpOnly: raw.httpOnly === true || raw.httpOnly === 1,
|
||||
sameSite: normalizeSameSite(raw.sameSite),
|
||||
expirationDate,
|
||||
partition: readJsonCookiePartition(raw.partitionKey)
|
||||
}
|
||||
}
|
||||
|
||||
// Why (STA-4300): the import writes get a store with no `set` on it and no Session behind it, so
|
||||
// the partition-dropping write is not merely unused here — it cannot be reached.
|
||||
type CookieImportSessionStore = CookieClearStore & CookieImportWriteStore & { dispose: () => void }
|
||||
|
||||
type CookieImportTarget = {
|
||||
partition: string
|
||||
openWriteStore: () => CookieImportSessionStore
|
||||
}
|
||||
|
||||
type CookieImportOptions = {
|
||||
canReportPartitionSkippedCookies?: boolean
|
||||
}
|
||||
|
||||
function cookieImportTarget(targetPartition: string): CookieImportTarget {
|
||||
const targetSession = session.fromPartition(targetPartition)
|
||||
return {
|
||||
partition: targetPartition,
|
||||
openWriteStore: () => openCookieClearStore(targetSession)
|
||||
expirationDate
|
||||
}
|
||||
}
|
||||
|
||||
async function importValidatedCookies(
|
||||
cookies: ValidatedCookie[],
|
||||
totalInput: number,
|
||||
target: CookieImportTarget,
|
||||
mode: CookieImportMode,
|
||||
options: CookieImportOptions = {}
|
||||
targetPartition: string,
|
||||
mode: CookieImportMode
|
||||
): Promise<BrowserCookieImportResult> {
|
||||
const targetPartition = target.partition
|
||||
const importDomainCache = new Map<string, boolean>()
|
||||
const validDomainCookies = cookies.filter((cookie) => {
|
||||
let valid = importDomainCache.get(cookie.domain)
|
||||
@@ -628,94 +590,128 @@ async function importValidatedCookies(
|
||||
diag(
|
||||
`importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped, ${nonTransplantableSkipped} non-transplantable skipped of ${totalInput} total, partition="${targetPartition}"`
|
||||
)
|
||||
// Why: an older remote client cannot surface this skip, so fail before opening the target jar.
|
||||
if (
|
||||
options.canReportPartitionSkippedCookies === false &&
|
||||
importableCookies.some((cookie) => cookie.partition.status === 'unreadable')
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.'
|
||||
}
|
||||
}
|
||||
const targetSession = session.fromPartition(targetPartition)
|
||||
let importedCount = 0
|
||||
let skipped = totalInput - importableCookies.length
|
||||
let phase: ImportWritePhase = emptyImportWritePhase()
|
||||
// Why (STA-4097/STA-4300): both the rollback and the import writes need CDP identities — only
|
||||
// they carry partitionKey. cookies.set drops it silently, on the success path as well.
|
||||
const cookieClearStore = importableCookies.length > 0 ? target.openWriteStore() : null
|
||||
const domainSet = new Set<string>()
|
||||
let replaced: ReplacedImportedDomainCookies | null = null
|
||||
// Why (STA-4097): the rollback below has to put back cookies this import already deleted, and
|
||||
// only CDP identities carry partitionKey — rebuilding them with cookies.set drops it silently.
|
||||
const cookieClearStore =
|
||||
mode === 'replace-imported-domains' && importableCookies.length > 0
|
||||
? openCookieClearStore(targetSession)
|
||||
: null
|
||||
|
||||
if (cookieClearStore) {
|
||||
let replaced: ReplacedImportedDomainCookies | null = null
|
||||
try {
|
||||
if (mode === 'replace-imported-domains') {
|
||||
try {
|
||||
// Why: a skipped cookie must not erase the existing session it never replaces.
|
||||
const replacementDomains = importableCookies
|
||||
.filter((cookie) => cookie.partition.status !== 'unreadable')
|
||||
.map((cookie) => cookie.domain)
|
||||
replaced = await replaceCookiesForImportedDomains(cookieClearStore, replacementDomains)
|
||||
diag(` removed ${replaced.removed.length} existing cookies in imported domain scopes`)
|
||||
} catch (err) {
|
||||
diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`)
|
||||
return {
|
||||
ok: false,
|
||||
reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: Chromium rejects any non-printable-ASCII byte in a cookie value; strip as a safety net.
|
||||
const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '')
|
||||
phase = await writeImportedCookies(
|
||||
cookieClearStore,
|
||||
importableCookies.map((cookie) => ({ ...cookie, value: stripNonPrintable(cookie.value) })),
|
||||
{ stopOnFailure: replaced !== null, log: diag }
|
||||
)
|
||||
skipped += phase.writeRejected + phase.partitionSkipped
|
||||
|
||||
if (phase.failure && replaced) {
|
||||
const rollbackFailures: unknown[] = []
|
||||
for (const cookie of phase.importedKeys.toReversed()) {
|
||||
try {
|
||||
await cookieClearStore.remove(cookie.url, cookie.name)
|
||||
} catch (err) {
|
||||
rollbackFailures.push(err)
|
||||
}
|
||||
}
|
||||
// Why: restoreClearIdentities attaches the debugger before it iterates, so an empty
|
||||
// restore set would spin up a hidden BrowserWindow to put nothing back.
|
||||
if (replaced.identities.length > 0) {
|
||||
try {
|
||||
await cookieClearStore.restoreClearIdentities(replaced.identities.toReversed())
|
||||
} catch (err) {
|
||||
rollbackFailures.push(err)
|
||||
}
|
||||
}
|
||||
if (rollbackFailures.length > 0) {
|
||||
diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`)
|
||||
}
|
||||
try {
|
||||
if (cookieClearStore) {
|
||||
try {
|
||||
replaced = await replaceCookiesForImportedDomains(
|
||||
cookieClearStore,
|
||||
importableCookies.map((cookie) => cookie.domain)
|
||||
)
|
||||
diag(` removed ${replaced.removed.length} existing cookies in imported domain scopes`)
|
||||
} catch (err) {
|
||||
diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`)
|
||||
return {
|
||||
ok: false,
|
||||
reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.')
|
||||
reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cookieClearStore.dispose()
|
||||
}
|
||||
|
||||
// Why: Electron's cookies.set() rejects any non-printable-ASCII byte; strip as a safety net.
|
||||
const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '')
|
||||
const importedCookieKeys: { url: string; name: string }[] = []
|
||||
let setFailure: unknown = null
|
||||
|
||||
for (const cookie of importableCookies) {
|
||||
try {
|
||||
// Why: Chromium rejects __Host- cookies unless they omit domain and use path=/.
|
||||
const isHostPrefixed = cookie.name.startsWith('__Host-')
|
||||
const path = isHostPrefixed ? '/' : cookie.path
|
||||
await targetSession.cookies.set({
|
||||
url: cookie.url,
|
||||
name: cookie.name,
|
||||
value: stripNonPrintable(cookie.value),
|
||||
...(isHostPrefixed ? {} : { domain: cookie.domain }),
|
||||
path,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite,
|
||||
expirationDate: cookie.expirationDate
|
||||
})
|
||||
const removalUrl = new URL(cookie.url)
|
||||
removalUrl.pathname = path.startsWith('/') ? path : '/'
|
||||
importedCookieKeys.push({ url: removalUrl.toString(), name: cookie.name })
|
||||
importedCount++
|
||||
// Why: surface only the domain (never name/value/path) so the summary doesn't leak secret cookie data.
|
||||
const cleanDomain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain
|
||||
domainSet.add(cleanDomain)
|
||||
} catch (err) {
|
||||
skipped++
|
||||
setFailure = err
|
||||
if (skipped <= 5) {
|
||||
// Find the exact offending character position and code
|
||||
const val = cookie.value
|
||||
let badInfo = 'none found'
|
||||
for (let i = 0; i < val.length; i++) {
|
||||
const code = val.charCodeAt(i)
|
||||
if (code < 0x20 || code > 0x7e) {
|
||||
badInfo = `pos=${i} char=U+${code.toString(16).padStart(4, '0')}`
|
||||
break
|
||||
}
|
||||
}
|
||||
diag(
|
||||
` cookie.set FAILED: domain=${cookie.domain} name=${cookie.name} valLen=${val.length} badChar=${badInfo} err=${String(err)}`
|
||||
)
|
||||
}
|
||||
if (replaced) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: replaced is only ever set alongside cookieClearStore, which owns the CDP restore.
|
||||
if (setFailure && replaced && cookieClearStore) {
|
||||
const rollbackFailures: unknown[] = []
|
||||
for (const cookie of importedCookieKeys.toReversed()) {
|
||||
try {
|
||||
await targetSession.cookies.remove(cookie.url, cookie.name)
|
||||
} catch (err) {
|
||||
rollbackFailures.push(err)
|
||||
}
|
||||
}
|
||||
// Why: restoreClearIdentities attaches the debugger before it iterates, so an empty
|
||||
// restore set would spin up a hidden BrowserWindow to put nothing back.
|
||||
if (replaced.identities.length > 0) {
|
||||
try {
|
||||
await cookieClearStore.restoreClearIdentities(replaced.identities.toReversed())
|
||||
} catch (err) {
|
||||
rollbackFailures.push(err)
|
||||
}
|
||||
}
|
||||
if (rollbackFailures.length > 0) {
|
||||
diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`)
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cookieClearStore?.dispose()
|
||||
}
|
||||
|
||||
diag(
|
||||
`importValidatedCookies result: imported=${phase.importedCount} skipped=${skipped} partition-unreadable=${phase.partitionSkipped} domains=${phase.domains.size}`
|
||||
`importValidatedCookies result: imported=${importedCount} skipped=${skipped} domains=${domainSet.size}`
|
||||
)
|
||||
|
||||
const summary: BrowserCookieImportSummary = {
|
||||
totalCookies: totalInput,
|
||||
importedCookies: phase.importedCount,
|
||||
importedCookies: importedCount,
|
||||
skippedCookies: skipped,
|
||||
...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}),
|
||||
...(phase.partitionSkipped > 0 ? { partitionSkippedCookies: phase.partitionSkipped } : {}),
|
||||
domains: [...phase.domains].sort()
|
||||
domains: [...domainSet].sort()
|
||||
}
|
||||
|
||||
return { ok: true, profileId: '', summary }
|
||||
@@ -796,7 +792,7 @@ export async function importCookiesFromFile(
|
||||
return importValidatedCookies(
|
||||
validated,
|
||||
parsed.length,
|
||||
cookieImportTarget(targetPartition),
|
||||
targetPartition,
|
||||
'replace-imported-domains'
|
||||
)
|
||||
}
|
||||
@@ -1268,10 +1264,7 @@ function decodeSafariCookie(buf: Buffer): ValidatedCookie | null {
|
||||
secure,
|
||||
httpOnly,
|
||||
sameSite: 'unspecified',
|
||||
expirationDate,
|
||||
// Why: Cookies.binarycookies has no partition field — Safari's format predates CHIPS, so every
|
||||
// decoded cookie is genuinely unpartitioned rather than missing an identity.
|
||||
partition: { status: 'unpartitioned' }
|
||||
expirationDate
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1295,8 +1288,7 @@ function readCString(buf: Buffer, offset: number, end: number): string | null {
|
||||
|
||||
async function importCookiesFromFirefox(
|
||||
browser: DetectedBrowser,
|
||||
targetPartition: string,
|
||||
options: CookieImportOptions
|
||||
targetPartition: string
|
||||
): Promise<BrowserCookieImportResult> {
|
||||
diag(`importCookiesFromFirefox: partition="${targetPartition}"`)
|
||||
|
||||
@@ -1325,7 +1317,7 @@ async function importCookiesFromFirefox(
|
||||
|
||||
try {
|
||||
const db = new DatabaseSync(tmpCookiesPath, { readOnly: true })
|
||||
type FirefoxRow = Record<string, unknown> & {
|
||||
type FirefoxRow = {
|
||||
name: string
|
||||
value: string
|
||||
host: string
|
||||
@@ -1334,21 +1326,10 @@ async function importCookiesFromFirefox(
|
||||
isSecure: number
|
||||
isHttpOnly: number
|
||||
sameSite: number
|
||||
isPartitionedAttributeSet?: number
|
||||
}
|
||||
// Why: selecting a column an older moz_cookies schema lacks fails the whole import. A schema
|
||||
// without the server-declared partition flag predates that cookie identity.
|
||||
const firefoxColumns = new Set(
|
||||
(db.prepare('PRAGMA table_info(moz_cookies)').all() as { name: string }[]).map(
|
||||
(column) => column.name
|
||||
)
|
||||
)
|
||||
const partitionColumn = firefoxColumns.has('isPartitionedAttributeSet')
|
||||
? ', isPartitionedAttributeSet'
|
||||
: ''
|
||||
const rows = db
|
||||
.prepare(
|
||||
`SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite${partitionColumn} FROM moz_cookies`
|
||||
'SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite FROM moz_cookies'
|
||||
)
|
||||
.all() as FirefoxRow[]
|
||||
db.close()
|
||||
@@ -1385,8 +1366,7 @@ async function importCookiesFromFirefox(
|
||||
secure,
|
||||
httpOnly: row.isHttpOnly === 1,
|
||||
sameSite: firefoxSameSite(row.sameSite),
|
||||
expirationDate: row.expiry > 0 ? row.expiry : undefined,
|
||||
partition: readFirefoxRowPartition(row, firefoxColumns)
|
||||
expirationDate: row.expiry > 0 ? row.expiry : undefined
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1399,9 +1379,8 @@ async function importCookiesFromFirefox(
|
||||
return importValidatedCookies(
|
||||
validated,
|
||||
rows.length,
|
||||
cookieImportTarget(targetPartition),
|
||||
'replace-imported-domains',
|
||||
options
|
||||
targetPartition,
|
||||
'replace-imported-domains'
|
||||
)
|
||||
} catch (err) {
|
||||
rmSync(tmpDir, { recursive: true, force: true })
|
||||
@@ -1459,7 +1438,7 @@ async function importCookiesFromSafari(
|
||||
return importValidatedCookies(
|
||||
valid,
|
||||
cookies.length,
|
||||
cookieImportTarget(targetPartition),
|
||||
targetPartition,
|
||||
'replace-imported-domains'
|
||||
)
|
||||
} catch (err) {
|
||||
@@ -1474,8 +1453,7 @@ async function importCookiesFromSafari(
|
||||
|
||||
export async function importCookiesFromBrowser(
|
||||
browser: DetectedBrowser,
|
||||
targetPartition: string,
|
||||
options: CookieImportOptions = {}
|
||||
targetPartition: string
|
||||
): Promise<BrowserCookieImportResult> {
|
||||
diag(`importCookiesFromBrowser: browser=${browser.family} partition="${targetPartition}"`)
|
||||
if (!existsSync(browser.cookiesPath)) {
|
||||
@@ -1484,7 +1462,7 @@ export async function importCookiesFromBrowser(
|
||||
}
|
||||
|
||||
if (browser.family === 'firefox') {
|
||||
return importCookiesFromFirefox(browser, targetPartition, options)
|
||||
return importCookiesFromFirefox(browser, targetPartition)
|
||||
}
|
||||
if (browser.family === 'safari') {
|
||||
return importCookiesFromSafari(browser, targetPartition)
|
||||
@@ -1496,15 +1474,8 @@ export async function importCookiesFromBrowser(
|
||||
const targetSession = session.fromPartition(targetPartition)
|
||||
await targetSession.cookies.flushStore()
|
||||
|
||||
// Why (STA-4300): ask the Session where its own storage lives instead of rebuilding the path from
|
||||
// the caller's partition string. String surgery on a caller-supplied name is what let a value like
|
||||
// "persist:../.." resolve a Cookies DB outside the Partitions directory and stage a replacement
|
||||
// over it; it also drifts whenever Chromium changes how a partition name maps to a directory.
|
||||
const partitionDir = targetSession.getStoragePath()
|
||||
if (!partitionDir) {
|
||||
return { ok: false, reason: 'Target cookie database not found. Open a browser tab first.' }
|
||||
}
|
||||
const partitionName = targetPartition.replace('persist:', '')
|
||||
const partitionDir = join(app.getPath('userData'), 'Partitions', partitionName)
|
||||
let liveCookiesPath = resolveChromiumCookiesPath(partitionDir)
|
||||
|
||||
// Why: Electron creates the Cookies file only after a cookie is stored; a throwaway set/remove forces DB init for unused profiles.
|
||||
@@ -1620,13 +1591,6 @@ export async function importCookiesFromBrowser(
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1668,15 +1632,20 @@ export async function importCookiesFromBrowser(
|
||||
let skipped = 0
|
||||
let integritySkipped = 0
|
||||
let nonTransplantableSkipped = 0
|
||||
let partitionSkipped = 0
|
||||
let memoryLoaded = 0
|
||||
let memoryFailed = 0
|
||||
const domainSet = new Set<string>()
|
||||
|
||||
type DecryptedCookie = Omit<ImportedCookieFields, 'url'> & {
|
||||
type DecryptedCookie = {
|
||||
decryptedValue: Buffer
|
||||
value: string
|
||||
domain: string
|
||||
name: string
|
||||
path: string
|
||||
secure: boolean
|
||||
httpOnly: boolean
|
||||
sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict'
|
||||
partition: SourcePartitionRead
|
||||
expirationDate: number | undefined
|
||||
}
|
||||
|
||||
const decryptedCookies: DecryptedCookie[] = []
|
||||
@@ -1752,12 +1721,14 @@ export async function importCookiesFromBrowser(
|
||||
continue
|
||||
}
|
||||
|
||||
const cleanDomain = domain.startsWith('.') ? domain.slice(1) : domain
|
||||
domainSet.add(cleanDomain)
|
||||
|
||||
const path = sourceRow.path as string
|
||||
const secure = sourceRow.is_secure === 1n
|
||||
const httpOnly = sourceRow.is_httponly === 1n
|
||||
const sameSite = chromiumSameSite(Number(sourceRow.samesite ?? 0))
|
||||
const expiresUtc = chromiumTimestampToUnix(sourceRow.expires_utc as bigint)
|
||||
const partition = readChromiumRowPartition(sourceRow, sourceColumns)
|
||||
// Why: cookie values are raw bytes, not UTF-8; latin1 preserves 0x00–0xFF without lossy replacement.
|
||||
const value = decryptedValue.toString('latin1')
|
||||
|
||||
@@ -1770,18 +1741,9 @@ export async function importCookiesFromBrowser(
|
||||
secure,
|
||||
httpOnly,
|
||||
sameSite,
|
||||
expirationDate: expiresUtc > 0 ? expiresUtc : undefined,
|
||||
partition
|
||||
expirationDate: expiresUtc > 0 ? expiresUtc : undefined
|
||||
})
|
||||
|
||||
if (partition.status === 'unreadable') {
|
||||
partitionSkipped++
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
const cleanDomain = domain.startsWith('.') ? domain.slice(1) : domain
|
||||
domainSet.add(cleanDomain)
|
||||
if (insertStmt && targetColumnInfo) {
|
||||
try {
|
||||
const params = buildChromiumCookieInsertParams(
|
||||
@@ -1803,18 +1765,7 @@ export async function importCookiesFromBrowser(
|
||||
)
|
||||
const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped
|
||||
|
||||
// Why: an older remote client ignores the new counter and would present this loss as success.
|
||||
if (partitionSkipped > 0 && options.canReportPartitionSkippedCookies === false) {
|
||||
closeStagingDb()
|
||||
discardStagingFile()
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
'This Orca client cannot report cookies skipped for an unreadable site partition. Update Orca on this device and try again.'
|
||||
}
|
||||
}
|
||||
|
||||
if (imported === 0) {
|
||||
if (decryptedCookies.length === 0) {
|
||||
closeStagingDb()
|
||||
discardStagingFile()
|
||||
return {
|
||||
@@ -1825,7 +1776,6 @@ export async function importCookiesFromBrowser(
|
||||
importedCookies: 0,
|
||||
skippedCookies: skipped + integritySkipped + nonTransplantableSkipped,
|
||||
...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}),
|
||||
...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}),
|
||||
domains: []
|
||||
}
|
||||
}
|
||||
@@ -1846,8 +1796,6 @@ export async function importCookiesFromBrowser(
|
||||
// Why: clear stale cookies first; mixing them with the imported set makes sites reject the
|
||||
// session. Non-transplantable families are exempt — nothing was imported for them, and their
|
||||
// live session is the only one that works.
|
||||
// Why (STA-4300): one store spans the clear and the writes, so both halves of the import speak
|
||||
// the same CDP identities — cookies.set() cannot express the partition either one reads.
|
||||
const cookieClearStore = openCookieClearStore(targetSession)
|
||||
try {
|
||||
await withCookieClearLock(targetSession, () =>
|
||||
@@ -1859,35 +1807,42 @@ export async function importCookiesFromBrowser(
|
||||
cookieClearStore.restoreClearIdentities(identities)
|
||||
})
|
||||
)
|
||||
diag(
|
||||
` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies`
|
||||
)
|
||||
|
||||
const writable: SourceCookieToWrite[] = []
|
||||
for (const cookie of decryptedCookies) {
|
||||
const url = deriveUrl(cookie.domain, cookie.secure)
|
||||
if (!url) {
|
||||
memoryFailed++
|
||||
continue
|
||||
}
|
||||
writable.push({ ...cookie, url })
|
||||
}
|
||||
// Why: a rejected cookie here falls back to the staged cold-start replay rather than
|
||||
// unwinding the import, so one failure must not stop the rest from loading.
|
||||
const phase = await writeImportedCookies(cookieClearStore, writable, {
|
||||
stopOnFailure: false,
|
||||
log: diag
|
||||
})
|
||||
memoryLoaded = phase.importedCount
|
||||
memoryFailed += phase.writeRejected
|
||||
} finally {
|
||||
cookieClearStore.dispose()
|
||||
}
|
||||
|
||||
diag(
|
||||
` memory load: ${memoryLoaded} OK, ${memoryFailed} failed, ${partitionSkipped} partition-unreadable`
|
||||
` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies`
|
||||
)
|
||||
|
||||
// Why: load into memory via cookies.set() so imported cookies work without a restart.
|
||||
for (const cookie of decryptedCookies) {
|
||||
const url = deriveUrl(cookie.domain, cookie.secure)
|
||||
if (!url) {
|
||||
memoryFailed++
|
||||
continue
|
||||
}
|
||||
try {
|
||||
// Why: Chromium rejects __Host- cookies unless they omit domain and use path=/.
|
||||
const isHostPrefixed = cookie.name.startsWith('__Host-')
|
||||
await targetSession.cookies.set({
|
||||
url,
|
||||
name: cookie.name,
|
||||
value: cookie.value,
|
||||
...(isHostPrefixed ? {} : { domain: cookie.domain }),
|
||||
path: isHostPrefixed ? '/' : cookie.path,
|
||||
secure: cookie.secure,
|
||||
httpOnly: cookie.httpOnly,
|
||||
sameSite: cookie.sameSite,
|
||||
expirationDate: cookie.expirationDate
|
||||
})
|
||||
memoryLoaded++
|
||||
} catch {
|
||||
memoryFailed++
|
||||
}
|
||||
}
|
||||
|
||||
diag(` memory load: ${memoryLoaded} OK, ${memoryFailed} failed`)
|
||||
|
||||
let warning: BrowserCookieImportSummary['warning']
|
||||
if (memoryFailed > 0 && stagingAvailable) {
|
||||
// Why: keep the staging DB so the failed cookies load from SQLite on next cold start, where CookieMonster skips validation.
|
||||
@@ -1924,7 +1879,6 @@ export async function importCookiesFromBrowser(
|
||||
importedCookies: imported,
|
||||
skippedCookies: skipped + integritySkipped + nonTransplantableSkipped,
|
||||
...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}),
|
||||
...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}),
|
||||
domains: [...domainSet].sort(),
|
||||
...(warning ? { warning } : {})
|
||||
}
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
readChromiumRowPartition,
|
||||
readFirefoxRowPartition,
|
||||
readJsonCookiePartition
|
||||
} from './browser-cookie-source-partition'
|
||||
|
||||
const MODERN_COLUMNS = new Set([
|
||||
'host_key',
|
||||
'name',
|
||||
'top_frame_site_key',
|
||||
'has_cross_site_ancestor'
|
||||
])
|
||||
|
||||
describe('readChromiumRowPartition', () => {
|
||||
it('reads a partitioned row as both halves of the partition key', () => {
|
||||
expect(
|
||||
readChromiumRowPartition(
|
||||
{ top_frame_site_key: 'https://top.example', has_cross_site_ancestor: 1n },
|
||||
MODERN_COLUMNS
|
||||
)
|
||||
).toEqual({
|
||||
status: 'partitioned',
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
})
|
||||
})
|
||||
|
||||
it('carries a false cross-site-ancestor rather than defaulting it to true', () => {
|
||||
expect(
|
||||
readChromiumRowPartition(
|
||||
{ top_frame_site_key: 'https://top.example', has_cross_site_ancestor: 0n },
|
||||
MODERN_COLUMNS
|
||||
)
|
||||
).toEqual({
|
||||
status: 'partitioned',
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: false }
|
||||
})
|
||||
})
|
||||
|
||||
it('reads an empty partition site as unpartitioned', () => {
|
||||
expect(
|
||||
readChromiumRowPartition(
|
||||
{ top_frame_site_key: '', has_cross_site_ancestor: 0n },
|
||||
MODERN_COLUMNS
|
||||
)
|
||||
).toEqual({ status: 'unpartitioned' })
|
||||
})
|
||||
|
||||
// Why: a schema predating cookie partitioning genuinely has no partitioned rows, so importing
|
||||
// every one of its cookies unpartitioned is faithful rather than lossy.
|
||||
it('reads a schema without the partition column as unpartitioned', () => {
|
||||
expect(readChromiumRowPartition({ name: 'sid' }, new Set(['host_key', 'name']))).toEqual({
|
||||
status: 'unpartitioned'
|
||||
})
|
||||
})
|
||||
|
||||
// Why (STA-4300): the ancestor bit selects which partition the cookie lands in. Guessing it files
|
||||
// the cookie under a partition the site never reads — indistinguishable from losing it.
|
||||
it('refuses a partitioned row whose schema has no cross-site-ancestor column', () => {
|
||||
const result = readChromiumRowPartition(
|
||||
{ top_frame_site_key: 'https://top.example' },
|
||||
new Set(['host_key', 'name', 'top_frame_site_key'])
|
||||
)
|
||||
|
||||
expect(result.status).toBe('unreadable')
|
||||
expect(result).toHaveProperty('reason', expect.stringContaining('cross-site-ancestor'))
|
||||
})
|
||||
|
||||
it('refuses a partitioned row whose ancestor flag is not an integer', () => {
|
||||
expect(
|
||||
readChromiumRowPartition(
|
||||
{ top_frame_site_key: 'https://top.example', has_cross_site_ancestor: null },
|
||||
MODERN_COLUMNS
|
||||
).status
|
||||
).toBe('unreadable')
|
||||
})
|
||||
|
||||
it('refuses a partition site column that is not text', () => {
|
||||
expect(
|
||||
readChromiumRowPartition(
|
||||
{ top_frame_site_key: 42, has_cross_site_ancestor: 1n },
|
||||
MODERN_COLUMNS
|
||||
).status
|
||||
).toBe('unreadable')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readJsonCookiePartition', () => {
|
||||
// Why: every mainstream exporter omits the field for ordinary cookies, so absence has to mean
|
||||
// unpartitioned or whole exports would be rejected.
|
||||
it('reads an absent partitionKey as unpartitioned', () => {
|
||||
expect(readJsonCookiePartition(undefined)).toEqual({ status: 'unpartitioned' })
|
||||
expect(readJsonCookiePartition(null)).toEqual({ status: 'unpartitioned' })
|
||||
})
|
||||
|
||||
it('reads a complete partitionKey object', () => {
|
||||
expect(
|
||||
readJsonCookiePartition({ topLevelSite: 'https://top.example', hasCrossSiteAncestor: true })
|
||||
).toEqual({
|
||||
status: 'partitioned',
|
||||
partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true }
|
||||
})
|
||||
})
|
||||
|
||||
// Why: exporters that emit only topLevelSite carry no ancestor bit. Skipping is reported; a
|
||||
// guessed bit would be a silent misfile.
|
||||
it('refuses a partitionKey missing the cross-site-ancestor bit', () => {
|
||||
expect(readJsonCookiePartition({ topLevelSite: 'https://top.example' }).status).toBe(
|
||||
'unreadable'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses the legacy string partitionKey form', () => {
|
||||
expect(readJsonCookiePartition('https://top.example').status).toBe('unreadable')
|
||||
})
|
||||
|
||||
it('refuses a partitionKey with a non-boolean ancestor bit', () => {
|
||||
expect(
|
||||
readJsonCookiePartition({ topLevelSite: 'https://top.example', hasCrossSiteAncestor: 'yes' })
|
||||
.status
|
||||
).toBe('unreadable')
|
||||
})
|
||||
|
||||
it('refuses an array or empty-site partitionKey', () => {
|
||||
expect(readJsonCookiePartition([]).status).toBe('unreadable')
|
||||
expect(readJsonCookiePartition({ topLevelSite: '', hasCrossSiteAncestor: true }).status).toBe(
|
||||
'unreadable'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readFirefoxRowPartition', () => {
|
||||
// Why: originAttributes partitionKey components describe Firefox storage isolation, not whether
|
||||
// the server declared the cookie Partitioned.
|
||||
it('keeps a dFPI row unpartitioned even with ancestor context', () => {
|
||||
expect(
|
||||
readFirefoxRowPartition(
|
||||
{
|
||||
originAttributes: '^partitionKey=(https,example.com,f)',
|
||||
isPartitionedAttributeSet: 0n
|
||||
},
|
||||
new Set(['originAttributes', 'isPartitionedAttributeSet'])
|
||||
)
|
||||
).toEqual({ status: 'unpartitioned' })
|
||||
})
|
||||
|
||||
it('refuses a server-declared partitioned cookie', () => {
|
||||
const result = readFirefoxRowPartition(
|
||||
{
|
||||
originAttributes: '^partitionKey=(https,example.com)',
|
||||
isPartitionedAttributeSet: 1n
|
||||
},
|
||||
new Set(['originAttributes', 'isPartitionedAttributeSet'])
|
||||
)
|
||||
|
||||
expect(result.status).toBe('unreadable')
|
||||
expect(result).toHaveProperty('reason', expect.stringContaining('cross-site-ancestor'))
|
||||
})
|
||||
|
||||
it('reads a schema without the partitioned-attribute column as unpartitioned', () => {
|
||||
expect(
|
||||
readFirefoxRowPartition(
|
||||
{ originAttributes: '^partitionKey=(https,example.com,f)' },
|
||||
new Set(['originAttributes'])
|
||||
)
|
||||
).toEqual({ status: 'unpartitioned' })
|
||||
})
|
||||
|
||||
it('refuses an invalid partitioned-attribute flag', () => {
|
||||
expect(
|
||||
readFirefoxRowPartition(
|
||||
{ isPartitionedAttributeSet: null },
|
||||
new Set(['isPartitionedAttributeSet'])
|
||||
).status
|
||||
).toBe('unreadable')
|
||||
})
|
||||
})
|
||||
@@ -1,125 +0,0 @@
|
||||
import type { CookieClearPartitionKey } from './browser-cookie-import-clear'
|
||||
|
||||
// Why (STA-4300): a partition identity that cannot be read faithfully must skip the cookie, never
|
||||
// downgrade it. An unpartitioned write of a partitioned cookie looks like a success and leaves the
|
||||
// site unable to see its own session — the failure mode that produced STA-4013/4061/4090/4170.
|
||||
export type SourcePartitionRead =
|
||||
| { status: 'unpartitioned' }
|
||||
| { status: 'partitioned'; partitionKey: CookieClearPartitionKey }
|
||||
| { status: 'unreadable'; reason: string }
|
||||
|
||||
const CHROMIUM_PARTITION_SITE_COLUMN = 'top_frame_site_key'
|
||||
const CHROMIUM_CROSS_SITE_ANCESTOR_COLUMN = 'has_cross_site_ancestor'
|
||||
|
||||
const UNPARTITIONED: SourcePartitionRead = { status: 'unpartitioned' }
|
||||
|
||||
function readSqliteFlag(raw: unknown): boolean | null {
|
||||
if (typeof raw === 'boolean') {
|
||||
return raw
|
||||
}
|
||||
if (typeof raw === 'bigint') {
|
||||
return raw !== 0n
|
||||
}
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||
return raw !== 0
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a Chromium cookie row's partition identity.
|
||||
*
|
||||
* Chromium stores the partition as `top_frame_site_key` (empty string when unpartitioned) plus
|
||||
* `has_cross_site_ancestor`, which older schemas predate. Both halves are required: a partition key
|
||||
* written with the wrong ancestor bit files the cookie under a partition the site never reads, which
|
||||
* is indistinguishable from losing it.
|
||||
*/
|
||||
export function readChromiumRowPartition(
|
||||
sourceRow: Record<string, unknown>,
|
||||
sourceColumns: ReadonlySet<string>
|
||||
): SourcePartitionRead {
|
||||
// Why: a schema without the column predates cookie partitioning, so every row is genuinely
|
||||
// unpartitioned — that is a faithful read, not a missing one.
|
||||
if (!sourceColumns.has(CHROMIUM_PARTITION_SITE_COLUMN)) {
|
||||
return UNPARTITIONED
|
||||
}
|
||||
|
||||
const rawSite = sourceRow[CHROMIUM_PARTITION_SITE_COLUMN]
|
||||
if (rawSite === null || rawSite === undefined || rawSite === '') {
|
||||
return UNPARTITIONED
|
||||
}
|
||||
if (typeof rawSite !== 'string') {
|
||||
return { status: 'unreadable', reason: 'partition site column was not text' }
|
||||
}
|
||||
|
||||
if (!sourceColumns.has(CHROMIUM_CROSS_SITE_ANCESTOR_COLUMN)) {
|
||||
return {
|
||||
status: 'unreadable',
|
||||
reason: 'source schema has no cross-site-ancestor column for a partitioned cookie'
|
||||
}
|
||||
}
|
||||
const hasCrossSiteAncestor = readSqliteFlag(sourceRow[CHROMIUM_CROSS_SITE_ANCESTOR_COLUMN])
|
||||
if (hasCrossSiteAncestor === null) {
|
||||
return { status: 'unreadable', reason: 'cross-site-ancestor column was not an integer flag' }
|
||||
}
|
||||
|
||||
return { status: 'partitioned', partitionKey: { topLevelSite: rawSite, hasCrossSiteAncestor } }
|
||||
}
|
||||
|
||||
const FIREFOX_PARTITIONED_ATTRIBUTE_COLUMN = 'isPartitionedAttributeSet'
|
||||
|
||||
/**
|
||||
* Reads whether Firefox recorded the server-declared `Partitioned` attribute.
|
||||
*
|
||||
* `originAttributes.partitionKey` is Firefox storage isolation and may include port or ancestor
|
||||
* context. The separate `isPartitionedAttributeSet` column is the server-declared CHIPS signal.
|
||||
*/
|
||||
export function readFirefoxRowPartition(
|
||||
sourceRow: Record<string, unknown>,
|
||||
sourceColumns: ReadonlySet<string>
|
||||
): SourcePartitionRead {
|
||||
if (!sourceColumns.has(FIREFOX_PARTITIONED_ATTRIBUTE_COLUMN)) {
|
||||
return UNPARTITIONED
|
||||
}
|
||||
const partitionedAttribute = readSqliteFlag(sourceRow[FIREFOX_PARTITIONED_ATTRIBUTE_COLUMN])
|
||||
if (partitionedAttribute === false) {
|
||||
return UNPARTITIONED
|
||||
}
|
||||
if (partitionedAttribute === null) {
|
||||
return { status: 'unreadable', reason: 'partitioned-attribute column was not an integer flag' }
|
||||
}
|
||||
return {
|
||||
status: 'unreadable',
|
||||
reason: 'Firefox partitioned-attribute cookie has no cross-site-ancestor bit to read'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a JSON cookie entry's partition identity.
|
||||
*
|
||||
* Absent means unpartitioned — every mainstream exporter omits the field for ordinary cookies, so
|
||||
* treating absence as unreadable would reject whole exports. Present-but-incomplete is unreadable:
|
||||
* exporters that emit only `topLevelSite` (or the legacy CDP string form) carry no ancestor bit, and
|
||||
* guessing it silently misfiles the cookie.
|
||||
*/
|
||||
export function readJsonCookiePartition(raw: unknown): SourcePartitionRead {
|
||||
if (raw === undefined || raw === null || raw === '') {
|
||||
return UNPARTITIONED
|
||||
}
|
||||
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return { status: 'unreadable', reason: 'partitionKey was not an object with both fields' }
|
||||
}
|
||||
|
||||
const { topLevelSite, hasCrossSiteAncestor } = raw as Record<string, unknown>
|
||||
if (typeof topLevelSite !== 'string' || topLevelSite.length === 0) {
|
||||
return { status: 'unreadable', reason: 'partitionKey.topLevelSite was missing or not text' }
|
||||
}
|
||||
if (typeof hasCrossSiteAncestor !== 'boolean') {
|
||||
return {
|
||||
status: 'unreadable',
|
||||
reason: 'partitionKey.hasCrossSiteAncestor was missing or not a boolean'
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'partitioned', partitionKey: { topLevelSite, hasCrossSiteAncestor } }
|
||||
}
|
||||
@@ -259,6 +259,9 @@ describe('Crashpad dump pruning', () => {
|
||||
|
||||
await _pruneCrashpadDumpsForTest(16)
|
||||
|
||||
expect((await readdir(path.join(dumpDir, 'reports'))).sort()).toEqual(['middle.dmp', 'new.dmp'])
|
||||
expect((await readdir(path.join(dumpDir, 'reports'))).sort()).toEqual([
|
||||
'middle.dmp',
|
||||
'new.dmp'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1550,7 +1550,6 @@ export class RuntimeBrowserCommands {
|
||||
profileId: string
|
||||
browserFamily: string
|
||||
browserProfile?: string
|
||||
supportsPartitionSkippedCookies?: true
|
||||
}): Promise<BrowserProfileImportFromBrowserResult> {
|
||||
const profile = browserSessionRegistry.getProfile(params.profileId)
|
||||
if (!profile) {
|
||||
@@ -1580,9 +1579,7 @@ export class RuntimeBrowserCommands {
|
||||
browser = reselected
|
||||
}
|
||||
|
||||
const result = await importCookiesFromBrowser(browser, profile.partition, {
|
||||
canReportPartitionSkippedCookies: params.supportsPartitionSkippedCookies === true
|
||||
})
|
||||
const result = await importCookiesFromBrowser(browser, profile.partition)
|
||||
if (!result.ok) {
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -148,8 +148,7 @@ export const ProfileDelete = z.object({ profileId: requiredString('Missing requi
|
||||
export const ProfileImportFromBrowser = z.object({
|
||||
profileId: requiredString('Missing required --profile'),
|
||||
browserFamily: requiredString('Missing required --browser-family'),
|
||||
browserProfile: OptionalString,
|
||||
supportsPartitionSkippedCookies: z.literal(true).optional()
|
||||
browserProfile: OptionalString
|
||||
})
|
||||
|
||||
export const Drag = BrowserTarget.extend({
|
||||
|
||||
@@ -68,8 +68,7 @@ describe('browser RPC methods', () => {
|
||||
makeRequest('browser.profileImportFromBrowser', {
|
||||
profileId: 'profile-1',
|
||||
browserFamily: 'chrome',
|
||||
browserProfile: 'Default',
|
||||
supportsPartitionSkippedCookies: true
|
||||
browserProfile: 'Default'
|
||||
})
|
||||
)
|
||||
|
||||
@@ -93,8 +92,7 @@ describe('browser RPC methods', () => {
|
||||
expect(runtime.browserProfileImportFromBrowser).toHaveBeenCalledWith({
|
||||
profileId: 'profile-1',
|
||||
browserFamily: 'chrome',
|
||||
browserProfile: 'Default',
|
||||
supportsPartitionSkippedCookies: true
|
||||
browserProfile: 'Default'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -783,8 +783,7 @@
|
||||
"toast": {
|
||||
"restartFallbackUnavailableNone": "None of the {{value0}} cookies could be loaded, and the restart fallback was unavailable. The previous cookies for this profile were replaced. Try the import again.",
|
||||
"restartFallbackUnavailablePartial": "Imported {{value0}} of {{value1}} cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again.",
|
||||
"googleCookiesSkipped": "Google cookies were not imported. Open a browser in Orca on {{value0}} with this profile, then sign into Google.",
|
||||
"partitionSkipped": "{{value0}} cookies were not imported because their site-partition could not be read. Sign in to those sites again in Orca."
|
||||
"googleCookiesSkipped": "Google cookies were not imported. Open a browser in Orca on {{value0}} with this profile, then sign into Google."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,32 +69,6 @@ describe('emitBrowserCookieImportToast', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Why (STA-4300): these cookies were skipped rather than written unpartitioned, so the success
|
||||
// count alone would report a lossy import as clean.
|
||||
it('warns separately about cookies skipped for an unreadable partition', () => {
|
||||
emitBrowserCookieImportToast(
|
||||
{ ...summary, importedCookies: 2, skippedCookies: 1, partitionSkippedCookies: 1 },
|
||||
'Imported 2 cookies.',
|
||||
'Local Mac'
|
||||
)
|
||||
|
||||
expect(successToastMock).toHaveBeenCalledWith('Imported 2 cookies.')
|
||||
expect(warningToastMock).toHaveBeenCalledWith(
|
||||
'1 cookies were not imported because their site-partition could not be read. Sign in to those sites again in Orca.',
|
||||
{ duration: 12000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not infer a partition warning from generic skipped cookies', () => {
|
||||
emitBrowserCookieImportToast(
|
||||
{ ...summary, importedCookies: 2, skippedCookies: 1 },
|
||||
'Imported 2 cookies.',
|
||||
'Local Mac'
|
||||
)
|
||||
|
||||
expect(warningToastMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not infer a Google warning from generic skipped cookies', () => {
|
||||
emitBrowserCookieImportToast(
|
||||
{ ...summary, importedCookies: 2, skippedCookies: 1 },
|
||||
|
||||
@@ -41,22 +41,6 @@ function emitGoogleCookieImportWarning(
|
||||
)
|
||||
}
|
||||
|
||||
// Why (STA-4300): these cookies were skipped rather than downgraded to unpartitioned, so the import
|
||||
// is lossy in a way the success count alone would hide.
|
||||
function emitPartitionSkippedImportWarning(summary: BrowserCookieImportSummary): void {
|
||||
if (!summary.partitionSkippedCookies) {
|
||||
return
|
||||
}
|
||||
toast.warning(
|
||||
translate(
|
||||
'auto.lib.browser.cookie.import.toast.partitionSkipped',
|
||||
'{{value0}} cookies were not imported because their site-partition could not be read. Sign in to those sites again in Orca.',
|
||||
{ value0: summary.partitionSkippedCookies }
|
||||
),
|
||||
{ duration: 12000 }
|
||||
)
|
||||
}
|
||||
|
||||
// Why: a degraded import returns ok:true with a warning, so every call site must route it to a
|
||||
// warning toast instead of reporting an unqualified success (#9355).
|
||||
export function emitBrowserCookieImportToast(
|
||||
@@ -71,5 +55,4 @@ export function emitBrowserCookieImportToast(
|
||||
toast.success(successMessage)
|
||||
}
|
||||
emitGoogleCookieImportWarning(summary, executionHostLabel)
|
||||
emitPartitionSkippedImportWarning(summary)
|
||||
}
|
||||
|
||||
@@ -169,17 +169,6 @@ describe('createBrowserSlice runtime guard', () => {
|
||||
.getState()
|
||||
.importCookiesFromBrowser('windows-profile', 'chrome', 'Default')
|
||||
await vi.waitFor(() => expect(resolveImport).toBeDefined())
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'windows-2',
|
||||
method: 'browser.profileImportFromBrowser',
|
||||
params: {
|
||||
profileId: 'windows-profile',
|
||||
browserFamily: 'chrome',
|
||||
browserProfile: 'Default',
|
||||
supportsPartitionSkippedCookies: true
|
||||
},
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
await store.getState().setBrowserSessionHostId('runtime:linux-3')
|
||||
const callsBeforeCompletion = runtimeEnvironmentCall.mock.calls.length
|
||||
resolveImport?.({
|
||||
|
||||
@@ -2201,7 +2201,7 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
|
||||
const result = await callRuntimeRpc<BrowserProfileImportFromBrowserResult>(
|
||||
{ kind: 'environment', environmentId: runtimeEnvironmentId },
|
||||
'browser.profileImportFromBrowser',
|
||||
{ profileId, browserFamily, browserProfile, supportsPartitionSkippedCookies: true },
|
||||
{ profileId, browserFamily, browserProfile },
|
||||
{ timeoutMs: 30_000 }
|
||||
)
|
||||
if (result.ok) {
|
||||
|
||||
@@ -142,9 +142,6 @@ export type BrowserCookieImportSummary = {
|
||||
importedCookies: number
|
||||
skippedCookies: number
|
||||
googleCookiesSkipped?: number
|
||||
// Why (STA-4300): cookies whose source partition identity could not be read faithfully are
|
||||
// skipped rather than written unpartitioned, and a skip is only honest if it is reported.
|
||||
partitionSkippedCookies?: number
|
||||
domains: string[]
|
||||
warning?: {
|
||||
code: 'restart-fallback-unavailable'
|
||||
|
||||
Reference in New Issue
Block a user