diff --git a/src/main/browser/browser-cookie-clear-store.test.ts b/src/main/browser/browser-cookie-clear-store.test.ts index 38c3d885fdf..65933968b07 100644 --- a/src/main/browser/browser-cookie-clear-store.test.ts +++ b/src/main/browser/browser-cookie-clear-store.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { Cookie } from 'electron' import { - cdpRestoreParamsFromIdentity, + cdpSetCookieParamsFromIdentity, cookieClearIdentitiesFromCdp } from './browser-cookie-clear-store' @@ -37,7 +37,7 @@ describe('cookie clear CDP identities', () => { partitionKey: { topLevelSite: 'https://top.example', hasCrossSiteAncestor: true } }) ]) - expect(cdpRestoreParamsFromIdentity(identities[0])).toEqual( + expect(cdpSetCookieParamsFromIdentity(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(cdpRestoreParamsFromIdentity(identities[0])).not.toHaveProperty('domain') - expect(cdpRestoreParamsFromIdentity(identities[1])).toHaveProperty('domain', '.example.com') + expect(cdpSetCookieParamsFromIdentity(identities[0])).not.toHaveProperty('domain') + expect(cdpSetCookieParamsFromIdentity(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( - cdpRestoreParamsFromIdentity({ + cdpSetCookieParamsFromIdentity({ url: 'https://example.com/', name: 'unspecified', value: 'value', diff --git a/src/main/browser/browser-cookie-clear-store.ts b/src/main/browser/browser-cookie-clear-store.ts index a749586f86f..d79843863cc 100644 --- a/src/main/browser/browser-cookie-clear-store.ts +++ b/src/main/browser/browser-cookie-clear-store.ts @@ -4,7 +4,8 @@ import { normalizeCookieDomain } from './browser-cookie-import-policy' import type { CookieClearIdentity, CookieClearPartitionKey, - CookieClearStore + CookieClearStore, + CookieImportWriteStore } from './browser-cookie-import-clear' type CdpCookiePartitionKey = { @@ -120,25 +121,24 @@ function identityFromCdpCookie(url: string, cdpCookie: CdpCookie): CookieClearId } } -async function attachCookieClearSession(targetSession: Session): Promise { - const existing = findPartitionWebContents(targetSession) - const window = existing - ? null - : new BrowserWindow({ - show: false, - webPreferences: { - session: targetSession, - sandbox: true, - contextIsolation: true, - nodeIntegration: false - } - }) - try { - if (window) { - await window.loadURL('data:text/html,cookie-clear') +function openHiddenCookieWindow(targetSession: Session): BrowserWindow { + return new BrowserWindow({ + show: false, + webPreferences: { + session: targetSession, + sandbox: true, + contextIsolation: true, + nodeIntegration: false } - const contents = existing ?? window?.webContents - if (!contents || contents.isDestroyed()) { + }) +} + +async function leaseHiddenCookieDebugger(targetSession: Session): Promise { + const window = openHiddenCookieWindow(targetSession) + try { + await window.loadURL('data:text/html,cookie-clear') + const contents = window.webContents + if (contents.isDestroyed()) { throw new Error('Could not attach to the cookie session for an atomic clear') } const lease = acquireElectronDebugger(contents) @@ -146,15 +146,30 @@ async function attachCookieClearSession(targetSession: Session): Promise { lease.release() - window?.destroy() + window.destroy() } } } catch (error) { - window?.destroy() + window.destroy() throw error } } +async function attachCookieClearSession(targetSession: Session): Promise { + 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[] @@ -191,7 +206,7 @@ export function cookieClearIdentitiesFromCdp( return identities } -export function cdpRestoreParamsFromIdentity( +export function cdpSetCookieParamsFromIdentity( identity: CookieClearIdentity ): Record { const sameSite = cdpSameSite(identity.sameSite) @@ -232,24 +247,34 @@ async function snapshotClearIdentitiesFromCdp( return cookieClearIdentitiesFromCdp(cookies, cdpCookiesFromCommand(result)) } +async function writeIdentityWithCdp( + cookieDebugger: CookieClearDebugger, + identity: CookieClearIdentity, + failureLabel: string +): Promise { + 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 { for (const identity of identities) { - const result = await cookieDebugger.sendCommand( - 'Network.setCookie', - cdpRestoreParamsFromIdentity(identity) - ) - if (!cdpSetCookieSucceeded(result)) { - throw new Error(`Could not restore cookie ${identity.name}`) - } + await writeIdentityWithCdp(cookieDebugger, identity, 'restore') } } export function openCookieClearStore( targetSession: Session -): CookieClearStore & { dispose: () => void } { +): CookieClearStore & CookieImportWriteStore & { dispose: () => void } { let attached: CookieClearSession | null = null let pendingAttach: Promise | null = null let disposed = false @@ -287,6 +312,8 @@ 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 diff --git a/src/main/browser/browser-cookie-import-clear.ts b/src/main/browser/browser-cookie-import-clear.ts index 65598504174..a6265581bce 100644 --- a/src/main/browser/browser-cookie-import-clear.ts +++ b/src/main/browser/browser-cookie-import-clear.ts @@ -35,6 +35,13 @@ export type CookieClearStore = Pick & { restoreClearIdentities(identities: readonly CookieClearIdentity[]): Promise } +// 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 & { + writeCookieIdentity(identity: CookieClearIdentity): Promise +} + // Why (STA-4061): 'set' stays out so the lossy partition-dropping reconstruction cannot return. export type CookieClearSession = { cookies: Pick diff --git a/src/main/browser/browser-cookie-import-google-exclusion.test.ts b/src/main/browser/browser-cookie-import-google-exclusion.test.ts index a213d42f16a..48af8c4329b 100644 --- a/src/main/browser/browser-cookie-import-google-exclusion.test.ts +++ b/src/main/browser/browser-cookie-import-google-exclusion.test.ts @@ -40,6 +40,10 @@ 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) => + targetSession.cookies.set!(identity), snapshotClearIdentities: async ( items: { cookie: { @@ -217,7 +221,8 @@ describe('native Chromium import excludes the Google cookie family', () => { remove: cookiesRemoveMock, set: cookiesSetMock }, - clearData: clearDataMock + clearData: clearDataMock, + getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test') }) platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') }) diff --git a/src/main/browser/browser-cookie-import-partition-fidelity.test.ts b/src/main/browser/browser-cookie-import-partition-fidelity.test.ts new file mode 100644 index 00000000000..c843baab711 --- /dev/null +++ b/src/main/browser/browser-cookie-import-partition-fidelity.test.ts @@ -0,0 +1,436 @@ +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() + return { + ...actual, + copyFileSync: (...args: Parameters) => { + 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 + remove: (url: string, name: string) => Promise + } + }) => ({ + get: (filter: object) => targetSession.cookies.get(filter), + remove: (url: string, name: string) => targetSession.cookies.remove(url, name), + snapshotClearIdentities: async (items: { cookie: Record; 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 + + 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 + let platformSpy: ReturnType + + 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') + }) +}) diff --git a/src/main/browser/browser-cookie-import-partition-success.electron.test.ts b/src/main/browser/browser-cookie-import-partition-success.electron.test.ts new file mode 100644 index 00000000000..d1c84820e37 --- /dev/null +++ b/src/main/browser/browser-cookie-import-partition-success.electron.test.ts @@ -0,0 +1,252 @@ +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 | 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,cookie partition success fixture') + 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) +}) diff --git a/src/main/browser/browser-cookie-import-replacement.test.ts b/src/main/browser/browser-cookie-import-replacement.test.ts index d0e79ec1c7f..ccfd2e3db16 100644 --- a/src/main/browser/browser-cookie-import-replacement.test.ts +++ b/src/main/browser/browser-cookie-import-replacement.test.ts @@ -38,10 +38,15 @@ vi.mock('./browser-cookie-clear-store', () => ({ cookies: { get: (filter: object) => Promise remove: (url: string, name: string) => Promise + set?: (details: Record) => Promise } }) => ({ 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) => + targetSession.cookies.set!(identity), snapshotClearIdentities: snapshotClearIdentitiesMock, restoreClearIdentities: restoreClearIdentitiesMock, dispose: disposeClearStoreMock @@ -248,7 +253,8 @@ describe('native Chromium integrity-cookie accounting', () => { remove: vi.fn().mockResolvedValue(undefined), set: cookiesSetMock }, - clearData: clearDataMock + clearData: clearDataMock, + getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test') }) }) diff --git a/src/main/browser/browser-cookie-import-test-database.ts b/src/main/browser/browser-cookie-import-test-database.ts index 1f38f0adc36..31cdfc1f157 100644 --- a/src/main/browser/browser-cookie-import-test-database.ts +++ b/src/main/browser/browser-cookie-import-test-database.ts @@ -8,6 +8,12 @@ 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( @@ -57,16 +63,21 @@ export function createChromiumCookieTestDatabase( source_port, last_update_utc, has_cross_site_ancestor - ) VALUES (?, ?, '', ?, ?, ?, '/', 0, 0, 0, 0, 0, -1, ?, 0) + ) VALUES (?, ?, ?, ?, ?, ?, '/', 0, ?, ?, ?, 0, -1, ?, ?) `) 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), - 0 + row.isSecure ?? 0, + row.isHttpOnly ?? 0, + row.sameSite ?? 0, + 0, + row.hasCrossSiteAncestor ?? 0 ) }) return database diff --git a/src/main/browser/browser-cookie-import-validated-partition.electron.test.ts b/src/main/browser/browser-cookie-import-validated-partition.electron.test.ts new file mode 100644 index 00000000000..bd85f0b9a9b --- /dev/null +++ b/src/main/browser/browser-cookie-import-validated-partition.electron.test.ts @@ -0,0 +1,231 @@ +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 | 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,validated partition fixture') + 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 { + 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) +}) diff --git a/src/main/browser/browser-cookie-import-write.test.ts b/src/main/browser/browser-cookie-import-write.test.ts new file mode 100644 index 00000000000..d505b436317 --- /dev/null +++ b/src/main/browser/browser-cookie-import-write.test.ts @@ -0,0 +1,199 @@ +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 & { 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>( + 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' }]) + }) +}) diff --git a/src/main/browser/browser-cookie-import-write.ts b/src/main/browser/browser-cookie-import-write.ts new file mode 100644 index 00000000000..6c1505b6153 --- /dev/null +++ b/src/main/browser/browser-cookie-import-write.ts @@ -0,0 +1,163 @@ +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 + failure: unknown +} + +export function emptyImportWritePhase(): ImportWritePhase { + return { + importedKeys: [], + importedCount: 0, + writeRejected: 0, + partitionSkipped: 0, + domains: new Set(), + 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, + cookies: readonly SourceCookieToWrite[], + options: { stopOnFailure: boolean; log: (message: string) => void } +): Promise { + 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 +} diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index 6ec09edf8aa..f36efb3fc4f 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -8,7 +8,8 @@ const { sessionFromPartitionMock, dialogShowOpenDialogMock, setPendingCookieImportMock, - clearPendingCookieImportMock + clearPendingCookieImportMock, + writeCookieIdentityMock } = vi.hoisted(() => ({ appGetPathMock: vi.fn(), copyFileSyncMock: vi.fn(), @@ -16,7 +17,8 @@ const { sessionFromPartitionMock: vi.fn(), dialogShowOpenDialogMock: vi.fn(), setPendingCookieImportMock: vi.fn(), - clearPendingCookieImportMock: vi.fn() + clearPendingCookieImportMock: vi.fn(), + writeCookieIdentityMock: vi.fn() })) vi.mock('./browser-session-registry', () => ({ @@ -56,6 +58,10 @@ vi.mock('./browser-cookie-clear-store', () => ({ snapshotClearIdentities: async (items: { cookie: Record; 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 }) })) @@ -97,6 +103,15 @@ 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', () => { @@ -176,19 +191,21 @@ describe('importCookiesFromFile', () => { let tmpDir: string let cookiesGetMock: ReturnType let cookiesRemoveMock: ReturnType - let cookiesSetMock: ReturnType + let cookieWriteMock: ReturnType beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-test-')) cookiesGetMock = vi.fn().mockResolvedValue([]) cookiesRemoveMock = vi.fn().mockResolvedValue(undefined) - cookiesSetMock = vi.fn().mockResolvedValue(undefined) + cookieWriteMock = writeCookieIdentityMock + cookieWriteMock.mockReset() + cookieWriteMock.mockResolvedValue(undefined) sessionFromPartitionMock.mockReset() sessionFromPartitionMock.mockReturnValue({ cookies: { get: cookiesGetMock, remove: cookiesRemoveMock, - set: cookiesSetMock + set: unreachableCookieSet } }) }) @@ -237,8 +254,8 @@ describe('importCookiesFromFile', () => { expect(result.summary.domains).toContain('github.com') expect(result.summary.domains).toContain('example.com') - expect(cookiesSetMock).toHaveBeenCalledTimes(2) - const firstCall = cookiesSetMock.mock.calls[0][0] + expect(cookieWriteMock).toHaveBeenCalledTimes(2) + const firstCall = cookieWriteMock.mock.calls[0][0] expect(firstCall.name).toBe('_gh_sess') expect(firstCall.domain).toBe('.github.com') expect(firstCall.secure).toBe(true) @@ -262,14 +279,18 @@ describe('importCookiesFromFile', () => { const result = await importCookiesFromFile(filePath, 'persist:test') expect(result.ok).toBe(true) - const hostCall = cookiesSetMock.mock.calls + const hostCall = cookieWriteMock.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. - expect(hostCall).not.toHaveProperty('domain') + // __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) expect(hostCall.path).toBe('/') - const normalCall = cookiesSetMock.mock.calls.map((c) => c[0]).find((c) => c.name === '_gh_sess') + const normalCall = cookieWriteMock.mock.calls + .map((c) => c[0]) + .find((c) => c.name === '_gh_sess') + expect(normalCall.hostOnly).toBe(false) expect(normalCall.domain).toBe('.github.com') expect(normalCall.path).toBe('/settings') }) @@ -363,11 +384,11 @@ describe('importCookiesFromFile', () => { await importCookiesFromFile(filePath, 'persist:test') - 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') + 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') }) it('derives correct URL from domain and secure flag', async () => { @@ -379,13 +400,13 @@ describe('importCookiesFromFile', () => { await importCookiesFromFile(filePath, 'persist:test') - 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/') + 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/') }) it('rolls back replacement when a cookie fails to set', async () => { - cookiesSetMock.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('set failed')) + cookieWriteMock.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('set failed')) const filePath = writeCookieFile([ { domain: '.a.com', name: 'ok', value: '1' }, @@ -400,14 +421,16 @@ describe('importCookiesFromFile', () => { describe('importCookiesFromBrowser Safari', () => { let tmpDir: string - let cookiesSetMock: ReturnType + let cookieWriteMock: ReturnType beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'orca-safari-cookie-test-')) - cookiesSetMock = vi.fn().mockResolvedValue(undefined) + cookieWriteMock = writeCookieIdentityMock + cookieWriteMock.mockReset() + cookieWriteMock.mockResolvedValue(undefined) sessionFromPartitionMock.mockReset() sessionFromPartitionMock.mockReturnValue({ - cookies: { set: cookiesSetMock } + cookies: { set: unreachableCookieSet } }) }) @@ -429,13 +452,13 @@ describe('importCookiesFromBrowser Safari', () => { const result = await importCookiesFromBrowser(browser, 'persist:test') expect(result).toEqual({ ok: false, reason: 'All Safari cookies are expired.' }) - expect(cookiesSetMock).not.toHaveBeenCalled() + expect(cookieWriteMock).not.toHaveBeenCalled() }) }) describe('importCookiesFromBrowser Chromium', () => { let tmpDir: string - let cookiesSetMock: ReturnType + let cookieWriteMock: ReturnType let cookiesRemoveMock: ReturnType let cookiesFlushStoreMock: ReturnType let clearDataMock: ReturnType @@ -443,7 +466,9 @@ describe('importCookiesFromBrowser Chromium', () => { beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'orca-chromium-cookie-test-')) - cookiesSetMock = vi.fn().mockResolvedValue(undefined) + cookieWriteMock = writeCookieIdentityMock + cookieWriteMock.mockReset() + cookieWriteMock.mockResolvedValue(undefined) cookiesRemoveMock = vi.fn().mockResolvedValue(undefined) cookiesFlushStoreMock = vi.fn().mockResolvedValue(undefined) clearDataMock = vi.fn().mockResolvedValue(undefined) @@ -461,19 +486,19 @@ describe('importCookiesFromBrowser Chromium', () => { sessionFromPartitionMock.mockReturnValue({ cookies: { get: vi.fn().mockResolvedValue([]), - set: cookiesSetMock, + set: unreachableCookieSet, remove: cookiesRemoveMock, flushStore: cookiesFlushStoreMock }, clearData: clearDataMock, - setUserAgent: setUserAgentMock + setUserAgent: setUserAgentMock, + getStoragePath: () => join(tmpDir, 'userData', 'Partitions', 'test') }) }) 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') @@ -507,7 +532,7 @@ describe('importCookiesFromBrowser Chromium', () => { ) expect(result.ok).toBe(true) - expect(cookiesSetMock).toHaveBeenCalledWith( + expect(cookieWriteMock).toHaveBeenCalledWith( expect.objectContaining({ domain: '.example.com', name: 'sid', @@ -568,7 +593,7 @@ describe('importCookiesFromBrowser Chromium', () => { expect.any(Array), expect.any(Object) ) - expect(cookiesSetMock).toHaveBeenCalledWith( + expect(cookieWriteMock).toHaveBeenCalledWith( expect.objectContaining({ name: 'sid', value: 'encrypted-value' }) ) } finally { @@ -623,7 +648,7 @@ describe('importCookiesFromBrowser Chromium', () => { ) expect(result.ok).toBe(true) - expect(cookiesSetMock).toHaveBeenCalledWith( + expect(cookieWriteMock).toHaveBeenCalledWith( expect.objectContaining({ name: 'sid', value: 'source-value' }) ) // The partial staging file is still discarded, so no stale DB replays on cold start. @@ -653,7 +678,7 @@ describe('importCookiesFromBrowser Chromium', () => { ) expect(result.ok).toBe(true) - expect(cookiesSetMock).toHaveBeenCalledWith( + expect(cookieWriteMock).toHaveBeenCalledWith( expect.objectContaining({ name: 'sid', value: 'source-value' }) ) // The summary counts importable cookies, not staged rows. @@ -675,7 +700,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. - cookiesSetMock.mockRejectedValue(new Error('cookie rejected')) + cookieWriteMock.mockRejectedValue(new Error('cookie rejected')) const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') try { @@ -717,7 +742,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. - cookiesSetMock.mockRejectedValue(new Error('cookie rejected')) + cookieWriteMock.mockRejectedValue(new Error('cookie rejected')) const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') try { @@ -727,7 +752,7 @@ describe('importCookiesFromBrowser Chromium', () => { ) expect(result.ok).toBe(true) - expect(cookiesSetMock).toHaveBeenCalledWith( + expect(cookieWriteMock).toHaveBeenCalledWith( expect.objectContaining({ name: 'sid', value: 'source-value' }) ) expect(setPendingCookieImportMock).not.toHaveBeenCalled() @@ -773,7 +798,7 @@ describe('importCookiesFromBrowser Chromium', () => { { name: 'sid', value: 'source-value' } ]).close() createChromiumCookieTestDatabase(targetCookiesPath, []).close() - cookiesSetMock.mockRejectedValue(new Error('cookie rejected')) + cookieWriteMock.mockRejectedValue(new Error('cookie rejected')) const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') try { diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 2e2f50dbdd1..e525952feb3 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -84,8 +84,26 @@ import { type CookieImportMode, type ReplacedImportedDomainCookies } from './browser-cookie-import-policy' -import { removeTransplantableCookies, withCookieClearLock } from './browser-cookie-import-clear' +import { + removeTransplantableCookies, + withCookieClearLock, + type CookieClearStore, + type CookieImportWriteStore +} 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 @@ -448,18 +466,14 @@ type RawCookieEntry = { httpOnly?: unknown sameSite?: unknown expirationDate?: unknown + partitionKey?: unknown } -type ValidatedCookie = { - url: string - name: string - value: string - domain: string - path: string - secure: boolean - httpOnly: boolean +// 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 & { sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' - expirationDate: number | undefined + partition: SourcePartitionRead } // Why: Chromium's CookieSameSiteForStorage enum (0=Unspecified,1=None,2=Lax,3=Strict) differs from Firefox's numbering. @@ -509,7 +523,7 @@ function normalizeSameSite(raw: unknown): 'unspecified' | 'no_restriction' | 'la return 'unspecified' } -// Why: cookies.set() needs a url to scope the cookie; derive it from domain + secure flag. +// Why: a cookie identity needs a url to scope it; derive it from domain + secure flag. function deriveUrl(domain: string, secure: boolean): string | null { const normalizedDomain = normalizeCookieDomain(domain) if (!normalizedDomain) { @@ -556,16 +570,40 @@ function validateCookieEntry(raw: RawCookieEntry): ValidatedCookie | null { secure, httpOnly: raw.httpOnly === true || raw.httpOnly === 1, sameSite: normalizeSameSite(raw.sameSite), - expirationDate + 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) } } async function importValidatedCookies( cookies: ValidatedCookie[], totalInput: number, - targetPartition: string, - mode: CookieImportMode + target: CookieImportTarget, + mode: CookieImportMode, + options: CookieImportOptions = {} ): Promise { + const targetPartition = target.partition const importDomainCache = new Map() const validDomainCookies = cookies.filter((cookie) => { let valid = importDomainCache.get(cookie.domain) @@ -590,128 +628,94 @@ 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}"` ) - const targetSession = session.fromPartition(targetPartition) - let importedCount = 0 + // 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.' + } + } let skipped = totalInput - importableCookies.length - const domainSet = new Set() - 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 + 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 - 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)}`) + 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)`) + } return { ok: false, - reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.') + reason: reasonWithDiagLog('Could not safely replace 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=${importedCount} skipped=${skipped} domains=${domainSet.size}` + `importValidatedCookies result: imported=${phase.importedCount} skipped=${skipped} partition-unreadable=${phase.partitionSkipped} domains=${phase.domains.size}` ) const summary: BrowserCookieImportSummary = { totalCookies: totalInput, - importedCookies: importedCount, + importedCookies: phase.importedCount, skippedCookies: skipped, ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), - domains: [...domainSet].sort() + ...(phase.partitionSkipped > 0 ? { partitionSkippedCookies: phase.partitionSkipped } : {}), + domains: [...phase.domains].sort() } return { ok: true, profileId: '', summary } @@ -792,7 +796,7 @@ export async function importCookiesFromFile( return importValidatedCookies( validated, parsed.length, - targetPartition, + cookieImportTarget(targetPartition), 'replace-imported-domains' ) } @@ -1264,7 +1268,10 @@ function decodeSafariCookie(buf: Buffer): ValidatedCookie | null { secure, httpOnly, sameSite: 'unspecified', - expirationDate + 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' } } } @@ -1288,7 +1295,8 @@ function readCString(buf: Buffer, offset: number, end: number): string | null { async function importCookiesFromFirefox( browser: DetectedBrowser, - targetPartition: string + targetPartition: string, + options: CookieImportOptions ): Promise { diag(`importCookiesFromFirefox: partition="${targetPartition}"`) @@ -1317,7 +1325,7 @@ async function importCookiesFromFirefox( try { const db = new DatabaseSync(tmpCookiesPath, { readOnly: true }) - type FirefoxRow = { + type FirefoxRow = Record & { name: string value: string host: string @@ -1326,10 +1334,21 @@ 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 FROM moz_cookies' + `SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite${partitionColumn} FROM moz_cookies` ) .all() as FirefoxRow[] db.close() @@ -1366,7 +1385,8 @@ async function importCookiesFromFirefox( secure, httpOnly: row.isHttpOnly === 1, sameSite: firefoxSameSite(row.sameSite), - expirationDate: row.expiry > 0 ? row.expiry : undefined + expirationDate: row.expiry > 0 ? row.expiry : undefined, + partition: readFirefoxRowPartition(row, firefoxColumns) }) } @@ -1379,8 +1399,9 @@ async function importCookiesFromFirefox( return importValidatedCookies( validated, rows.length, - targetPartition, - 'replace-imported-domains' + cookieImportTarget(targetPartition), + 'replace-imported-domains', + options ) } catch (err) { rmSync(tmpDir, { recursive: true, force: true }) @@ -1438,7 +1459,7 @@ async function importCookiesFromSafari( return importValidatedCookies( valid, cookies.length, - targetPartition, + cookieImportTarget(targetPartition), 'replace-imported-domains' ) } catch (err) { @@ -1453,7 +1474,8 @@ async function importCookiesFromSafari( export async function importCookiesFromBrowser( browser: DetectedBrowser, - targetPartition: string + targetPartition: string, + options: CookieImportOptions = {} ): Promise { diag(`importCookiesFromBrowser: browser=${browser.family} partition="${targetPartition}"`) if (!existsSync(browser.cookiesPath)) { @@ -1462,7 +1484,7 @@ export async function importCookiesFromBrowser( } if (browser.family === 'firefox') { - return importCookiesFromFirefox(browser, targetPartition) + return importCookiesFromFirefox(browser, targetPartition, options) } if (browser.family === 'safari') { return importCookiesFromSafari(browser, targetPartition) @@ -1474,8 +1496,15 @@ 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. @@ -1591,6 +1620,13 @@ 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 @@ -1632,20 +1668,15 @@ 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() - type DecryptedCookie = { + type DecryptedCookie = Omit & { decryptedValue: Buffer - value: string - domain: string - name: string - path: string - secure: boolean - httpOnly: boolean sameSite: 'unspecified' | 'no_restriction' | 'lax' | 'strict' - expirationDate: number | undefined + partition: SourcePartitionRead } const decryptedCookies: DecryptedCookie[] = [] @@ -1721,14 +1752,12 @@ 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') @@ -1741,9 +1770,18 @@ export async function importCookiesFromBrowser( secure, httpOnly, sameSite, - expirationDate: expiresUtc > 0 ? expiresUtc : undefined + expirationDate: expiresUtc > 0 ? expiresUtc : undefined, + partition }) + 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( @@ -1765,7 +1803,18 @@ export async function importCookiesFromBrowser( ) const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped - if (decryptedCookies.length === 0) { + // 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) { closeStagingDb() discardStagingFile() return { @@ -1776,6 +1825,7 @@ export async function importCookiesFromBrowser( importedCookies: 0, skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), + ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), domains: [] } } @@ -1796,6 +1846,8 @@ 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, () => @@ -1807,42 +1859,35 @@ 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( - ` cleared existing session cookies before loading ${decryptedCookies.length} imported cookies` + ` memory load: ${memoryLoaded} OK, ${memoryFailed} failed, ${partitionSkipped} partition-unreadable` ) - // 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. @@ -1879,6 +1924,7 @@ export async function importCookiesFromBrowser( importedCookies: imported, skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), + ...(partitionSkipped > 0 ? { partitionSkippedCookies: partitionSkipped } : {}), domains: [...domainSet].sort(), ...(warning ? { warning } : {}) } diff --git a/src/main/browser/browser-cookie-source-partition.test.ts b/src/main/browser/browser-cookie-source-partition.test.ts new file mode 100644 index 00000000000..b179215872a --- /dev/null +++ b/src/main/browser/browser-cookie-source-partition.test.ts @@ -0,0 +1,177 @@ +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') + }) +}) diff --git a/src/main/browser/browser-cookie-source-partition.ts b/src/main/browser/browser-cookie-source-partition.ts new file mode 100644 index 00000000000..69911707524 --- /dev/null +++ b/src/main/browser/browser-cookie-source-partition.ts @@ -0,0 +1,125 @@ +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, + sourceColumns: ReadonlySet +): 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, + sourceColumns: ReadonlySet +): 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 + 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 } } +} diff --git a/src/main/crash-reporting/crashpad-capture.test.ts b/src/main/crash-reporting/crashpad-capture.test.ts index a32d41a6bd0..265823185b9 100644 --- a/src/main/crash-reporting/crashpad-capture.test.ts +++ b/src/main/crash-reporting/crashpad-capture.test.ts @@ -259,9 +259,6 @@ 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']) }) }) diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts index dac2ef92efd..33f1b5dddc5 100644 --- a/src/main/runtime/orca-runtime-browser.ts +++ b/src/main/runtime/orca-runtime-browser.ts @@ -1550,6 +1550,7 @@ export class RuntimeBrowserCommands { profileId: string browserFamily: string browserProfile?: string + supportsPartitionSkippedCookies?: true }): Promise { const profile = browserSessionRegistry.getProfile(params.profileId) if (!profile) { @@ -1579,7 +1580,9 @@ export class RuntimeBrowserCommands { browser = reselected } - const result = await importCookiesFromBrowser(browser, profile.partition) + const result = await importCookiesFromBrowser(browser, profile.partition, { + canReportPartitionSkippedCookies: params.supportsPartitionSkippedCookies === true + }) if (!result.ok) { return result } diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index 47027451aeb..03872b61cc0 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -148,7 +148,8 @@ 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 + browserProfile: OptionalString, + supportsPartitionSkippedCookies: z.literal(true).optional() }) export const Drag = BrowserTarget.extend({ diff --git a/src/main/runtime/rpc/methods/browser.test.ts b/src/main/runtime/rpc/methods/browser.test.ts index 57095000146..638483f843d 100644 --- a/src/main/runtime/rpc/methods/browser.test.ts +++ b/src/main/runtime/rpc/methods/browser.test.ts @@ -68,7 +68,8 @@ describe('browser RPC methods', () => { makeRequest('browser.profileImportFromBrowser', { profileId: 'profile-1', browserFamily: 'chrome', - browserProfile: 'Default' + browserProfile: 'Default', + supportsPartitionSkippedCookies: true }) ) @@ -92,7 +93,8 @@ describe('browser RPC methods', () => { expect(runtime.browserProfileImportFromBrowser).toHaveBeenCalledWith({ profileId: 'profile-1', browserFamily: 'chrome', - browserProfile: 'Default' + browserProfile: 'Default', + supportsPartitionSkippedCookies: true }) }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 046bbb9df45..67c46a2b6a1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -783,7 +783,8 @@ "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." + "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." } } } diff --git a/src/renderer/src/lib/browser-cookie-import-toast.test.ts b/src/renderer/src/lib/browser-cookie-import-toast.test.ts index 3479887b419..b6f0fe11d6f 100644 --- a/src/renderer/src/lib/browser-cookie-import-toast.test.ts +++ b/src/renderer/src/lib/browser-cookie-import-toast.test.ts @@ -69,6 +69,32 @@ 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 }, diff --git a/src/renderer/src/lib/browser-cookie-import-toast.ts b/src/renderer/src/lib/browser-cookie-import-toast.ts index 9e18e3c96c8..2c8cc5f3033 100644 --- a/src/renderer/src/lib/browser-cookie-import-toast.ts +++ b/src/renderer/src/lib/browser-cookie-import-toast.ts @@ -41,6 +41,22 @@ 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( @@ -55,4 +71,5 @@ export function emitBrowserCookieImportToast( toast.success(successMessage) } emitGoogleCookieImportWarning(summary, executionHostLabel) + emitPartitionSkippedImportWarning(summary) } diff --git a/src/renderer/src/store/slices/browser-session-host-selection.test.ts b/src/renderer/src/store/slices/browser-session-host-selection.test.ts index 71017053991..420a66fbf3a 100644 --- a/src/renderer/src/store/slices/browser-session-host-selection.test.ts +++ b/src/renderer/src/store/slices/browser-session-host-selection.test.ts @@ -169,6 +169,17 @@ 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?.({ diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index fe7c3a694a2..0017d42c7c8 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -2201,7 +2201,7 @@ export const createBrowserSlice: StateCreator = const result = await callRuntimeRpc( { kind: 'environment', environmentId: runtimeEnvironmentId }, 'browser.profileImportFromBrowser', - { profileId, browserFamily, browserProfile }, + { profileId, browserFamily, browserProfile, supportsPartitionSkippedCookies: true }, { timeoutMs: 30_000 } ) if (result.ok) { diff --git a/src/shared/browser-workspace-types.ts b/src/shared/browser-workspace-types.ts index 67619decd58..652bc58c65c 100644 --- a/src/shared/browser-workspace-types.ts +++ b/src/shared/browser-workspace-types.ts @@ -142,6 +142,9 @@ 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'