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