mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-03 18:06:54 +02:00
* fix: support Windows Opera and Opera GX cookie imports Fixes #2957 * fix: repair Windows cookie decryption and make Opera import failures actionable - strip the SHA-256(host_key) prefix Chromium adds to v10 values (DB meta v24+) on Windows - keep receipts and name `$B handoff` recovery for App-Bound rows in browsers without native extraction - explain missing browsers, missing profiles and ambiguous profile selection with next steps - add windowsNative/resolveBrowserInfo, the operagx alias and sorted failure reasons in CLI output - cover the Node server runtime with a real-DPAPI Windows test * docs: document Windows Opera cookie import and guard browser lists against drift * chore: file cookie-import follow-ups from the Opera fix wave review * test: keep Opera receipt tests independent of the shared key cache * test: declare generated gstack/llms.txt as a command-reference input for PR selection * chore: release v1.91.4.0 * test: reconstruct the historical cookie-workflow approval after the Opera BROWSER.md additions * test: run the real-DPAPI Windows check with the runner's environment PowerShell launched with a stripped environment took ~18-21s on the Windows runner (measured on a throwaway diagnostics run), past dpapiDecrypt's 10s deadline; with the full environment it returns in ~0.3s. Only APPDATA is redirected to the fixture's Opera root. --------- Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
This commit is contained in:
1 parent
01593aa67c
commit
d2a0bbcf4c
21 files changed
+1022
-69
No files matched your search
@@ -93,7 +93,7 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
|
||||
| `click <sel>` | Click element |
|
||||
| `cookie <name>=<value>` | Set cookie on current page domain |
|
||||
| `cookie-import <json>` | Import cookies from JSON file |
|
||||
| `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]` | Copy cookies from chrome, chromium, brave, edge, or macOS-only comet, arc, dia. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification. |
|
||||
| `cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]` | Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet, arc, dia. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification. |
|
||||
| `dialog-accept [text]` | Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response |
|
||||
| `dialog-dismiss` | Auto-dismiss next dialog |
|
||||
| `fill <sel> <val>` | Fill input |
|
||||
|
||||
@@ -130,7 +130,7 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
|
||||
'viewport':{ category: 'Interaction', description: 'Set viewport size and optional deviceScaleFactor (1-3, for retina screenshots). --scale requires a context rebuild.', usage: 'viewport [<WxH>] [--scale <n>]' },
|
||||
'cookie': { category: 'Interaction', description: 'Set cookie on current page domain', usage: 'cookie <name>=<value>' },
|
||||
'cookie-import': { category: 'Interaction', description: 'Import cookies from JSON file', usage: 'cookie-import <json>' },
|
||||
'cookie-import-browser': { category: 'Interaction', description: 'Copy cookies from chrome, chromium, brave, edge, or macOS-only comet, arc, dia. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification.', usage: 'cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]' },
|
||||
'cookie-import-browser': { category: 'Interaction', description: 'Copy cookies from chrome, chromium, brave, edge, Windows-only opera, opera-gx, or macOS-only comet, arc, dia. Omitted browser retains legacy comet; select the intended browser explicitly. --domain requires a matching current page; no scope flag opens the picker. --profile is the source directory; ambiguous profiles require selection. --all explicitly selects every non-expired cookie and cannot accompany --domain or --clear-storage. Storage is preserved unless --clear-storage resets captured-origin localStorage (shared across context tabs) and target-tab sessionStorage. --verify-auth requires daemon GSTACK_COOKIE_AUTH_SELECTOR and GSTACK_COOKIE_AUTH_EXPECTED_IDENTITY before startup; missing config rejects before mutation. Verified means an exact visible identity match, not cookie counts or HTTP 200. Windows native extraction remains disabled pending qualification.', usage: 'cookie-import-browser [browser] [--domain d] [--profile p] [--all] [--clear-storage] [--verify-auth]' },
|
||||
'header': { category: 'Interaction', description: 'Set custom request header (colon-separated, sensitive values auto-redacted)', usage: 'header <name>:<value>' },
|
||||
'useragent': { category: 'Interaction', description: 'Set user agent', usage: 'useragent <string>' },
|
||||
'dialog-accept': { category: 'Interaction', description: 'Auto-accept next alert/confirm/prompt. Optional text is sent as the prompt response', usage: 'dialog-accept [text]' },
|
||||
|
||||
@@ -10,11 +10,15 @@
|
||||
* │ 1. Resolve the cookie DB from the browser profile dir │
|
||||
* │ - macOS: ~/Library/Application Support/<browser>/<profile> │
|
||||
* │ - Linux: ~/.config/<browser>/<profile> │
|
||||
* │ - Windows: %LOCALAPPDATA% or %APPDATA% (Opera) /<browser>/ │
|
||||
* │ <profile>/Network/Cookies, falling back to <profile>/Cookies│
|
||||
* │ │
|
||||
* │ 2. Derive the AES key │
|
||||
* │ - macOS v10: Keychain password, PBKDF2(..., iter=1003) │
|
||||
* │ - Linux v10: "peanuts", PBKDF2(..., iter=1) │
|
||||
* │ - Linux v11: libsecret/secret-tool password, iter=1 │
|
||||
* │ - Windows v10: DPAPI-unprotect Local State os_crypt key │
|
||||
* │ - Windows v20 (App-Bound): not decryptable here │
|
||||
* │ │
|
||||
* │ 3. For each cookie with encrypted_value starting with "v10"/ │
|
||||
* │ "v11": │
|
||||
@@ -24,6 +28,8 @@
|
||||
* │ - Remove PKCS7 padding │
|
||||
* │ - Skip first 32 bytes of Chromium cookie metadata │
|
||||
* │ - Remaining bytes = cookie value (UTF-8) │
|
||||
* │ Windows v10: AES-256-GCM(nonce=ev[3:15], tag=last 16 bytes); │
|
||||
* │ drop a leading SHA-256(host_key) when present (DB meta v24+) │
|
||||
* │ │
|
||||
* │ 4. If encrypted_value is empty but `value` field is set, │
|
||||
* │ use value directly (unencrypted cookie) │
|
||||
@@ -46,12 +52,14 @@ import { isIP } from 'node:net';
|
||||
|
||||
export interface BrowserInfo {
|
||||
name: string;
|
||||
dataDir: string; // primary storage dir (retained for compatibility with existing callers/tests)
|
||||
dataDir: string | null; // macOS directory; null when that platform has no supported location
|
||||
keychainService: string;
|
||||
aliases: string[];
|
||||
linuxDataDir?: string;
|
||||
linuxApplication?: string;
|
||||
windowsDataDir?: string;
|
||||
windowsDataRoot?: 'local' | 'roaming';
|
||||
windowsNative?: true;
|
||||
}
|
||||
|
||||
export interface ProfileEntry {
|
||||
@@ -107,12 +115,15 @@ interface BrowserMatch {
|
||||
|
||||
const BROWSER_REGISTRY: BrowserInfo[] = [
|
||||
{ name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] },
|
||||
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome', windowsDataDir: 'Google/Chrome/User Data/' },
|
||||
{ name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium', windowsDataDir: 'Chromium/User Data/' },
|
||||
{ name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome', windowsDataDir: 'Google/Chrome/User Data/', windowsNative: true },
|
||||
{ name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium', windowsDataDir: 'Chromium/User Data/', windowsNative: true },
|
||||
{ name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
|
||||
{ name: 'Dia', dataDir: 'Dia/User Data/', keychainService: 'Dia Safe Storage', aliases: ['dia'] },
|
||||
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave', windowsDataDir: 'BraveSoftware/Brave-Browser/User Data/' },
|
||||
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge', windowsDataDir: 'Microsoft/Edge/User Data/' },
|
||||
{ name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave', windowsDataDir: 'BraveSoftware/Brave-Browser/User Data/', windowsNative: true },
|
||||
{ name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge', windowsDataDir: 'Microsoft/Edge/User Data/', windowsNative: true },
|
||||
// Windows-only. Local State sits directly under the browser root in %APPDATA% — no User Data segment.
|
||||
{ name: 'Opera', dataDir: null, keychainService: 'Opera Safe Storage', aliases: ['opera'], windowsDataDir: 'Opera Software/Opera Stable/', windowsDataRoot: 'roaming' },
|
||||
{ name: 'Opera GX', dataDir: null, keychainService: 'Opera GX Safe Storage', aliases: ['opera-gx', 'operagx'], windowsDataDir: 'Opera Software/Opera GX Stable/', windowsDataRoot: 'roaming' },
|
||||
];
|
||||
|
||||
// ─── Key Cache ──────────────────────────────────────────────────
|
||||
@@ -134,14 +145,12 @@ export function findInstalledBrowsers(): BrowserInfo[] {
|
||||
for (const platform of getSearchPlatforms()) {
|
||||
const dataDir = getDataDirForPlatform(browser, platform);
|
||||
if (!dataDir) continue;
|
||||
const browserDir = path.join(getBaseDir(platform), dataDir);
|
||||
const browserDir = path.join(getBaseDir(platform, browser), dataDir);
|
||||
try {
|
||||
const entries = fs.readdirSync(browserDir, { withFileTypes: true });
|
||||
if (entries.some(e => {
|
||||
if (!e.isDirectory() || !e.name.startsWith('Profile ')) return false;
|
||||
const profileDir = path.join(browserDir, e.name);
|
||||
return fs.existsSync(path.join(profileDir, 'Cookies'))
|
||||
|| (platform === 'win32' && fs.existsSync(path.join(profileDir, 'Network', 'Cookies')));
|
||||
return profileCookieCandidates(platform, path.join(browserDir, e.name)).some(candidate => fs.existsSync(candidate));
|
||||
})) return true;
|
||||
} catch {}
|
||||
}
|
||||
@@ -160,14 +169,14 @@ export function listSupportedBrowserNames(): string[] {
|
||||
* List available profiles for a browser.
|
||||
*/
|
||||
export function listProfiles(browserName: string): ProfileEntry[] {
|
||||
const browser = resolveBrowser(browserName);
|
||||
const browser = resolveBrowserInfo(browserName);
|
||||
const profiles: ProfileEntry[] = [];
|
||||
|
||||
// Scan each supported platform for profile directories
|
||||
for (const platform of getSearchPlatforms()) {
|
||||
const dataDir = getDataDirForPlatform(browser, platform);
|
||||
if (!dataDir) continue;
|
||||
const browserDir = path.join(getBaseDir(platform), dataDir);
|
||||
const browserDir = path.join(getBaseDir(platform, browser), dataDir);
|
||||
if (!fs.existsSync(browserDir)) continue;
|
||||
|
||||
let profileNames: Record<string, { name?: unknown }> = {};
|
||||
@@ -185,11 +194,7 @@ export function listProfiles(browserName: string): ProfileEntry[] {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name !== 'Default' && !entry.name.startsWith('Profile ')) continue;
|
||||
// Chrome 80+ on Windows stores cookies under Network/Cookies
|
||||
const cookieCandidates = platform === 'win32'
|
||||
? [path.join(browserDir, entry.name, 'Network', 'Cookies'), path.join(browserDir, entry.name, 'Cookies')]
|
||||
: [path.join(browserDir, entry.name, 'Cookies')];
|
||||
if (!cookieCandidates.some(p => fs.existsSync(p))) continue;
|
||||
if (!profileCookieCandidates(platform, path.join(browserDir, entry.name)).some(p => fs.existsSync(p))) continue;
|
||||
|
||||
// Avoid duplicates if the same profile appears on multiple platforms
|
||||
if (profiles.some(p => p.name === entry.name)) continue;
|
||||
@@ -274,7 +279,7 @@ export async function withCookieReadRetry<T>(operation: () => T | Promise<T>): P
|
||||
* List unique cookie domains + counts from a browser's DB. No decryption.
|
||||
*/
|
||||
export function listDomains(browserName: string, profile = 'Default'): { domains: DomainEntry[]; browser: string } {
|
||||
const browser = resolveBrowser(browserName);
|
||||
const browser = resolveBrowserInfo(browserName);
|
||||
const match = getBrowserMatch(browser, profile);
|
||||
const db = openDb(match.dbPath, browser.name);
|
||||
try {
|
||||
@@ -306,7 +311,7 @@ export async function importCookies(
|
||||
const normalized = normalizeCookieDomain(domain);
|
||||
return [normalized, '.' + normalized];
|
||||
}))];
|
||||
const browser = resolveBrowser(browserName);
|
||||
const browser = resolveBrowserInfo(browserName);
|
||||
const match = getBrowserMatch(browser, profile);
|
||||
const db = openDb(match.dbPath, browser.name);
|
||||
|
||||
@@ -350,9 +355,17 @@ export async function importCookies(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw the same typed not-installed error an import would raise when the
|
||||
* profile has no cookie database; returns quietly when one exists.
|
||||
*/
|
||||
export function assertCookieDatabase(browserName: string, profile: string): void {
|
||||
getBrowserMatch(resolveBrowserInfo(browserName), profile);
|
||||
}
|
||||
|
||||
// ─── Internal: Browser Resolution ───────────────────────────────
|
||||
|
||||
function resolveBrowser(nameOrAlias: string): BrowserInfo {
|
||||
export function resolveBrowserInfo(nameOrAlias: string): BrowserInfo {
|
||||
const needle = nameOrAlias.toLowerCase().trim();
|
||||
const found = BROWSER_REGISTRY.find(b =>
|
||||
b.aliases.includes(needle) || b.name.toLowerCase() === needle
|
||||
@@ -360,13 +373,41 @@ function resolveBrowser(nameOrAlias: string): BrowserInfo {
|
||||
if (!found) {
|
||||
const supported = BROWSER_REGISTRY.flatMap(b => b.aliases).join(', ');
|
||||
throw new CookieImportError(
|
||||
`Unknown browser '${nameOrAlias}'. Supported: ${supported}`,
|
||||
`Unknown browser '${nameOrAlias}'. Supported on this OS: ${hostBrowserTokens()}. All names: ${supported}`,
|
||||
'unknown_browser',
|
||||
);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const PLATFORM_LABELS: Record<BrowserPlatform, string> = { darwin: 'macOS', linux: 'Linux', win32: 'Windows' };
|
||||
|
||||
function joinLabels(labels: string[]): string {
|
||||
return labels.length <= 1 ? labels.join('') : `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`;
|
||||
}
|
||||
|
||||
function hostBrowserTokens(): string {
|
||||
const host = getHostPlatform();
|
||||
return BROWSER_REGISTRY
|
||||
.filter(browser => !host || getDataDirForPlatform(browser, host) !== null)
|
||||
.map(browser => `${browser.name} (${browser.aliases[0]})`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function symbolicLocation(browser: BrowserInfo, platform: BrowserPlatform): string {
|
||||
const dataDir = getDataDirForPlatform(browser, platform)!.replace(/\/$/, '');
|
||||
if (platform === 'darwin') return `~/Library/Application Support/${dataDir}`;
|
||||
if (platform === 'linux') return `~/.config/${dataDir}`;
|
||||
return `${browser.windowsDataRoot === 'roaming' ? '%APPDATA%' : '%LOCALAPPDATA%'}\\${dataDir.replace(/\//g, '\\')}`;
|
||||
}
|
||||
|
||||
function profileCookieCandidates(platform: BrowserPlatform, profileDir: string): string[] {
|
||||
// Chrome 80+ on Windows stores cookies under Network/Cookies; fall back to Cookies
|
||||
return platform === 'win32'
|
||||
? [path.join(profileDir, 'Network', 'Cookies'), path.join(profileDir, 'Cookies')]
|
||||
: [path.join(profileDir, 'Cookies')];
|
||||
}
|
||||
|
||||
function validateProfile(profile: string): void {
|
||||
if (/[/\\]|\.\./.test(profile) || /[\x00-\x1f]/.test(profile)) {
|
||||
throw new CookieImportError(
|
||||
@@ -393,14 +434,21 @@ function getSearchPlatforms(): BrowserPlatform[] {
|
||||
}
|
||||
|
||||
function getDataDirForPlatform(browser: BrowserInfo, platform: BrowserPlatform): string | null {
|
||||
if (platform === 'darwin') return browser.dataDir;
|
||||
if (platform === 'darwin') return browser.dataDir || null;
|
||||
if (platform === 'linux') return browser.linuxDataDir || null;
|
||||
return browser.windowsDataDir || null;
|
||||
}
|
||||
|
||||
function getBaseDir(platform: BrowserPlatform): string {
|
||||
function windowsBaseDir(browser: BrowserInfo): string {
|
||||
if (browser.windowsDataRoot !== 'roaming') return path.join(os.homedir(), 'AppData', 'Local');
|
||||
const appData = process.env.APPDATA;
|
||||
if (typeof appData === 'string' && path.win32.isAbsolute(appData.trim())) return appData.trim();
|
||||
return path.join(os.homedir(), 'AppData', 'Roaming');
|
||||
}
|
||||
|
||||
function getBaseDir(platform: BrowserPlatform, browser: BrowserInfo): string {
|
||||
if (platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support');
|
||||
if (platform === 'win32') return path.join(os.homedir(), 'AppData', 'Local');
|
||||
if (platform === 'win32') return windowsBaseDir(browser);
|
||||
return path.join(os.homedir(), '.config');
|
||||
}
|
||||
|
||||
@@ -409,12 +457,8 @@ function findBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch |
|
||||
for (const platform of getSearchPlatforms()) {
|
||||
const dataDir = getDataDirForPlatform(browser, platform);
|
||||
if (!dataDir) continue;
|
||||
const baseProfile = path.join(getBaseDir(platform), dataDir, profile);
|
||||
// Chrome 80+ on Windows stores cookies under Network/Cookies; fall back to Cookies
|
||||
const candidates = platform === 'win32'
|
||||
? [path.join(baseProfile, 'Network', 'Cookies'), path.join(baseProfile, 'Cookies')]
|
||||
: [path.join(baseProfile, 'Cookies')];
|
||||
for (const dbPath of candidates) {
|
||||
const baseProfile = path.join(getBaseDir(platform, browser), dataDir, profile);
|
||||
for (const dbPath of profileCookieCandidates(platform, baseProfile)) {
|
||||
try {
|
||||
if (fs.existsSync(dbPath)) {
|
||||
return { browser, platform, dbPath };
|
||||
@@ -429,17 +473,31 @@ function getBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch {
|
||||
const match = findBrowserMatch(browser, profile);
|
||||
if (match) return match;
|
||||
|
||||
const attempted = getSearchPlatforms()
|
||||
.map(platform => {
|
||||
const dataDir = getDataDirForPlatform(browser, platform);
|
||||
return dataDir ? path.join(getBaseDir(platform), dataDir, profile, 'Cookies') : null;
|
||||
})
|
||||
.filter((entry): entry is string => entry !== null);
|
||||
const platforms = getSearchPlatforms().filter(platform => getDataDirForPlatform(browser, platform) !== null);
|
||||
const rootFor = (platform: BrowserPlatform) => path.join(getBaseDir(platform, browser), getDataDirForPlatform(browser, platform)!);
|
||||
const checked = `Checked: ${platforms.flatMap(platform => profileCookieCandidates(platform, path.join(rootFor(platform), profile))).join(', ')}.`;
|
||||
const host = getHostPlatform();
|
||||
|
||||
throw new CookieImportError(
|
||||
`${browser.name} is not installed (no cookie database at ${attempted.join(' or ')})`,
|
||||
'not_installed',
|
||||
);
|
||||
if (host && !platforms.includes(host)) {
|
||||
const locations = platforms.map(platform => `${symbolicLocation(browser, platform)} on ${PLATFORM_LABELS[platform]}`).join('; ');
|
||||
throw new CookieImportError(
|
||||
`${browser.name} cookie import is available on ${joinLabels(platforms.map(platform => PLATFORM_LABELS[platform]))} only. It reads ${locations}. Browsers available on this OS: ${hostBrowserTokens()}. ${checked}`,
|
||||
'not_installed',
|
||||
);
|
||||
}
|
||||
|
||||
if (platforms.some(platform => fs.existsSync(rootFor(platform)))) {
|
||||
const available = listProfiles(browser.name).map(entry => entry.name);
|
||||
throw new CookieImportError(
|
||||
`${browser.name} profile '${profile}' not found. Available: ${available.length ? available.join(', ') : 'none'}. ${checked}`,
|
||||
'not_installed',
|
||||
);
|
||||
}
|
||||
|
||||
const layout = browser.windowsDataRoot === 'roaming'
|
||||
? ` Supported layout: ${symbolicLocation(browser, 'win32')}\\<Default|Profile N>\\Network\\Cookies. Legacy root-level layouts, side profiles and portable installs are not supported; sign in manually with \`$B handoff\` instead.`
|
||||
: '';
|
||||
throw new CookieImportError(`No supported ${browser.name} cookie database found. ${checked}${layout}`, 'not_installed');
|
||||
}
|
||||
|
||||
// ─── Internal: SQLite Access ────────────────────────────────────
|
||||
@@ -563,7 +621,7 @@ async function getWindowsAesKey(browser: BrowserInfo): Promise<Buffer> {
|
||||
const dataDir = getDataDirForPlatform(browser, platform);
|
||||
if (!dataDir) throw new CookieImportError(`No Windows data dir for ${browser.name}`, 'not_installed');
|
||||
|
||||
const localStatePath = path.join(getBaseDir(platform), dataDir, 'Local State');
|
||||
const localStatePath = path.join(getBaseDir(platform, browser), dataDir, 'Local State');
|
||||
let localState: any;
|
||||
try {
|
||||
localState = JSON.parse(fs.readFileSync(localStatePath, 'utf-8'));
|
||||
@@ -784,7 +842,11 @@ function decryptCookieValue(row: RawCookie, keys: Map<string, Buffer>, platform:
|
||||
const ciphertext = ev.slice(15, ev.length - 16);
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce) as crypto.DecipherGCM;
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf-8');
|
||||
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
// Cookie DB meta version 24+ prefixes the value with SHA-256(host_key).
|
||||
const hostHash = crypto.createHash('sha256').update(row.host_key).digest();
|
||||
const prefixed = plaintext.length >= 32 && plaintext.subarray(0, 32).equals(hostHash);
|
||||
return (prefixed ? plaintext.subarray(32) : plaintext).toString('utf-8');
|
||||
}
|
||||
|
||||
// macOS / Linux: AES-128-CBC — structure: v10/v11(3) + ciphertext
|
||||
@@ -844,14 +906,17 @@ export async function importCookiesViaCdp(
|
||||
): Promise<ImportResult> {
|
||||
if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
|
||||
if (process.platform !== 'win32') throw new CookieImportError('Native extraction is only supported on Windows', 'not_supported');
|
||||
const browser = resolveBrowser(browserName);
|
||||
const browser = resolveBrowserInfo(browserName);
|
||||
validateProfile(profile);
|
||||
const dataDir = getDataDirForPlatform(browser, 'win32');
|
||||
if (!dataDir) throw new CookieImportError('This browser is not supported on Windows', 'not_supported');
|
||||
if (!browser.windowsNative) {
|
||||
throw new CookieImportError(`${browser.name} has no native cookie extraction. Sign in manually: run \`$B handoff\`, sign in, then \`$B resume\`.`, 'native_unsupported_browser');
|
||||
}
|
||||
const { importNativeCookies } = await import('./cookie-import-native');
|
||||
const cookies = await importNativeCookies({
|
||||
browserName: browser.name,
|
||||
userDataDir: path.join(getBaseDir('win32'), dataDir),
|
||||
userDataDir: path.join(getBaseDir('win32', browser), dataDir),
|
||||
profile,
|
||||
domains: [...new Set(domains.flatMap(domain => {
|
||||
const normalized = normalizeCookieDomain(domain);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Page } from 'playwright';
|
||||
import {
|
||||
CookieImportError, cookieDomainMatches, importCookies, importCookiesViaCdp,
|
||||
listDomains, listProfiles, normalizeCookieDomain, withCookieReadRetry,
|
||||
CookieImportError, assertCookieDatabase, cookieDomainMatches, importCookies, importCookiesViaCdp,
|
||||
listDomains, listProfiles, normalizeCookieDomain, resolveBrowserInfo, withCookieReadRetry,
|
||||
type ProfileEntry,
|
||||
} from './cookie-import-browser';
|
||||
import { clearCookieTargetStorage, validateCookieAuthOptions, validateCookieStorageSupport, verifyCookieAuthentication, type CookieAuthVerificationOptions } from './cookie-auth-verification';
|
||||
@@ -78,6 +78,36 @@ export async function getCookieProfiles(browser: string, domains: string[] = [],
|
||||
return { profiles, recommendedProfile };
|
||||
}
|
||||
|
||||
type ProfileSuggestion = Array<ProfileEntry & { matches?: boolean; unavailable?: boolean }>;
|
||||
|
||||
function profileSelectionError(browser: string, profiles: ProfileSuggestion, domains: string[]): CookieImportError {
|
||||
const { name, aliases } = resolveBrowserInfo(browser);
|
||||
if (!profiles.length) {
|
||||
assertCookieDatabase(browser, 'Default');
|
||||
return new CookieImportError(`${name} profiles changed while selecting. Retry with --profile "<dir>", or run \`$B cookie-import-browser ${aliases[0]}\` to choose in the picker.`, 'profile_required');
|
||||
}
|
||||
const retry = `Retry with --profile "<dir>", or run \`$B cookie-import-browser ${aliases[0]}\` to choose in the picker.`;
|
||||
const list = (entries: ProfileSuggestion) => entries.map(entry => entry.name).join(', ');
|
||||
const unreadable = profiles.filter(entry => entry.unavailable);
|
||||
const matching = profiles.filter(entry => entry.matches === true);
|
||||
const scope = domains.join(', ');
|
||||
if (unreadable.length) {
|
||||
const found = matching.length ? ` Profiles with cookies for ${scope}: ${list(matching)}.` : '';
|
||||
return new CookieImportError(`${name} profiles ${list(unreadable)} could not be read (the browser may be locking them). Close ${name} and retry, or pass --profile.${found}`, 'profile_required');
|
||||
}
|
||||
if (!domains.length) return new CookieImportError(`${name} has several profiles: ${list(profiles)}. ${retry}`, 'profile_required');
|
||||
if (matching.length > 1) return new CookieImportError(`${name} has several profiles with cookies for ${scope}: ${list(matching)}. ${retry}`, 'profile_required');
|
||||
return new CookieImportError(`No ${name} profile has cookies for ${scope}. Check the domain and that you are signed in to it in ${name}.`, 'profile_required');
|
||||
}
|
||||
|
||||
function appBoundMessage(browser: string, result: { count: number; failureReasons?: Record<string, number> }): string {
|
||||
const { name } = resolveBrowserInfo(browser);
|
||||
const recovery = 'Sign in manually: run `$B handoff`, sign in to the intended account in the window that opens, then run `$B resume` (needs a display). See BROWSER.md, Platform limits.';
|
||||
if (result.count) return `Some ${name} cookies use App-Bound Encryption and were skipped, so the session may not be restored. Check with \`$B reload\` (or --verify-auth); if you are signed out, run \`$B handoff\`, sign in, then \`$B resume\`.`;
|
||||
if (result.failureReasons?.decryption_failed) return `${name} cookies could not be imported: some use App-Bound Encryption and others could not be decrypted. ${recovery}`;
|
||||
return `Some selected ${name} cookies use App-Bound Encryption, which gstack cannot decrypt for this browser. ${recovery}`;
|
||||
}
|
||||
|
||||
export function validateCookieTarget(target: CookieImportTarget): URL {
|
||||
try {
|
||||
const url = new URL(target.url);
|
||||
@@ -119,13 +149,15 @@ export async function runCookieImport(
|
||||
if (!profile) {
|
||||
const suggestion = await getCookieProfiles(options.browser, selected);
|
||||
profile = suggestion.recommendedProfile;
|
||||
if (!profile) throw new CookieImportError('Choose a source profile explicitly; matching profiles are ambiguous, unavailable, or empty.', 'profile_required');
|
||||
if (!profile) throw profileSelectionError(options.browser, suggestion.profiles, selected);
|
||||
}
|
||||
const domains = options.all
|
||||
? (await withCookieReadRetry(() => listDomains(options.browser, profile!))).domains.map(entry => entry.domain)
|
||||
: selected;
|
||||
let result = await withCookieReadRetry(() => importCookies(options.browser, domains, profile));
|
||||
if (result.count === 0 && result.failureReasons?.unsupported_encryption && process.platform === 'win32') {
|
||||
const appBound = process.platform === 'win32' && result.failureReasons?.unsupported_encryption && !resolveBrowserInfo(options.browser).windowsNative
|
||||
? appBoundMessage(options.browser, result) : undefined;
|
||||
if (!appBound && result.count === 0 && result.failureReasons?.unsupported_encryption && process.platform === 'win32') {
|
||||
const failed = result.failed;
|
||||
result = await importCookiesViaCdp(options.browser, domains, profile);
|
||||
result.failed = Math.max(result.failed, failed - result.count);
|
||||
@@ -141,7 +173,7 @@ export async function runCookieImport(
|
||||
outcome: (result.failed ? 'failed' : 'empty') as 'empty' | 'imported' | 'partial' | 'failed',
|
||||
reset: 'not_requested' as 'not_requested' | 'cleared' | 'failed',
|
||||
verification: { verified: false, reason: 'not_requested' } as { verified: boolean; reason: string; status?: number },
|
||||
message: result.failed ? 'No cookies imported; cookies could not be decrypted.' : 'No matching cookies found.',
|
||||
message: appBound ?? (result.failed ? 'No cookies imported; cookies could not be decrypted.' : 'No matching cookies found.'),
|
||||
};
|
||||
if (!result.count) {
|
||||
if (options.verifyAuth) receipt.verification.reason = 'no_cookies_imported';
|
||||
@@ -178,7 +210,7 @@ export async function runCookieImport(
|
||||
receipt.imported = result.count;
|
||||
receipt.domainCounts = result.domainCounts;
|
||||
receipt.outcome = result.failed ? 'partial' : 'imported';
|
||||
receipt.message = result.failed ? 'Some cookies could not be decrypted.' : 'Cookie copy complete.';
|
||||
receipt.message = appBound ?? (result.failed ? 'Some cookies could not be decrypted.' : 'Cookie copy complete.');
|
||||
if (options.verifyAuth) {
|
||||
try {
|
||||
validateCookieTarget(target);
|
||||
@@ -194,5 +226,7 @@ export async function runCookieImport(
|
||||
}
|
||||
|
||||
export function formatCookieImportResult(result: Awaited<ReturnType<typeof runCookieImport>>): string {
|
||||
return `Imported ${result.imported} cookies from ${result.browser} (profile: ${result.profile}); ${result.failed} failed to decrypt. ${result.message} Storage reset: ${result.reset}. Authentication: ${result.verification.reason}.`;
|
||||
const reasons = Object.entries(result.failureReasons).sort(([a], [b]) => a.localeCompare(b)).map(([key, count]) => `${key}=${count}`);
|
||||
const reasonText = reasons.length ? ` Failure reasons: ${reasons.join(', ')}.` : '';
|
||||
return `Imported ${result.imported} cookies from ${result.browser} (profile: ${result.profile}); ${result.failed} failed to decrypt. ${result.message}${reasonText} Storage reset: ${result.reset}. Authentication: ${result.verification.reason}.`;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
||||
import { createCipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
import { copyFileSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
@@ -28,6 +29,20 @@ beforeAll(() => {
|
||||
mkdirSync(windows, { recursive: true });
|
||||
expect(realpathSync(windows).startsWith(root + path.sep)).toBe(true);
|
||||
copyFileSync(dbPath, path.join(windows, 'Cookies'));
|
||||
|
||||
const writePlain = (directory: string, domain: string, name: string, value: string) => {
|
||||
mkdirSync(directory, { recursive: true });
|
||||
expect(realpathSync(directory).startsWith(root + path.sep)).toBe(true);
|
||||
const database = new Database(path.join(directory, 'Cookies'));
|
||||
database.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
database.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 0, 1, 0, 1)', [domain, name, value, Buffer.alloc(0), '/']);
|
||||
database.close();
|
||||
};
|
||||
writePlain(path.join(root, 'OperaRoaming/Opera Software/Opera Stable/Default/Network'), '.opera.fixture', 'opera-cookie', 'opera-plaintext');
|
||||
writePlain(path.join(root, 'OperaRoaming/Opera Software/Opera Stable/User Data/Default/Network'), '.opera.fixture', 'opera-cookie', 'user-data-decoy');
|
||||
writePlain(path.join(root, 'OperaRoaming/Opera Software/Opera GX Stable/Profile 2/Network'), '.gx.fixture', 'gx-cookie', 'gx-plaintext');
|
||||
writePlain(path.join(root, 'AppData/Local/Opera Software/Opera Stable/Default/Network'), '.opera.fixture', 'opera-cookie', 'local-decoy');
|
||||
writePlain(path.join(root, 'AppData/Local/Opera Software/Opera GX Stable/Default/Network'), '.gx.fixture', 'gx-cookie', 'local-gx-decoy');
|
||||
});
|
||||
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
@@ -49,6 +64,101 @@ describe('actual Node importer runtime', () => {
|
||||
expect(JSON.parse(child.stdout)).toEqual({ domains: { browser: 'Chromium', domains: [{ domain: '.fixture.test', count: 1 }] }, count: 1, failed: 0, scope: ['.fixture.test'], cookieName: 'synthetic' });
|
||||
});
|
||||
|
||||
// Path and SQLite coverage for the bundled module. This does not exercise Windows DPAPI.
|
||||
test('discovers and imports plaintext Opera and Opera GX cookies through the bundled module', () => {
|
||||
const child = spawnSync(node!, ['--input-type=module', '-e', `
|
||||
const { findInstalledBrowsers, listDomains, listProfiles, listSupportedBrowserNames, importCookies } = await import(process.argv[1]);
|
||||
const opera = await importCookies('opera', ['opera.fixture']);
|
||||
const gx = await importCookies('Opera GX', ['gx.fixture'], 'Profile 2');
|
||||
const skipped = await importCookies('opera', ['gx.fixture']);
|
||||
console.log(JSON.stringify({
|
||||
platform: process.platform,
|
||||
discovered: findInstalledBrowsers().map(browser => browser.name).sort(),
|
||||
supported: listSupportedBrowserNames(),
|
||||
operaDomains: listDomains('opera').domains.map(entry => entry.domain).sort(),
|
||||
gxProfiles: listProfiles('opera-gx').map(profile => profile.name),
|
||||
gxDomains: listDomains('opera-gx', 'Profile 2').domains.map(entry => entry.domain),
|
||||
opera: { count: opera.count, failed: opera.failed, name: opera.cookies[0]?.name, value: opera.cookies[0]?.value },
|
||||
gx: { count: gx.count, failed: gx.failed, name: gx.cookies[0]?.name, value: gx.cookies[0]?.value },
|
||||
skipped: skipped.count,
|
||||
}));
|
||||
`, pathToFileURL(bundle).href], {
|
||||
encoding: 'utf8', timeout: 15_000,
|
||||
env: {
|
||||
HOME: root, USERPROFILE: root, LOCALAPPDATA: path.join(root, 'AppData/Local'),
|
||||
APPDATA: path.join(root, 'OperaRoaming'), TEMP: root, TMP: root, NODE_NO_WARNINGS: '1',
|
||||
PATH: path.dirname(node!), ...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
|
||||
},
|
||||
});
|
||||
expect(child.error).toBeUndefined();
|
||||
expect(child.status).toBe(0);
|
||||
expect(child.stderr).toBe('');
|
||||
const parsed = JSON.parse(child.stdout);
|
||||
expect(parsed.discovered).toEqual(expect.arrayContaining(['Opera', 'Opera GX']));
|
||||
expect(parsed.platform === 'win32'
|
||||
? parsed.supported.includes('Opera') && parsed.supported.includes('Opera GX')
|
||||
: !parsed.supported.includes('Opera') && !parsed.supported.includes('Opera GX')).toBe(true);
|
||||
expect(parsed.operaDomains).toEqual(['.opera.fixture']);
|
||||
expect(parsed.gxProfiles).toEqual(['Profile 2']);
|
||||
expect(parsed.gxDomains).toEqual(['.gx.fixture']);
|
||||
expect(parsed.opera).toEqual({ count: 1, failed: 0, name: 'opera-cookie', value: 'opera-plaintext' });
|
||||
expect(parsed.gx).toEqual({ count: 1, failed: 0, name: 'gx-cookie', value: 'gx-plaintext' });
|
||||
expect(parsed.skipped).toBe(0);
|
||||
});
|
||||
|
||||
// Real Windows DPAPI through the production Node polyfill; no credential subprocess mock.
|
||||
test.skipIf(process.platform !== 'win32')('decrypts a host-bound Opera v10 cookie with real DPAPI under the Node server runtime', () => {
|
||||
const systemRoot = process.env.SystemRoot || 'C:\\Windows';
|
||||
const powershellDir = path.win32.join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0');
|
||||
const key = randomBytes(32);
|
||||
const protect = spawnSync(path.win32.join(powershellDir, 'powershell.exe'), ['-NoProfile', '-NonInteractive', '-Command',
|
||||
'Add-Type -AssemblyName System.Security; $b = [Convert]::FromBase64String([Console]::In.ReadToEnd().Trim()); [Convert]::ToBase64String([System.Security.Cryptography.ProtectedData]::Protect($b, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser))',
|
||||
], { input: key.toString('base64'), encoding: 'utf8', timeout: 30_000, windowsHide: true });
|
||||
expect(protect.status).toBe(0);
|
||||
const blob = Buffer.from(protect.stdout.trim(), 'base64');
|
||||
expect(blob.length).toBeGreaterThan(32);
|
||||
|
||||
const appData = path.join(root, 'DpapiRoaming');
|
||||
const browserRoot = path.join(appData, 'Opera Software', 'Opera Stable');
|
||||
const network = path.join(browserRoot, 'Default', 'Network');
|
||||
mkdirSync(network, { recursive: true });
|
||||
expect(realpathSync(network).startsWith(root + path.sep)).toBe(true);
|
||||
writeFileSync(path.join(browserRoot, 'Local State'), JSON.stringify({ os_crypt: { encrypted_key: Buffer.concat([Buffer.from('DPAPI'), blob]).toString('base64') } }));
|
||||
const seal = (sealKey: Buffer, plaintext: Buffer) => {
|
||||
const nonce = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', sealKey, nonce);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
return Buffer.concat([Buffer.from('v10'), nonce, ciphertext, cipher.getAuthTag()]);
|
||||
};
|
||||
const expected = 'dpapi-synthetic-session';
|
||||
const hostHash = createHash('sha256').update('.dpapi.fixture').digest();
|
||||
const database = new Database(path.join(network, 'Cookies'));
|
||||
database.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
database.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 1, 1, 0, 1)', ['.dpapi.fixture', 'session', '', seal(key, Buffer.concat([hostHash, Buffer.from(expected)])), '/']);
|
||||
database.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 1, 1, 0, 1)', ['.dpapi.fixture', 'wrong-key', '', seal(randomBytes(32), Buffer.concat([hostHash, Buffer.from('other')])), '/']);
|
||||
database.close();
|
||||
|
||||
const child = spawnSync(node!, ['--input-type=module', '-e', `
|
||||
import { createRequire } from 'node:module';
|
||||
createRequire(import.meta.url)(process.argv[1]);
|
||||
const { importCookies } = await import(process.argv[2]);
|
||||
const result = await importCookies('opera', ['dpapi.fixture']);
|
||||
console.log(JSON.stringify({ count: result.count, failed: result.failed, reasons: result.failureReasons, matches: result.cookies[0]?.value === process.argv[3] }));
|
||||
`, path.resolve(import.meta.dir, '../src/bun-polyfill.cjs'), pathToFileURL(bundle).href, expected], {
|
||||
encoding: 'utf8', timeout: 45_000, windowsHide: true,
|
||||
// Keep the runner's full environment: PowerShell started with a stripped
|
||||
// environment takes ~20s on Windows CI (measured), past dpapiDecrypt's 10s
|
||||
// deadline. Only APPDATA moves, to the fixture's Opera root.
|
||||
env: { ...process.env, APPDATA: appData, NODE_NO_WARNINGS: '1' },
|
||||
});
|
||||
expect(child.error).toBeUndefined();
|
||||
expect(child.stderr).toBe('');
|
||||
expect(child.status).toBe(0);
|
||||
expect(JSON.parse(child.stdout)).toEqual({ count: 1, failed: 1, reasons: { decryption_failed: 1 }, matches: true });
|
||||
expect(child.stdout).not.toContain(expected);
|
||||
expect(child.stdout).not.toContain(key.toString('base64'));
|
||||
}, 90_000);
|
||||
|
||||
test('Node server build does not stub away the database', () => {
|
||||
const script = readFileSync(path.resolve(import.meta.dir, '../scripts/build-node-server.sh'), 'utf8');
|
||||
expect(script).not.toContain('const Database = null');
|
||||
|
||||
@@ -315,3 +315,138 @@ describe('registered import callers', () => {
|
||||
try { expect((await route('GET', '?code=' + expired)).status).toBe(403); } finally { Date.now = now; }
|
||||
});
|
||||
});
|
||||
|
||||
describe('Windows Opera receipts and profile diagnostics', () => {
|
||||
const operaDb = (profile: string, rows: Array<{ domain: string; name: string; value?: string; encrypted?: Buffer }>) => {
|
||||
const dir = path.join(home, 'AppData/Roaming/Opera Software/Opera Stable', profile, 'Network');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
expect(fs.realpathSync(dir).startsWith(fs.realpathSync(home) + path.sep)).toBe(true);
|
||||
const db = new Database(path.join(dir, 'Cookies'));
|
||||
db.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
for (const row of rows) db.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 1, 1, 0, 1)', [row.domain, row.name, row.value ?? '', row.encrypted ?? Buffer.alloc(0), '/']);
|
||||
db.close();
|
||||
};
|
||||
const onPlatform = async <T>(value: string, run: () => Promise<T>): Promise<T> => {
|
||||
const platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
const appData = process.env.APPDATA;
|
||||
delete process.env.APPDATA;
|
||||
Object.defineProperty(process, 'platform', { value, configurable: true });
|
||||
try { return await run(); } finally {
|
||||
Object.defineProperty(process, 'platform', platform);
|
||||
if (appData === undefined) delete process.env.APPDATA; else process.env.APPDATA = appData;
|
||||
}
|
||||
};
|
||||
const recovery = 'Sign in manually: run `$B handoff`, sign in to the intended account in the window that opens, then run `$B resume` (needs a display). See BROWSER.md, Platform limits.';
|
||||
|
||||
test('App-Bound-only Opera rows return a failed receipt without native extraction', async () => {
|
||||
operaDb('Default', [{ domain: '.example.test', name: 'bound', encrypted: Buffer.from('v20synthetic') }]);
|
||||
const native = spyOn(importer, 'importCookiesViaCdp');
|
||||
try {
|
||||
for (const verifyAuth of [false, true]) {
|
||||
const result = await onPlatform('win32', () => runCookieImport({ browser: 'opera', profile: 'Default', domains: ['example.test'], verifyAuth }, { page, url: currentUrl }, () => {}, verifyAuth ? { identitySelector: '#me', expectedIdentity: 'me' } : {}));
|
||||
expect(result).toEqual({
|
||||
browser: 'opera', profile: 'Default', imported: 0, failed: 1, domainCounts: {},
|
||||
failureReasons: { unsupported_encryption: 1 }, outcome: 'failed', reset: 'not_requested',
|
||||
verification: { verified: false, reason: verifyAuth ? 'no_cookies_imported' : 'not_requested' },
|
||||
message: `Some selected Opera cookies use App-Bound Encryption, which gstack cannot decrypt for this browser. ${recovery}`,
|
||||
});
|
||||
}
|
||||
expect(native).not.toHaveBeenCalled();
|
||||
expect(context.addCookies).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
native.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('mixed App-Bound and undecryptable Opera rows name both causes', async () => {
|
||||
operaDb('Default', [{ domain: '.example.test', name: 'bound', encrypted: Buffer.from('v20synthetic') }]);
|
||||
const imported = spyOn(importer, 'importCookies').mockResolvedValue({ cookies: [], count: 0, failed: 2, domainCounts: {}, failureReasons: { unsupported_encryption: 1, decryption_failed: 1 } });
|
||||
try {
|
||||
const result = await onPlatform('win32', () => runCookieImport({ browser: 'opera', profile: 'Default', domains: ['example.test'] }, { page, url: currentUrl }, () => {}));
|
||||
expect(result.failureReasons).toEqual({ decryption_failed: 1, unsupported_encryption: 1 });
|
||||
expect(result.outcome).toBe('failed');
|
||||
expect(result.message).toBe(`Opera cookies could not be imported: some use App-Bound Encryption and others could not be decrypted. ${recovery}`);
|
||||
} finally {
|
||||
imported.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('partial Opera imports apply readable cookies and warn about skipped App-Bound rows', async () => {
|
||||
operaDb('Default', [
|
||||
{ domain: '.example.test', name: 'plain', value: 'synthetic-plain' },
|
||||
{ domain: '.example.test', name: 'bound', encrypted: Buffer.from('v20synthetic') },
|
||||
]);
|
||||
const native = spyOn(importer, 'importCookiesViaCdp');
|
||||
try {
|
||||
const result = await onPlatform('win32', () => runCookieImport({ browser: 'opera', profile: 'Default', domains: ['example.test'] }, { page, url: currentUrl }, () => {}));
|
||||
expect(result).toMatchObject({ imported: 1, failed: 1, outcome: 'partial', failureReasons: { unsupported_encryption: 1 } });
|
||||
expect(result.message).toBe('Some Opera cookies use App-Bound Encryption and were skipped, so the session may not be restored. Check with `$B reload` (or --verify-auth); if you are signed out, run `$B handoff`, sign in, then `$B resume`.');
|
||||
expect(context.addCookies).toHaveBeenCalledTimes(1);
|
||||
expect(native).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
native.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('native extraction guard rejects browsers without a native mapping before loading it', async () => {
|
||||
await onPlatform('win32', async () => {
|
||||
await expect(importer.importCookiesViaCdp('opera', ['example.test'])).rejects.toMatchObject({ code: 'native_unsupported_browser' });
|
||||
await expect(importer.importCookiesViaCdp('arc', ['example.test'])).rejects.toMatchObject({ code: 'not_supported' });
|
||||
});
|
||||
});
|
||||
|
||||
test('CLI receipt text lists sorted failure reasons after the message', async () => {
|
||||
const { formatCookieImportResult } = await import('../src/cookie-import-operation');
|
||||
const base = { browser: 'opera', profile: 'Default', imported: 0, failed: 2, domainCounts: {}, outcome: 'failed' as const, reset: 'not_requested' as const, verification: { verified: false, reason: 'not_requested' }, message: 'Message.' };
|
||||
expect(formatCookieImportResult({ ...base, failureReasons: { unsupported_encryption: 1, decryption_failed: 1 } })).toContain('Message. Failure reasons: decryption_failed=1, unsupported_encryption=1. Storage reset:');
|
||||
expect(formatCookieImportResult({ ...base, failureReasons: {} })).toContain('Message. Storage reset:');
|
||||
});
|
||||
|
||||
test('a missing browser reports where it looked instead of asking for a profile', async () => {
|
||||
await onPlatform('darwin', async () => {
|
||||
const error: any = await runCookieImport({ browser: 'opera', domains: ['example.test'] }, { page, url: currentUrl }, () => {}).catch(caught => caught);
|
||||
expect(error.code).toBe('not_installed');
|
||||
expect(error.message.startsWith('Opera cookie import is available on Windows only.')).toBe(true);
|
||||
expect(error.message).toContain('%APPDATA%\\Opera Software\\Opera Stable');
|
||||
expect(error.message).toContain('Browsers available on this OS:');
|
||||
expect(error.message).toContain('Chrome (chrome)');
|
||||
});
|
||||
await onPlatform('win32', async () => {
|
||||
const error: any = await runCookieImport({ browser: 'opera-gx', domains: ['example.test'] }, { page, url: currentUrl }, () => {}).catch(caught => caught);
|
||||
expect(error.code).toBe('not_installed');
|
||||
expect(error.message).toContain('No supported Opera GX cookie database found.');
|
||||
expect(error.message).toContain(path.join('AppData', 'Roaming', 'Opera Software', 'Opera GX Stable', 'Default', 'Network', 'Cookies'));
|
||||
expect(error.message).toContain('Supported layout: %APPDATA%\\Opera Software\\Opera GX Stable\\<Default|Profile N>\\Network\\Cookies');
|
||||
});
|
||||
});
|
||||
|
||||
test('profile diagnostics explain ambiguity, empty matches, unreadable profiles and --all', async () => {
|
||||
installProfile();
|
||||
installProfile('Profile 2');
|
||||
const run = (options: any) => runCookieImport({ browser: 'chromium', ...options }, { page, url: currentUrl }, () => {}).catch(caught => caught);
|
||||
let error = await run({ domains: ['example.test'] });
|
||||
expect(error.code).toBe('profile_required');
|
||||
expect(error.message).toBe('Chromium has several profiles with cookies for example.test: Default, Profile 2. Retry with --profile "<dir>", or run `$B cookie-import-browser chromium` to choose in the picker.');
|
||||
error = await run({ domains: ['missing.test'] });
|
||||
expect(error.message).toBe('No Chromium profile has cookies for missing.test. Check the domain and that you are signed in to it in Chromium.');
|
||||
error = await run({ all: true });
|
||||
expect(error.message).toBe('Chromium has several profiles: Default, Profile 2. Retry with --profile "<dir>", or run `$B cookie-import-browser chromium` to choose in the picker.');
|
||||
fs.writeFileSync(path.join(home, '.config/chromium/Profile 2/Cookies'), 'not a database');
|
||||
error = await run({ domains: ['example.test'] });
|
||||
expect(error.code).toBe('profile_required');
|
||||
expect(error.message).toBe('Chromium profiles Profile 2 could not be read (the browser may be locking them). Close Chromium and retry, or pass --profile. Profiles with cookies for example.test: Default.');
|
||||
expect(error.message).not.toContain('synthetic-session');
|
||||
});
|
||||
|
||||
test('a profile that appears after listing asks for an explicit profile', async () => {
|
||||
const list = spyOn(importer, 'listProfiles').mockReturnValue([]);
|
||||
try {
|
||||
installProfile();
|
||||
const error: any = await runCookieImport({ browser: 'chromium', domains: ['example.test'] }, { page, url: currentUrl }, () => {}).catch(caught => caught);
|
||||
expect(error.code).toBe('profile_required');
|
||||
expect(error.message).toContain('Chromium profiles changed while selecting.');
|
||||
} finally {
|
||||
list.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,71 @@
|
||||
import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import * as crypto from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { findInstalledBrowsers, importCookies, listProfiles, cookieDomainMatches, CookieImportError, normalizeCookieDomain, withCookieReadRetry } from '../src/cookie-import-browser';
|
||||
import { findInstalledBrowsers, importCookies, listDomains, listProfiles, listSupportedBrowserNames, cookieDomainMatches, CookieImportError, normalizeCookieDomain, resolveBrowserInfo, withCookieReadRetry } from '../src/cookie-import-browser';
|
||||
import { nativeBrowserPaths } from '../src/cookie-import-native';
|
||||
|
||||
let home: string;
|
||||
let oldHome: string | undefined;
|
||||
let oldUserProfile: string | undefined;
|
||||
let oldAppData: string | undefined;
|
||||
let platform: PropertyDescriptor;
|
||||
let spawn: typeof Bun.spawn;
|
||||
let homeMock: ReturnType<typeof spyOn>;
|
||||
let extraDirs: string[] = [];
|
||||
|
||||
type CookieRow = { domain: string; name: string; value?: string; encrypted?: Buffer };
|
||||
|
||||
function tempRoot(prefix: string): string {
|
||||
const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix)));
|
||||
extraDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function assertInside(root: string, target: string): void {
|
||||
const resolvedRoot = fs.realpathSync(root);
|
||||
const resolvedTarget = fs.realpathSync(target);
|
||||
expect(resolvedTarget === resolvedRoot || resolvedTarget.startsWith(resolvedRoot + path.sep)).toBe(true);
|
||||
}
|
||||
|
||||
function writeCookies(root: string, directory: string, rows: CookieRow[]): string {
|
||||
const target = path.join(root, directory);
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
assertInside(root, target);
|
||||
const db = new Database(path.join(target, 'Cookies'));
|
||||
db.run('CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB, path TEXT, expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, has_expires INTEGER, samesite INTEGER)');
|
||||
for (const row of rows) {
|
||||
db.run('INSERT INTO cookies VALUES (?, ?, ?, ?, ?, 0, 1, 1, 0, 1)', [row.domain, row.name, row.value ?? '', row.encrypted ?? Buffer.alloc(0), '/']);
|
||||
}
|
||||
db.close();
|
||||
return target;
|
||||
}
|
||||
|
||||
function writeDpapiState(root: string, browserDir: string, material: Buffer): void {
|
||||
const target = path.join(root, browserDir);
|
||||
fs.mkdirSync(target, { recursive: true });
|
||||
assertInside(root, target);
|
||||
const encryptedKey = Buffer.concat([Buffer.from('DPAPI'), material]).toString('base64');
|
||||
fs.writeFileSync(path.join(target, 'Local State'), JSON.stringify({ os_crypt: { encrypted_key: encryptedKey } }));
|
||||
}
|
||||
|
||||
function windowsV10Cookie(key: Buffer, plaintext: string | Buffer): Buffer {
|
||||
const nonce = Buffer.alloc(12, 0x24);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, nonce);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
return Buffer.concat([Buffer.from('v10'), nonce, ciphertext, cipher.getAuthTag()]);
|
||||
}
|
||||
|
||||
function closedStream(content = ''): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
if (content) controller.enqueue(Buffer.from(content));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function profile(dir: string, name: string, cookieDomain = '.example.test') {
|
||||
const target = path.join(home, dir, name);
|
||||
@@ -24,10 +80,14 @@ function profile(dir: string, name: string, cookieDomain = '.example.test') {
|
||||
|
||||
beforeEach(() => {
|
||||
home = fs.mkdtempSync(path.join(os.tmpdir(), 'cookie-wave-'));
|
||||
extraDirs = [];
|
||||
oldHome = process.env.HOME;
|
||||
oldUserProfile = process.env.USERPROFILE;
|
||||
oldAppData = process.env.APPDATA;
|
||||
platform = Object.getOwnPropertyDescriptor(process, 'platform')!;
|
||||
process.env.HOME = home;
|
||||
process.env.USERPROFILE = home;
|
||||
delete process.env.APPDATA;
|
||||
homeMock = spyOn(os, 'homedir').mockReturnValue(home);
|
||||
spawn = Bun.spawn;
|
||||
Bun.spawn = ((command: string[]) => {
|
||||
@@ -38,9 +98,12 @@ beforeEach(() => {
|
||||
|
||||
afterEach(() => {
|
||||
Bun.spawn = spawn;
|
||||
Object.defineProperty(process, 'platform', platform);
|
||||
homeMock.mockRestore();
|
||||
if (oldHome === undefined) delete process.env.HOME; else process.env.HOME = oldHome;
|
||||
if (oldUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = oldUserProfile;
|
||||
if (oldAppData === undefined) delete process.env.APPDATA; else process.env.APPDATA = oldAppData;
|
||||
for (const dir of extraDirs) fs.rmSync(dir, { recursive: true, force: true });
|
||||
fs.rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -184,4 +247,413 @@ describe('cookie import reliability', () => {
|
||||
})).rejects.toThrow('Locked');
|
||||
expect(attempts).toBe(3);
|
||||
});
|
||||
|
||||
test('lists Opera only on Windows and keeps other families on their current roots', () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' });
|
||||
expect(listSupportedBrowserNames()).toEqual(['Chrome', 'Chromium', 'Brave', 'Edge', 'Opera', 'Opera GX']);
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'darwin' });
|
||||
expect(listSupportedBrowserNames()).toEqual(['Comet', 'Chrome', 'Chromium', 'Arc', 'Dia', 'Brave', 'Edge']);
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' });
|
||||
expect(listSupportedBrowserNames()).toEqual(['Chrome', 'Chromium', 'Brave', 'Edge']);
|
||||
});
|
||||
|
||||
test('discovers Opera and Opera GX from an overridden roaming root and ignores Local and User Data decoys', async () => {
|
||||
const roaming = tempRoot('cookie-opera-appdata-');
|
||||
process.env.APPDATA = roaming;
|
||||
writeCookies(roaming, path.join('Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'opera-chosen' },
|
||||
{ domain: '.other.test', name: 'skip', value: 'opera-other' },
|
||||
]);
|
||||
writeCookies(roaming, path.join('Opera Software', 'Opera Stable', 'User Data', 'Default', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'user-data-decoy' },
|
||||
]);
|
||||
writeCookies(roaming, path.join('Opera Software', 'Opera GX Stable', 'Profile 1', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'gx-chosen' },
|
||||
]);
|
||||
writeCookies(home, path.join('AppData', 'Local', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'local-decoy' },
|
||||
]);
|
||||
writeCookies(home, path.join('AppData', 'Local', 'Opera Software', 'Opera Stable', 'Profile 9', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'local-profile-decoy' },
|
||||
]);
|
||||
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'home-roaming-decoy' },
|
||||
]);
|
||||
writeCookies(home, path.join('AppData', 'Local', 'Opera Software', 'Opera GX Stable', 'Default', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'local-gx-decoy' },
|
||||
]);
|
||||
writeCookies(roaming, path.join('Opera Software', 'Opera Stable', 'Guest Profile', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'guest-decoy' },
|
||||
]);
|
||||
|
||||
expect(findInstalledBrowsers().map(browser => browser.name)).toEqual(expect.arrayContaining(['Opera', 'Opera GX']));
|
||||
for (const alias of ['opera', 'OPERA', 'Opera']) {
|
||||
expect(listProfiles(alias).map(profile => profile.name)).toEqual(['Default']);
|
||||
}
|
||||
for (const alias of ['opera-gx', 'Opera GX', 'opera gx', 'OPERA GX']) {
|
||||
expect(listProfiles(alias)).toEqual([{ name: 'Profile 1', displayName: 'Profile 1' }]);
|
||||
}
|
||||
const listed = listDomains('opera');
|
||||
expect(listed.browser).toBe('Opera');
|
||||
expect(listed.domains.map(entry => `${entry.domain}:${entry.count}`).sort()).toEqual(['.chosen.test:1', '.other.test:1']);
|
||||
const imported = await importCookies('opera', ['chosen.test']);
|
||||
expect(imported.count).toBe(1);
|
||||
expect(imported.failed).toBe(0);
|
||||
expect(imported.cookies.map(cookie => ({ name: cookie.name, value: cookie.value, domain: cookie.domain }))).toEqual([
|
||||
{ name: 'keep', value: 'opera-chosen', domain: '.chosen.test' },
|
||||
]);
|
||||
const gx = await importCookies('opera-gx', ['chosen.test'], 'Profile 1');
|
||||
expect(gx.cookies.map(cookie => cookie.value)).toEqual(['gx-chosen']);
|
||||
expect((await importCookies('Opera', ['other.test'])).cookies.map(cookie => cookie.value)).toEqual(['opera-other']);
|
||||
});
|
||||
|
||||
test('uses the synthetic roaming home when APPDATA is unset, empty, or blank', async () => {
|
||||
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'fallback-opera' },
|
||||
]);
|
||||
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera GX Stable', 'Default', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'fallback-gx' },
|
||||
]);
|
||||
writeCookies(home, path.join('AppData', 'Local', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'local-decoy' },
|
||||
]);
|
||||
for (const value of [undefined, '', ' ']) {
|
||||
if (value === undefined) delete process.env.APPDATA;
|
||||
else process.env.APPDATA = value;
|
||||
expect((await importCookies('opera', ['chosen.test'])).cookies[0].value).toBe('fallback-opera');
|
||||
expect((await importCookies('opera-gx', ['chosen.test'])).cookies[0].value).toBe('fallback-gx');
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps Chrome, Chromium, Edge, and Brave on the Local root when APPDATA is overridden', async () => {
|
||||
const roaming = tempRoot('cookie-local-root-');
|
||||
process.env.APPDATA = roaming;
|
||||
const cases = [
|
||||
['chrome', path.join('AppData', 'Local', 'Google', 'Chrome', 'User Data', 'Default'), path.join('Google', 'Chrome', 'User Data', 'Default')],
|
||||
['chromium', path.join('AppData', 'Local', 'Chromium', 'User Data', 'Default'), path.join('Chromium', 'User Data', 'Default')],
|
||||
['brave', path.join('AppData', 'Local', 'BraveSoftware', 'Brave-Browser', 'User Data', 'Default'), path.join('BraveSoftware', 'Brave-Browser', 'User Data', 'Default')],
|
||||
['edge', path.join('AppData', 'Local', 'Microsoft', 'Edge', 'User Data', 'Default'), path.join('Microsoft', 'Edge', 'User Data', 'Default')],
|
||||
] as const;
|
||||
for (const [alias, localDir, roamingDir] of cases) {
|
||||
writeCookies(home, localDir, [{ domain: '.chosen.test', name: 'keep', value: `${alias}-local` }]);
|
||||
writeCookies(roaming, roamingDir, [{ domain: '.chosen.test', name: 'keep', value: `${alias}-roaming-decoy` }]);
|
||||
expect((await importCookies(alias, ['chosen.test'])).cookies[0].value).toBe(`${alias}-local`);
|
||||
}
|
||||
expect(findInstalledBrowsers().map(browser => browser.name)).toEqual(expect.arrayContaining(['Chrome', 'Chromium', 'Brave', 'Edge']));
|
||||
});
|
||||
|
||||
test('prefers current Opera Local State names for a numbered profile with no Default', () => {
|
||||
const root = path.join(home, 'AppData', 'Roaming', 'Opera Software', 'Opera Stable');
|
||||
for (const name of ['Profile 10', 'Profile 2']) {
|
||||
const dir = writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', name, 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'numbered' },
|
||||
]);
|
||||
fs.writeFileSync(path.join(dir, '..', 'Preferences'), JSON.stringify({ profile: { name: 'Old name' } }));
|
||||
}
|
||||
fs.writeFileSync(path.join(root, 'Local State'), JSON.stringify({ profile: { info_cache: { 'Profile 2': { name: 'Current Opera name' } } } }));
|
||||
expect(listProfiles('opera')).toEqual([
|
||||
{ name: 'Profile 2', displayName: 'Current Opera name' },
|
||||
{ name: 'Profile 10', displayName: 'Old name' },
|
||||
]);
|
||||
expect(findInstalledBrowsers().map(browser => browser.name)).toContain('Opera');
|
||||
expect(listProfiles('opera').some(profile => profile.name === 'Default')).toBe(false);
|
||||
});
|
||||
|
||||
test('malformed Opera metadata preserves the directory identity', () => {
|
||||
const dir = writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera GX Stable', 'Default', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'gx-plain' },
|
||||
]);
|
||||
fs.writeFileSync(path.join(home, 'AppData', 'Roaming', 'Opera Software', 'Opera GX Stable', 'Local State'), '{');
|
||||
fs.writeFileSync(path.join(dir, '..', 'Preferences'), '{');
|
||||
expect(listProfiles('opera-gx')).toEqual([{ name: 'Default', displayName: 'Default' }]);
|
||||
});
|
||||
|
||||
test('reports missing Opera browsers and profiles and rejects profile traversal', () => {
|
||||
let error: any;
|
||||
try { listDomains('opera'); } catch (caught) { error = caught; }
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect(error.code).toBe('not_installed');
|
||||
expect(error.message).toContain(path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable'));
|
||||
expect(error.message).not.toContain(path.join('AppData', 'Local'));
|
||||
expect(error.message).not.toContain('User Data');
|
||||
expect(error.message).not.toContain('fixture-secret-sentinel');
|
||||
|
||||
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'present' },
|
||||
]);
|
||||
try { listDomains('Opera GX', 'Profile 8'); } catch (caught) { error = caught; }
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect(error.code).toBe('not_installed');
|
||||
expect(error.message).toContain('Opera GX');
|
||||
expect(error.message).not.toContain('fixture-secret-sentinel');
|
||||
|
||||
expect(() => listDomains('opera', '../etc')).toThrow(/Invalid profile/);
|
||||
expect(() => listDomains('opera-gx', 'Default/../../etc')).toThrow(/Invalid profile/);
|
||||
expect(() => listDomains('Opera GX', 'Profile\x001')).toThrow(/Invalid profile/);
|
||||
expect(() => listDomains('opera', 'Default\\Network')).toThrow(/Invalid profile/);
|
||||
});
|
||||
|
||||
test('prefers Network/Cookies and still falls back to a profile-level Cookies file', async () => {
|
||||
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', 'Default'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'profile-level' },
|
||||
]);
|
||||
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'network-level' },
|
||||
]);
|
||||
expect((await importCookies('opera', ['chosen.test'])).cookies.map(cookie => cookie.value)).toEqual(['network-level']);
|
||||
|
||||
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera GX Stable', 'Default'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'profile-fallback' },
|
||||
]);
|
||||
expect((await importCookies('opera-gx', ['chosen.test'])).cookies.map(cookie => cookie.value)).toEqual(['profile-fallback']);
|
||||
});
|
||||
|
||||
test('does not treat historical browser-root Opera cookie files as profiles', () => {
|
||||
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'root-cookies' },
|
||||
]);
|
||||
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'root-network' },
|
||||
]);
|
||||
expect(findInstalledBrowsers().map(browser => browser.name)).not.toContain('Opera');
|
||||
expect(listProfiles('opera')).toEqual([]);
|
||||
let error: any;
|
||||
try { listDomains('opera'); } catch (caught) { error = caught; }
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect(error.code).toBe('not_installed');
|
||||
});
|
||||
|
||||
test('reports v20 rows as unsupported encryption without invoking native extraction', async () => {
|
||||
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'plain', value: 'visible-value' },
|
||||
{ domain: '.chosen.test', name: 'bound', value: '', encrypted: Buffer.from('v20synthetic') },
|
||||
]);
|
||||
let spawns = 0;
|
||||
Bun.spawn = ((command: string[]) => {
|
||||
spawns++;
|
||||
throw new Error(`unexpected subprocess ${command[0]}`);
|
||||
}) as typeof Bun.spawn;
|
||||
const result = await importCookies('opera', ['chosen.test']);
|
||||
expect(spawns).toBe(0);
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.failed).toBe(1);
|
||||
expect(result.failureReasons).toEqual({ unsupported_encryption: 1 });
|
||||
expect(result.cookies.map(cookie => cookie.value)).toEqual(['visible-value']);
|
||||
expect(JSON.stringify(result)).not.toContain('v20synthetic');
|
||||
});
|
||||
|
||||
test('decrypts distinct Opera and Opera GX v10 keys and preserves typed key failures', async () => {
|
||||
const sentinel = 'fixture-secret-sentinel';
|
||||
const operaKey = Buffer.alloc(32, 0x31);
|
||||
const gxKey = Buffer.alloc(32, 0x32);
|
||||
const operaMaterial = Buffer.from('opera-dpapi-material');
|
||||
const gxMaterial = Buffer.from('gx-dpapi-material');
|
||||
const operaPlaintext = 'opera-session-value';
|
||||
const gxPlaintext = 'gx-session-value';
|
||||
const operaRoot = path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable');
|
||||
const gxRoot = path.join('AppData', 'Roaming', 'Opera Software', 'Opera GX Stable');
|
||||
writeCookies(home, path.join(operaRoot, 'Default', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', encrypted: windowsV10Cookie(operaKey, operaPlaintext) },
|
||||
{ domain: '.other.test', name: 'skip', encrypted: windowsV10Cookie(operaKey, 'opera-other-value') },
|
||||
]);
|
||||
writeCookies(home, path.join(gxRoot, 'Profile 1', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', encrypted: windowsV10Cookie(gxKey, gxPlaintext) },
|
||||
]);
|
||||
writeCookies(home, path.join('AppData', 'Local', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'local-decoy' },
|
||||
]);
|
||||
fs.writeFileSync(path.join(home, operaRoot, 'Local State.bak'), sentinel);
|
||||
writeDpapiState(home, path.join('AppData', 'Local', 'Opera Software', 'Opera Stable'), Buffer.from('local-decoy-material'));
|
||||
|
||||
let error: any;
|
||||
try { await importCookies('opera', ['chosen.test']); } catch (caught) { error = caught; }
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect(error.code).toBe('keychain_error');
|
||||
expect(error.message).toBe('Cannot read Local State for Opera');
|
||||
expect(error.message).not.toContain(sentinel);
|
||||
expect(error.message).not.toContain(operaPlaintext);
|
||||
expect(error.message).not.toContain(operaMaterial.toString());
|
||||
|
||||
writeDpapiState(home, gxRoot, gxMaterial);
|
||||
Bun.spawn = ((command: string[]) => {
|
||||
expect(command[0]).toBe('powershell');
|
||||
expect(command).toContain('-NoProfile');
|
||||
return {
|
||||
stdin: { write(_value: string) {}, end() {} },
|
||||
stdout: closedStream(),
|
||||
stderr: closedStream(sentinel),
|
||||
exited: Promise.resolve(1),
|
||||
kill() {},
|
||||
};
|
||||
}) as typeof Bun.spawn;
|
||||
try { await importCookies('opera-gx', ['chosen.test'], 'Profile 1'); } catch (caught) { error = caught; }
|
||||
expect(error).toBeInstanceOf(CookieImportError);
|
||||
expect(error.code).toBe('keychain_error');
|
||||
expect(error.message).toBe('DPAPI decryption failed');
|
||||
expect(error.message).not.toContain(sentinel);
|
||||
expect(error.message).not.toContain(gxPlaintext);
|
||||
expect(error.message).not.toContain(gxMaterial.toString());
|
||||
expect(error.message).not.toContain(gxKey.toString('base64'));
|
||||
|
||||
writeDpapiState(home, operaRoot, operaMaterial);
|
||||
const submitted: string[] = [];
|
||||
const keys = new Map<string, Buffer>([
|
||||
[operaMaterial.toString('base64'), operaKey],
|
||||
[gxMaterial.toString('base64'), gxKey],
|
||||
]);
|
||||
Bun.spawn = ((command: string[]) => {
|
||||
expect(command[0]).toBe('powershell');
|
||||
expect(command).toContain('-NoProfile');
|
||||
let pending = '';
|
||||
let controller!: ReadableStreamDefaultController<Uint8Array>;
|
||||
const stdout = new ReadableStream<Uint8Array>({ start(value) { controller = value; } });
|
||||
return {
|
||||
stdin: {
|
||||
write(value: string) { pending += value; },
|
||||
end() {
|
||||
submitted.push(pending);
|
||||
const key = keys.get(pending);
|
||||
controller.enqueue(Buffer.from((key ?? Buffer.alloc(0)).toString('base64')));
|
||||
controller.close();
|
||||
},
|
||||
},
|
||||
stdout,
|
||||
stderr: closedStream(),
|
||||
exited: Promise.resolve(0),
|
||||
kill() { throw new Error('Unexpected kill'); },
|
||||
};
|
||||
}) as typeof Bun.spawn;
|
||||
const opera = await importCookies('opera', ['chosen.test']);
|
||||
const gx = await importCookies('Opera GX', ['chosen.test'], 'Profile 1');
|
||||
expect(submitted).toEqual([operaMaterial.toString('base64'), gxMaterial.toString('base64')]);
|
||||
expect(opera.failed).toBe(0);
|
||||
expect(gx.failed).toBe(0);
|
||||
expect(opera.cookies.map(cookie => cookie.value)).toEqual([operaPlaintext]);
|
||||
expect(gx.cookies.map(cookie => cookie.value)).toEqual([gxPlaintext]);
|
||||
expect((await importCookies('opera', ['other.test'])).cookies.map(cookie => cookie.value)).toEqual(['opera-other-value']);
|
||||
});
|
||||
|
||||
test('strips the SHA-256(host_key) prefix from Windows v10 values only when it matches', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' });
|
||||
const cases = [
|
||||
{ alias: 'opera', key: Buffer.alloc(32, 0x31), dir: path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable'), material: 'opera-dpapi-material' },
|
||||
{ alias: 'chrome', key: Buffer.alloc(32, 0x33), dir: path.join('AppData', 'Local', 'Google', 'Chrome', 'User Data'), material: 'chrome-dpapi-material' },
|
||||
];
|
||||
for (const { alias, key, dir, material } of cases) {
|
||||
const hash = crypto.createHash('sha256').update('.chosen.test').digest();
|
||||
writeCookies(home, path.join(dir, 'Default', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'bare', encrypted: windowsV10Cookie(key, 'bare-value') },
|
||||
{ domain: '.chosen.test', name: 'prefixed', encrypted: windowsV10Cookie(key, Buffer.concat([hash, Buffer.from('prefixed-value')])) },
|
||||
]);
|
||||
writeDpapiState(home, dir, Buffer.from(material));
|
||||
Bun.spawn = ((command: string[]) => {
|
||||
expect(command[0]).toBe('powershell');
|
||||
return { stdin: { write() {}, end() {} }, stdout: closedStream(key.toString('base64')), stderr: closedStream(), exited: Promise.resolve(0), kill() {} };
|
||||
}) as typeof Bun.spawn;
|
||||
const result = await importCookies(alias, ['chosen.test']);
|
||||
expect(result.failed).toBe(0);
|
||||
expect(Object.fromEntries(result.cookies.map(cookie => [cookie.name, cookie.value]))).toEqual({ bare: 'bare-value', prefixed: 'prefixed-value' });
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps a 32-byte lead that is not the host digest', async () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' });
|
||||
const key = Buffer.alloc(32, 0x31);
|
||||
const dir = path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable');
|
||||
const value = 'x'.repeat(32) + '-tail';
|
||||
writeCookies(home, path.join(dir, 'Default', 'Network'), [{ domain: '.chosen.test', name: 'long', encrypted: windowsV10Cookie(key, value) }]);
|
||||
writeDpapiState(home, dir, Buffer.from('opera-dpapi-material'));
|
||||
Bun.spawn = (() => ({ stdin: { write() {}, end() {} }, stdout: closedStream(key.toString('base64')), stderr: closedStream(), exited: Promise.resolve(0), kill() {} })) as unknown as typeof Bun.spawn;
|
||||
expect((await importCookies('opera', ['chosen.test'])).cookies.map(cookie => cookie.value)).toEqual([value]);
|
||||
});
|
||||
|
||||
test('resolves Opera GX aliases and lists typeable tokens for the host OS', () => {
|
||||
for (const alias of ['operagx', 'opera-gx', 'Opera GX', 'opera gx']) expect(resolveBrowserInfo(alias).name).toBe('Opera GX');
|
||||
const unknown = (platform: string) => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: platform });
|
||||
try { resolveBrowserInfo('firefox'); } catch (error: any) { return error; }
|
||||
throw new Error('expected unknown_browser');
|
||||
};
|
||||
const win = unknown('win32');
|
||||
expect(win.code).toBe('unknown_browser');
|
||||
const winSupported = win.message.split('Supported on this OS: ')[1].split('. All names:')[0];
|
||||
expect(winSupported).toContain('Opera GX (opera-gx)');
|
||||
expect(winSupported).toContain('Opera (opera)');
|
||||
for (const platform of ['darwin', 'linux']) {
|
||||
const supported = unknown(platform).message.split('Supported on this OS: ')[1].split('. All names:')[0];
|
||||
expect(supported).not.toContain('Opera');
|
||||
expect(unknown(platform).message).toContain('All names: comet');
|
||||
}
|
||||
});
|
||||
|
||||
test('names the supported OS for host-unsupported browsers', () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' });
|
||||
let error: any;
|
||||
try { listDomains('arc'); } catch (caught) { error = caught; }
|
||||
expect(error.code).toBe('not_installed');
|
||||
expect(error.message.startsWith('Arc cookie import is available on macOS only.')).toBe(true);
|
||||
expect(error.message).toContain('~/Library/Application Support/Arc/User Data');
|
||||
expect(error.message).toContain('Browsers available on this OS: Chrome (chrome), Chromium (chromium), Brave (brave), Edge (edge).');
|
||||
});
|
||||
|
||||
test('distinguishes a missing profile from a missing browser', () => {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' });
|
||||
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'present' },
|
||||
]);
|
||||
let error: any;
|
||||
try { listDomains('opera', 'Profile 8'); } catch (caught) { error = caught; }
|
||||
expect(error.code).toBe('not_installed');
|
||||
expect(error.message.startsWith("Opera profile 'Profile 8' not found. Available: Default.")).toBe(true);
|
||||
expect(error.message).not.toContain('Supported layout');
|
||||
});
|
||||
|
||||
test('ignores a relative APPDATA and falls back to the home Roaming root', async () => {
|
||||
process.env.APPDATA = 'relative\\Roaming';
|
||||
writeCookies(home, path.join('AppData', 'Roaming', 'Opera Software', 'Opera Stable', 'Default', 'Network'), [
|
||||
{ domain: '.chosen.test', name: 'keep', value: 'home-roaming' },
|
||||
]);
|
||||
expect((await importCookies('opera', ['chosen.test'])).cookies.map(cookie => cookie.value)).toEqual(['home-roaming']);
|
||||
});
|
||||
|
||||
test('windowsNative matches exactly the browsers the native extractor maps', () => {
|
||||
const env = { LOCALAPPDATA: 'C:\\Users\\fixture\\AppData\\Local' };
|
||||
for (const browser of ['Comet', 'Chrome', 'Chromium', 'Arc', 'Dia', 'Brave', 'Edge', 'Opera', 'Opera GX']) {
|
||||
let mapped = true;
|
||||
try { nativeBrowserPaths(browser, env); } catch { mapped = false; }
|
||||
expect(resolveBrowserInfo(browser).windowsNative === true).toBe(mapped);
|
||||
}
|
||||
});
|
||||
|
||||
test('browser lists in docs and the command description name every registry browser', () => {
|
||||
const names = new Set<string>();
|
||||
for (const value of ['darwin', 'linux', 'win32']) {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value });
|
||||
for (const name of listSupportedBrowserNames()) names.add(name);
|
||||
}
|
||||
const repo = path.resolve(import.meta.dir, '../..');
|
||||
const read = (file: string) => fs.readFileSync(path.join(repo, file), 'utf8');
|
||||
const line = (file: string, anchor: string) => {
|
||||
const found = read(file).split('\n').find(entry => entry.includes(anchor));
|
||||
expect(found).toBeDefined();
|
||||
return found!;
|
||||
};
|
||||
const lists = {
|
||||
'commands.ts': line('browse/src/commands.ts', "'cookie-import-browser':"),
|
||||
'README row': line('README.md', '| `/setup-browser-cookies` |'),
|
||||
'BROWSER.md sessions row': line('BROWSER.md', '| Your sessions are already there |'),
|
||||
'BROWSER.md picker': line('BROWSER.md', 'The picker recognizes'),
|
||||
'docs/skills.md row': line('docs/skills.md', '| [`/setup-browser-cookies`]'),
|
||||
'docs/skills.md picker': line('docs/skills.md', 'The picker detects'),
|
||||
'ARCHITECTURE.md registry': line('ARCHITECTURE.md', 'The browser registry ('),
|
||||
};
|
||||
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
for (const name of names) {
|
||||
const token = resolveBrowserInfo(name).aliases[0];
|
||||
const pattern = new RegExp(`\\b(?:${escape(name)}|${escape(token)})\\b(?![- ]gx)`, 'i');
|
||||
for (const [label, text] of Object.entries(lists)) {
|
||||
expect({ label, name, listed: pattern.test(text) }).toEqual({ label, name, listed: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user