From c495fb5078791ed621e02b73a5632ba668464d1a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 27 Dec 2025 13:23:39 +0100 Subject: [PATCH] refactor(cookies): use sweet-cookie --- .npmrc | 1 + README.md | 2 +- package.json | 1 + pnpm-lock.yaml | 16 +- src/lib/cookies.ts | 605 ++++++++---------------------------------- tests/cookies.test.ts | 303 +++++++-------------- 6 files changed, 217 insertions(+), 711 deletions(-) create mode 100644 .npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..f301fed --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +auto-install-peers=false diff --git a/README.md b/README.md index ada3730..231976b 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ Write operations: 1. CLI flags: `--auth-token`, `--ct0` 2. Environment variables: `AUTH_TOKEN`, `CT0` (fallback: `TWITTER_AUTH_TOKEN`, `TWITTER_CT0`) -3. Browser cookies (macOS): Safari, Chrome, Firefox (override via `--cookie-source` order) +3. Browser cookies via `@steipete/sweet-cookie` (override via `--cookie-source` order) Browser cookie sources: - Safari: `~/Library/Cookies/Cookies.binarycookies` (fallback: `~/Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies`) diff --git a/package.json b/package.json index 1034802..12d1ac6 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "graphql:update": "tsx scripts/update-query-ids.ts" }, "dependencies": { + "@steipete/sweet-cookie": "file:../sweet-cookie/packages/core", "commander": "^14.0.2", "json5": "^2.2.3", "kleur": "^4.1.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 114e339..0c06247 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,13 +1,16 @@ lockfileVersion: '9.0' settings: - autoInstallPeers: true + autoInstallPeers: false excludeLinksFromLockfile: false importers: .: dependencies: + '@steipete/sweet-cookie': + specifier: file:../sweet-cookie/packages/core + version: file:../sweet-cookie/packages/core commander: specifier: ^14.0.2 version: 14.0.2 @@ -392,6 +395,15 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@steipete/sweet-cookie@file:../sweet-cookie/packages/core': + resolution: {directory: ../sweet-cookie/packages/core, type: directory} + engines: {node: '>=22'} + peerDependencies: + chrome-cookies-secure: ^3.0.0 + peerDependenciesMeta: + chrome-cookies-secure: + optional: true + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -912,6 +924,8 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@steipete/sweet-cookie@file:../sweet-cookie/packages/core': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 diff --git a/src/lib/cookies.ts b/src/lib/cookies.ts index bc03729..4c0556a 100644 --- a/src/lib/cookies.ts +++ b/src/lib/cookies.ts @@ -1,13 +1,9 @@ /** - * Browser cookie extraction for Twitter authentication - * Uses sqlite3 CLI where possible and binarycookies parsing for Safari. + * Browser cookie extraction for Twitter authentication. + * Delegates to @steipete/sweet-cookie for Safari/Chrome/Firefox reads. */ -import { execSync } from 'node:child_process'; -import { createDecipheriv, pbkdf2Sync } from 'node:crypto'; -import { copyFileSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { getCookies } from '@steipete/sweet-cookie'; export interface TwitterCookies { authToken: string | null; @@ -23,457 +19,126 @@ export interface CookieExtractionResult { export type CookieSource = 'safari' | 'chrome' | 'firefox'; +const TWITTER_COOKIE_NAMES = ['auth_token', 'ct0'] as const; +const TWITTER_URL = 'https://x.com/'; +const TWITTER_ORIGINS = ['https://x.com/', 'https://twitter.com/']; + function normalizeValue(value: unknown): string | null { - if (typeof value === 'string') { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; - } - return null; + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; } -function getChromeCookiesPath(profile?: string): string { - const home = process.env.HOME || ''; - const profileDir = profile || 'Default'; - return join(home, 'Library', 'Application Support', 'Google', 'Chrome', profileDir, 'Cookies'); +function cookieHeader(authToken: string, ct0: string): string { + return `auth_token=${authToken}; ct0=${ct0}`; } -function getFirefoxProfilesRoot(): string | null { - const home = process.env.HOME || ''; - if (process.platform === 'darwin') { - return join(home, 'Library', 'Application Support', 'Firefox', 'Profiles'); - } - if (process.platform === 'linux') { - return join(home, '.mozilla', 'firefox'); - } - if (process.platform === 'win32') { - const appData = process.env.APPDATA; - if (!appData) return null; - return join(appData, 'Mozilla', 'Firefox', 'Profiles'); - } - return null; +function buildEmpty(): TwitterCookies { + return { authToken: null, ct0: null, cookieHeader: null, source: null }; } -function pickFirefoxProfile(profilesRoot: string, profile?: string): string | null { - if (profile) { - const candidate = join(profilesRoot, profile, 'cookies.sqlite'); - return existsSync(candidate) ? candidate : null; - } - - const entries = readdirSync(profilesRoot, { withFileTypes: true }); - const defaultRelease = entries.find((entry) => entry.isDirectory() && entry.name.includes('default-release')); - const targetDir = defaultRelease?.name ?? entries.find((e) => e.isDirectory())?.name; - if (!targetDir) return null; - - const candidate = join(profilesRoot, targetDir, 'cookies.sqlite'); - return existsSync(candidate) ? candidate : null; -} - -function getFirefoxCookiesPath(profile?: string): string | null { - const profilesRoot = getFirefoxProfilesRoot(); - if (!profilesRoot || !existsSync(profilesRoot)) return null; - return pickFirefoxProfile(profilesRoot, profile); -} - -function getSafariCookiesPath(): string | null { - if (process.platform !== 'darwin') return null; - const home = process.env.HOME || ''; - const candidates = [ - join(home, 'Library', 'Cookies', 'Cookies.binarycookies'), - join(home, 'Library', 'Containers', 'com.apple.Safari', 'Data', 'Library', 'Cookies', 'Cookies.binarycookies'), - ]; - for (const candidate of candidates) { - if (existsSync(candidate)) return candidate; - } - return null; -} - -const SAFARI_COOKIE_DOMAINS = ['x.com', 'twitter.com']; -const SAFARI_PAGE_SIGNATURE = Buffer.from([0x00, 0x00, 0x01, 0x00]); - -function matchesSafariDomain(domain: string | null): boolean { - if (!domain) return false; - const normalized = (domain.startsWith('.') ? domain.slice(1) : domain).toLowerCase(); - return SAFARI_COOKIE_DOMAINS.some((target) => normalized === target || normalized.endsWith(`.${target}`)); -} - -function readSafariCString(buffer: Buffer, start: number, end: number): string | null { - if (start < 0 || start >= end) return null; - let cursor = start; - while (cursor < end && buffer[cursor] !== 0) cursor += 1; - if (cursor >= end) return null; - return buffer.toString('utf8', start, cursor); -} - -function serializeCookieJar(jar: Record): string { - const entries = Object.entries(jar) - .filter(([, value]) => typeof value === 'string' && value.length > 0) - .sort(([a], [b]) => a.localeCompare(b)); - return entries.map(([name, value]) => `${name}=${value}`).join('; '); -} - -function parseSafariCookieRecord( - page: Buffer, - offset: number, - jar: Record, - cookies: TwitterCookies, -): void { - if (offset < 0 || offset + 4 > page.length) return; - const recordSize = page.readUInt32LE(offset); - const recordEnd = offset + recordSize; - if (recordSize <= 0 || recordEnd > page.length) return; - - const headerStart = offset + 4; - const headerSize = 4 + 4 + 4 + 4 + 4 + 4 + 4; // unknown1 + flags + unknown2 + domain + name + path + value - if (headerStart + headerSize > recordEnd) return; - - const domainOffset = page.readUInt32LE(headerStart + 12); - const nameOffset = page.readUInt32LE(headerStart + 16); - const valueOffset = page.readUInt32LE(headerStart + 24); - - const domain = readSafariCString(page, offset + domainOffset, recordEnd); - const name = readSafariCString(page, offset + nameOffset, recordEnd); - const value = readSafariCString(page, offset + valueOffset, recordEnd); - - if (!name || !value || !matchesSafariDomain(domain)) return; - - const normalizedValue = normalizeValue(value); - if (!normalizedValue) return; - - jar[name] = normalizedValue; - - if (name === 'auth_token' && !cookies.authToken) { - cookies.authToken = normalizedValue; - } else if (name === 'ct0' && !cookies.ct0) { - cookies.ct0 = normalizedValue; +function readEnvCookie(cookies: TwitterCookies, keys: readonly string[], field: 'authToken' | 'ct0'): void { + if (cookies[field]) return; + for (const key of keys) { + const value = normalizeValue(process.env[key]); + if (!value) continue; + cookies[field] = value; + if (!cookies.source) cookies.source = `env ${key}`; + break; } } -function parseSafariCookiePage(page: Buffer, jar: Record, cookies: TwitterCookies): void { - if (page.length < 12) return; - if (!page.subarray(0, 4).equals(SAFARI_PAGE_SIGNATURE)) return; - const cookieCount = page.readUInt32LE(4); - if (!cookieCount) return; - - const offsets: number[] = []; - let cursor = 8; - for (let i = 0; i < cookieCount; i += 1) { - if (cursor + 4 > page.length) return; - offsets.push(page.readUInt32LE(cursor)); - cursor += 4; - } - - for (const offset of offsets) { - parseSafariCookieRecord(page, offset, jar, cookies); - } +function resolveSources(cookieSource?: CookieSource | CookieSource[]): CookieSource[] { + if (Array.isArray(cookieSource)) return cookieSource; + if (cookieSource) return [cookieSource]; + return ['safari', 'chrome', 'firefox']; } -function parseSafariCookies(data: Buffer, jar: Record, cookies: TwitterCookies): void { - if (data.length < 8) return; - if (data.subarray(0, 4).toString('utf8') !== 'cook') return; - const pageCount = data.readUInt32BE(4); - let cursor = 8; - const pageSizes: number[] = []; - for (let i = 0; i < pageCount; i += 1) { - if (cursor + 4 > data.length) return; - pageSizes.push(data.readUInt32BE(cursor)); - cursor += 4; - } - - for (const pageSize of pageSizes) { - if (cursor + pageSize > data.length) return; - const page = data.subarray(cursor, cursor + pageSize); - parseSafariCookiePage(page, jar, cookies); - cursor += pageSize; +function labelForSource(source: CookieSource, profile?: string): string { + if (source === 'safari') return 'Safari'; + if (source === 'chrome') { + return profile ? `Chrome profile "${profile}"` : 'Chrome default profile'; } + return profile ? `Firefox profile "${profile}"` : 'Firefox default profile'; } -/** - * Extract Twitter cookies from Safari browser using Cookies.binarycookies - */ -export async function extractCookiesFromSafari(): Promise { +function pickCookieValue( + cookies: Array<{ name?: string; value?: string; domain?: string }>, + name: (typeof TWITTER_COOKIE_NAMES)[number], +): string | null { + const matches = cookies.filter((c) => c?.name === name && typeof c.value === 'string'); + if (matches.length === 0) return null; + + const preferred = matches.find((c) => (c.domain ?? '').endsWith('x.com')); + if (preferred?.value) return preferred.value; + + const twitter = matches.find((c) => (c.domain ?? '').endsWith('twitter.com')); + if (twitter?.value) return twitter.value; + + return matches[0]?.value ?? null; +} + +async function readTwitterCookiesFromBrowser(options: { + source: CookieSource; + chromeProfile?: string; + firefoxProfile?: string; +}): Promise { const warnings: string[] = []; - const cookies: TwitterCookies = { - authToken: null, - ct0: null, - cookieHeader: null, - source: null, - }; + const out = buildEmpty(); - const cookiesPath = getSafariCookiesPath(); - if (!cookiesPath) { - warnings.push('Safari cookies database not found.'); - return { cookies, warnings }; + const { cookies, warnings: providerWarnings } = await getCookies({ + url: TWITTER_URL, + origins: TWITTER_ORIGINS, + names: [...TWITTER_COOKIE_NAMES], + browsers: [options.source], + mode: 'merge', + chromeProfile: options.chromeProfile, + firefoxProfile: options.firefoxProfile, + }); + warnings.push(...providerWarnings); + + const authToken = pickCookieValue(cookies, 'auth_token'); + const ct0 = pickCookieValue(cookies, 'ct0'); + if (authToken) out.authToken = authToken; + if (ct0) out.ct0 = ct0; + + if (out.authToken && out.ct0) { + out.cookieHeader = cookieHeader(out.authToken, out.ct0); + out.source = labelForSource( + options.source, + options.source === 'chrome' ? options.chromeProfile : options.firefoxProfile, + ); + return { cookies: out, warnings }; } - let tempDir: string | null = null; - - try { - const jar: Record = {}; - tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-')); - const tempCookiesPath = join(tempDir, 'Cookies.binarycookies'); - copyFileSync(cookiesPath, tempCookiesPath); - const data = readFileSync(tempCookiesPath); - parseSafariCookies(data, jar, cookies); - if (Object.keys(jar).length > 0) { - cookies.cookieHeader = serializeCookieJar(jar); - } - - if (cookies.authToken || cookies.ct0) { - cookies.source = 'Safari'; - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - warnings.push(`Failed to read Safari cookies: ${message}`); - } finally { - if (tempDir) { - try { - rmSync(tempDir, { recursive: true, force: true }); - } catch { - // ignore cleanup errors - } - } - } - - if (!cookies.authToken && !cookies.ct0) { + if (options.source === 'safari') { warnings.push('No Twitter cookies found in Safari. Make sure you are logged into x.com in Safari.'); - } - - return { cookies, warnings }; -} - -/** - * Decrypt Chrome cookie value using macOS keychain - * Chrome encrypts cookies with a key stored in the keychain - */ -function decryptCookieValue(encryptedHex: string): string | null { - try { - // Convert hex to buffer - const encryptedValue = Buffer.from(encryptedHex, 'hex'); - - if (encryptedValue.length < 4) { - return null; - } - - const version = encryptedValue.subarray(0, 3).toString('utf8'); - if (version !== 'v10' && version !== 'v11') { - // Not encrypted, just return as string - return encryptedValue.toString('utf8'); - } - - // Get encryption key from keychain - const keyOutput = execSync('security find-generic-password -s "Chrome Safe Storage" -w 2>/dev/null || echo ""', { - encoding: 'utf8', - }).trim(); - - if (!keyOutput) { - return null; - } - - // Derive the key using PBKDF2 - const salt = 'saltysalt'; - const iterations = 1003; - const keyLength = 16; - - const derivedKey = pbkdf2Sync(keyOutput, salt, iterations, keyLength, 'sha1'); - - // Decrypt using AES-128-CBC with empty IV (16 bytes of 0x20/space) - const iv = Buffer.alloc(16, 0x20); - const encryptedData = encryptedValue.subarray(3); // Skip "v10" or "v11" prefix - - const decipher = createDecipheriv('aes-128-cbc', derivedKey, iv); - decipher.setAutoPadding(true); - - let decrypted = decipher.update(encryptedData); - decrypted = Buffer.concat([decrypted, decipher.final()]); - - // Chrome v10 cookies have key material prepended before the actual value - // Twitter cookies are hex strings, so extract the longest hex sequence - const decryptedStr = decrypted.toString('utf8'); - const hexMatch = decryptedStr.match(/[a-f0-9]{32,}/i); - if (hexMatch) { - return hexMatch[0]; - } - // Fallback: keep only printable ASCII characters - return decryptedStr.replace(/[^\x20-\x7E]/g, ''); - } catch { - return null; - } -} - -/** - * Extract Twitter cookies from Chrome browser using sqlite3 CLI - * @param profile - Chrome profile name (optional, uses Default if not specified) - */ -export async function extractCookiesFromChrome(profile?: string): Promise { - const warnings: string[] = []; - const cookies: TwitterCookies = { - authToken: null, - ct0: null, - cookieHeader: null, - source: null, - }; - - const cookiesPath = getChromeCookiesPath(profile); - - if (!existsSync(cookiesPath)) { - warnings.push(`Chrome cookies database not found at: ${cookiesPath}`); - return { cookies, warnings }; - } - - // Chrome locks the database, so we need to copy it - let tempDir: string | null = null; - - try { - tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-')); - const tempDbPath = join(tempDir, 'Cookies'); - copyFileSync(cookiesPath, tempDbPath); - - // Also copy the WAL and SHM files if they exist - const walPath = `${cookiesPath}-wal`; - const shmPath = `${cookiesPath}-shm`; - if (existsSync(walPath)) { - copyFileSync(walPath, `${tempDbPath}-wal`); - } - if (existsSync(shmPath)) { - copyFileSync(shmPath, `${tempDbPath}-shm`); - } - - const jar: Record = {}; - - // Use sqlite3 CLI to query cookies (no native deps required!) - const query = `SELECT name, hex(encrypted_value) as encrypted_hex FROM cookies WHERE host_key IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com');`; - - const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, { - encoding: 'utf8', - maxBuffer: 1024 * 1024, - }).trim(); - - if (result) { - for (const line of result.split('\n')) { - const [name, encryptedHex] = line.split('|'); - if (!name || !encryptedHex) continue; - - const decryptedValue = decryptCookieValue(encryptedHex); - if (decryptedValue) { - jar[name] = decryptedValue; - if (name === 'auth_token' && !cookies.authToken) { - cookies.authToken = decryptedValue; - } else if (name === 'ct0' && !cookies.ct0) { - cookies.ct0 = decryptedValue; - } - } - } - } - - if (Object.keys(jar).length > 0) { - cookies.cookieHeader = serializeCookieJar(jar); - } - - if (cookies.authToken || cookies.ct0) { - cookies.source = profile ? `Chrome profile "${profile}"` : 'Chrome default profile'; - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - warnings.push(`Failed to read Chrome cookies: ${message}`); - } finally { - // Cleanup temp files - if (tempDir) { - try { - rmSync(tempDir, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } - } - } - - if (!cookies.authToken && !cookies.ct0) { + } else if (options.source === 'chrome') { warnings.push('No Twitter cookies found in Chrome. Make sure you are logged into x.com in Chrome.'); - } - - return { cookies, warnings }; -} - -/** - * Extract Twitter cookies from Firefox browser using sqlite3 CLI - * @param profile - Firefox profile directory name (optional, auto-detected) - */ -export async function extractCookiesFromFirefox(profile?: string): Promise { - const warnings: string[] = []; - const cookies: TwitterCookies = { - authToken: null, - ct0: null, - cookieHeader: null, - source: null, - }; - - const cookiesPath = getFirefoxCookiesPath(profile); - - if (!cookiesPath) { - warnings.push('Firefox cookies database not found.'); - return { cookies, warnings }; - } - - let tempDir: string | null = null; - - try { - tempDir = mkdtempSync(join(tmpdir(), 'twitter-cli-')); - const tempDbPath = join(tempDir, 'cookies.sqlite'); - copyFileSync(cookiesPath, tempDbPath); - - const jar: Record = {}; - - const query = `SELECT name, value FROM moz_cookies WHERE host IN ('.x.com', '.twitter.com', 'x.com', 'twitter.com');`; - - const result = execSync(`sqlite3 -separator '|' "${tempDbPath}" "${query}"`, { - encoding: 'utf8', - maxBuffer: 1024 * 1024, - }).trim(); - - if (result) { - for (const line of result.split('\n')) { - const [name, value] = line.split('|'); - if (!name || !value) continue; - jar[name] = value; - if (name === 'auth_token' && !cookies.authToken) { - cookies.authToken = value; - } else if (name === 'ct0' && !cookies.ct0) { - cookies.ct0 = value; - } - } - } - - if (Object.keys(jar).length > 0) { - cookies.cookieHeader = serializeCookieJar(jar); - } - - if (cookies.authToken || cookies.ct0) { - cookies.source = profile ? `Firefox profile "${profile}"` : 'Firefox default profile'; - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - warnings.push(`Failed to read Firefox cookies: ${message}`); - } finally { - if (tempDir) { - try { - rmSync(tempDir, { recursive: true, force: true }); - } catch { - // ignore cleanup errors - } - } - } - - if (!cookies.authToken && !cookies.ct0) { + } else { warnings.push( 'No Twitter cookies found in Firefox. Make sure you are logged into x.com in Firefox and the profile exists.', ); } - return { cookies, warnings }; + return { cookies: out, warnings }; +} + +export async function extractCookiesFromSafari(): Promise { + return readTwitterCookiesFromBrowser({ source: 'safari' }); +} + +export async function extractCookiesFromChrome(profile?: string): Promise { + return readTwitterCookiesFromBrowser({ source: 'chrome', chromeProfile: profile }); +} + +export async function extractCookiesFromFirefox(profile?: string): Promise { + return readTwitterCookiesFromBrowser({ source: 'firefox', firefoxProfile: profile }); } /** - * Resolve Twitter credentials from multiple sources - * Priority: CLI args > environment variables > Safari > Chrome > Firefox + * Resolve Twitter credentials from multiple sources. + * Priority: CLI args > environment variables > browsers (ordered). */ export async function resolveCredentials(options: { authToken?: string; @@ -483,16 +148,8 @@ export async function resolveCredentials(options: { firefoxProfile?: string; }): Promise { const warnings: string[] = []; - const cookies: TwitterCookies = { - authToken: null, - ct0: null, - cookieHeader: null, - source: null, - }; + const cookies = buildEmpty(); - const cookieSource = options.cookieSource; - - // 1. CLI arguments (highest priority) if (options.authToken) { cookies.authToken = options.authToken; cookies.source = 'CLI argument'; @@ -502,72 +159,27 @@ export async function resolveCredentials(options: { if (!cookies.source) cookies.source = 'CLI argument'; } - // 2. Environment variables - const envAuthKeys = ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN']; - const envCt0Keys = ['CT0', 'TWITTER_CT0']; - - if (!cookies.authToken) { - for (const key of envAuthKeys) { - const value = normalizeValue(process.env[key]); - if (value) { - cookies.authToken = value; - cookies.source = `env ${key}`; - break; - } - } - } - - if (!cookies.ct0) { - for (const key of envCt0Keys) { - const value = normalizeValue(process.env[key]); - if (value) { - cookies.ct0 = value; - if (!cookies.source) cookies.source = `env ${key}`; - break; - } - } - } + readEnvCookie(cookies, ['AUTH_TOKEN', 'TWITTER_AUTH_TOKEN'], 'authToken'); + readEnvCookie(cookies, ['CT0', 'TWITTER_CT0'], 'ct0'); if (cookies.authToken && cookies.ct0) { - cookies.cookieHeader = `auth_token=${cookies.authToken}; ct0=${cookies.ct0}`; + cookies.cookieHeader = cookieHeader(cookies.authToken, cookies.ct0); return { cookies, warnings }; } - const sourcesToTry: CookieSource[] = Array.isArray(cookieSource) - ? cookieSource - : cookieSource - ? [cookieSource] - : ['safari', 'chrome', 'firefox']; - + const sourcesToTry = resolveSources(options.cookieSource); for (const source of sourcesToTry) { - if (source === 'safari') { - const safariResult = await extractCookiesFromSafari(); - warnings.push(...safariResult.warnings); - if (safariResult.cookies.authToken && safariResult.cookies.ct0) { - return { cookies: safariResult.cookies, warnings }; - } - continue; - } - - if (source === 'chrome') { - const chromeResult = await extractCookiesFromChrome(options.chromeProfile); - warnings.push(...chromeResult.warnings); - if (chromeResult.cookies.authToken && chromeResult.cookies.ct0) { - return { cookies: chromeResult.cookies, warnings }; - } - continue; - } - - if (source === 'firefox') { - const firefoxResult = await extractCookiesFromFirefox(options.firefoxProfile); - warnings.push(...firefoxResult.warnings); - if (firefoxResult.cookies.authToken && firefoxResult.cookies.ct0) { - return { cookies: firefoxResult.cookies, warnings }; - } + const res = await readTwitterCookiesFromBrowser({ + source, + chromeProfile: options.chromeProfile, + firefoxProfile: options.firefoxProfile, + }); + warnings.push(...res.warnings); + if (res.cookies.authToken && res.cookies.ct0) { + return { cookies: res.cookies, warnings }; } } - // Validation if (!cookies.authToken) { warnings.push( 'Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Safari/Chrome/Firefox', @@ -576,9 +188,8 @@ export async function resolveCredentials(options: { if (!cookies.ct0) { warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Safari/Chrome/Firefox'); } - if (cookies.authToken && cookies.ct0) { - cookies.cookieHeader = `auth_token=${cookies.authToken}; ct0=${cookies.ct0}`; + cookies.cookieHeader = cookieHeader(cookies.authToken, cookies.ct0); } return { cookies, warnings }; diff --git a/tests/cookies.test.ts b/tests/cookies.test.ts index 145c624..23d6ce8 100644 --- a/tests/cookies.test.ts +++ b/tests/cookies.test.ts @@ -1,98 +1,32 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -// Mock child_process.execSync to prevent actual shell commands -vi.mock('node:child_process', () => ({ - execSync: vi.fn(() => ''), +type SweetCookieResult = { cookies: Array<{ name: string; value: string; domain?: string }>; warnings: string[] }; + +const sweet = vi.hoisted(() => ({ + results: new Map(), })); -// Mock fs to prevent actual file operations -vi.mock('node:fs', () => { - const fs = vi.importActual('node:fs'); - return { - ...fs, - existsSync: vi.fn(() => false), - copyFileSync: vi.fn(), - mkdtempSync: vi.fn(() => '/tmp/test-dir'), - readdirSync: vi.fn(() => []), - readFileSync: vi.fn(() => Buffer.alloc(0)), - rmSync: vi.fn(), - }; -}); +vi.mock('@steipete/sweet-cookie', () => ({ + getCookies: vi.fn(async (options: { browsers?: string[] }) => { + const browser = options.browsers?.[0] ?? 'unknown'; + return ( + sweet.results.get(browser) ?? { + cookies: [], + warnings: [], + } + ); + }), +})); const itIfDarwin = process.platform === 'darwin' ? it : it.skip; -function buildSafariCookieRecord(input: { domain: string; name: string; value: string; path?: string }): Buffer { - const domain = Buffer.from(input.domain, 'utf8'); - const name = Buffer.from(input.name, 'utf8'); - const path = Buffer.from(input.path ?? '/', 'utf8'); - const value = Buffer.from(input.value, 'utf8'); - - const headerSize = 56; - const domainOffset = headerSize; - const nameOffset = domainOffset + domain.length + 1; - const pathOffset = nameOffset + name.length + 1; - const valueOffset = pathOffset + path.length + 1; - const recordSize = valueOffset + value.length + 1; - - const record = Buffer.alloc(recordSize); - record.writeUInt32LE(recordSize, 0); - record.writeUInt32LE(0, 4); - record.writeUInt32LE(0, 8); - record.writeUInt32LE(0, 12); - record.writeUInt32LE(domainOffset, 16); - record.writeUInt32LE(nameOffset, 20); - record.writeUInt32LE(pathOffset, 24); - record.writeUInt32LE(valueOffset, 28); - - domain.copy(record, domainOffset); - record[domainOffset + domain.length] = 0; - name.copy(record, nameOffset); - record[nameOffset + name.length] = 0; - path.copy(record, pathOffset); - record[pathOffset + path.length] = 0; - value.copy(record, valueOffset); - record[valueOffset + value.length] = 0; - - return record; -} - -function buildSafariCookiesFile(records: Buffer[]): Buffer { - const cookieCount = records.length; - const headerSize = 4 + 4 + 4 * cookieCount + 4; - const offsets: number[] = []; - let cursor = headerSize; - for (const record of records) { - offsets.push(cursor); - cursor += record.length; - } - - const pageSize = cursor; - const page = Buffer.alloc(pageSize); - page.writeUInt32BE(0x00000100, 0); - page.writeUInt32LE(cookieCount, 4); - offsets.forEach((offset, index) => { - page.writeUInt32LE(offset, 8 + index * 4); - }); - page.writeUInt32LE(0, 8 + cookieCount * 4); - offsets.forEach((offset, index) => { - records[index].copy(page, offset); - }); - - const header = Buffer.alloc(12); - header.write('cook', 0, 'ascii'); - header.writeUInt32BE(1, 4); - header.writeUInt32BE(pageSize, 8); - - return Buffer.concat([header, page]); -} - describe('cookies', () => { const originalEnv = process.env; beforeEach(() => { vi.resetModules(); + sweet.results.clear(); process.env = { ...originalEnv }; - // Clear Twitter-related env vars process.env.AUTH_TOKEN = undefined; process.env.TWITTER_AUTH_TOKEN = undefined; process.env.CT0 = undefined; @@ -106,58 +40,41 @@ describe('cookies', () => { describe('resolveCredentials', () => { it('honors cookieSource=firefox even when Safari has cookies', async () => { + sweet.results.set('safari', { + cookies: [ + { name: 'auth_token', value: 'safari_auth', domain: 'x.com' }, + { name: 'ct0', value: 'safari_ct0', domain: 'x.com' }, + ], + warnings: [], + }); + sweet.results.set('firefox', { + cookies: [ + { name: 'auth_token', value: 'firefox_auth', domain: 'x.com' }, + { name: 'ct0', value: 'firefox_ct0', domain: 'x.com' }, + ], + warnings: [], + }); + const { resolveCredentials } = await import('../src/lib/cookies.js'); - const fs = await import('node:fs'); - const { execSync } = await import('node:child_process'); - - // Safari cookie file exists + contains different cookies - (fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => { - const lower = path.toLowerCase(); - if (lower.endsWith('cookies.binarycookies')) return true; - if (lower.endsWith('cookies.sqlite')) return true; - if (lower.includes('firefox')) return true; - return false; - }); - (fs.readdirSync as unknown as vi.Mock).mockReturnValue([ - { isDirectory: () => true, name: 'abc.default-release' }, - ]); - (fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); - (fs.readFileSync as unknown as vi.Mock).mockReturnValue( - buildSafariCookiesFile([ - buildSafariCookieRecord({ domain: '.x.com', name: 'auth_token', value: 'safari_auth' }), - buildSafariCookieRecord({ domain: '.x.com', name: 'ct0', value: 'safari_ct0' }), - ]), - ); - - // Firefox sqlite3 extraction returns different cookies - (execSync as unknown as vi.Mock).mockImplementation((cmd: string) => { - if (cmd.includes('sqlite3')) return 'auth_token|firefox_auth\nct0|firefox_ct0'; - return ''; - }); - const result = await resolveCredentials({ cookieSource: 'firefox' }); + expect(result.cookies.authToken).toBe('firefox_auth'); expect(result.cookies.ct0).toBe('firefox_ct0'); expect(result.cookies.source).toContain('Firefox'); }); itIfDarwin('honors cookieSource=safari', async () => { + sweet.results.set('safari', { + cookies: [ + { name: 'auth_token', value: 'safari_auth', domain: 'x.com' }, + { name: 'ct0', value: 'safari_ct0', domain: 'x.com' }, + ], + warnings: [], + }); + const { resolveCredentials } = await import('../src/lib/cookies.js'); - const fs = await import('node:fs'); - - (fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => - path.toLowerCase().endsWith('cookies.binarycookies'), - ); - (fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); - (fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir'); - (fs.readFileSync as unknown as vi.Mock).mockReturnValue( - buildSafariCookiesFile([ - buildSafariCookieRecord({ domain: '.x.com', name: 'auth_token', value: 'safari_auth' }), - buildSafariCookieRecord({ domain: '.x.com', name: 'ct0', value: 'safari_ct0' }), - ]), - ); - const result = await resolveCredentials({ cookieSource: 'safari' }); + expect(result.cookies.authToken).toBe('safari_auth'); expect(result.cookies.ct0).toBe('safari_ct0'); expect(result.cookies.cookieHeader).toContain('auth_token=safari_auth'); @@ -166,26 +83,15 @@ describe('cookies', () => { }); it('uses firefox when enabled and returns cookies', async () => { - const { resolveCredentials } = await import('../src/lib/cookies.js'); - const fs = await import('node:fs'); - - // Firefox present with cookies.sqlite - (fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => { - const lower = path.toLowerCase(); - if (lower.endsWith('cookies.sqlite')) return true; - if (lower.includes('firefox')) return true; - return false; + sweet.results.set('firefox', { + cookies: [ + { name: 'auth_token', value: 'firefox_auth', domain: 'x.com' }, + { name: 'ct0', value: 'firefox_ct0', domain: 'x.com' }, + ], + warnings: [], }); - (fs.readdirSync as unknown as vi.Mock).mockReturnValue([ - { isDirectory: () => true, name: 'abc.default-release' }, - ]); - (fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); - (fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir'); - - // sqlite3 output for firefox - const { execSync } = await import('node:child_process'); - (execSync as unknown as vi.Mock).mockReturnValue('auth_token|firefox_auth\nct0|firefox_ct0'); + const { resolveCredentials } = await import('../src/lib/cookies.js'); const result = await resolveCredentials({ cookieSource: 'firefox', firefoxProfile: 'abc.default-release' }); expect(result.cookies.authToken).toBe('firefox_auth'); @@ -270,15 +176,12 @@ describe('cookies', () => { }); it('falls back to Chrome when enabled and Firefox disabled', async () => { - const fs = await import('node:fs'); - const { execSync } = await import('node:child_process'); - (fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => path.includes('Cookies')); - (fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); - (execSync as unknown as vi.Mock).mockImplementation((cmd: string) => { - if (cmd.includes('sqlite3')) { - return 'auth_token|746573745f61757468\nct0|746573745f637430'; - } - return ''; + sweet.results.set('chrome', { + cookies: [ + { name: 'auth_token', value: 'test_auth', domain: 'x.com' }, + { name: 'ct0', value: 'test_ct0', domain: 'x.com' }, + ], + warnings: [], }); const { resolveCredentials } = await import('../src/lib/cookies.js'); @@ -291,19 +194,14 @@ describe('cookies', () => { }); describe('extractCookiesFromSafari', () => { - itIfDarwin('returns cookies from Safari binarycookies', async () => { - const fs = await import('node:fs'); - (fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => - path.toLowerCase().endsWith('cookies.binarycookies'), - ); - (fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); - (fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir'); - (fs.readFileSync as unknown as vi.Mock).mockReturnValue( - buildSafariCookiesFile([ - buildSafariCookieRecord({ domain: '.x.com', name: 'auth_token', value: 'safari_auth' }), - buildSafariCookieRecord({ domain: '.x.com', name: 'ct0', value: 'safari_ct0' }), - ]), - ); + itIfDarwin('returns cookies from Safari', async () => { + sweet.results.set('safari', { + cookies: [ + { name: 'auth_token', value: 'safari_auth', domain: 'x.com' }, + { name: 'ct0', value: 'safari_ct0', domain: 'x.com' }, + ], + warnings: [], + }); const { extractCookiesFromSafari } = await import('../src/lib/cookies.js'); const result = await extractCookiesFromSafari(); @@ -314,22 +212,20 @@ describe('cookies', () => { }); itIfDarwin('prefers Safari over Chrome when both are available', async () => { - const fs = await import('node:fs'); - - (fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => { - const lower = path.toLowerCase(); - if (lower.endsWith('cookies.binarycookies')) return true; - if (lower.includes('chrome') && lower.endsWith('cookies')) return true; - return false; + sweet.results.set('safari', { + cookies: [ + { name: 'auth_token', value: 'safari_auth', domain: 'x.com' }, + { name: 'ct0', value: 'safari_ct0', domain: 'x.com' }, + ], + warnings: [], + }); + sweet.results.set('chrome', { + cookies: [ + { name: 'auth_token', value: 'chrome_auth', domain: 'x.com' }, + { name: 'ct0', value: 'chrome_ct0', domain: 'x.com' }, + ], + warnings: [], }); - (fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); - (fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir'); - (fs.readFileSync as unknown as vi.Mock).mockReturnValue( - buildSafariCookiesFile([ - buildSafariCookieRecord({ domain: '.x.com', name: 'auth_token', value: 'safari_auth' }), - buildSafariCookieRecord({ domain: '.x.com', name: 'ct0', value: 'safari_ct0' }), - ]), - ); const { resolveCredentials } = await import('../src/lib/cookies.js'); const result = await resolveCredentials({ cookieSource: ['safari', 'chrome'] }); @@ -340,18 +236,13 @@ describe('cookies', () => { }); describe('extractCookiesFromChrome', () => { - it('returns cookies when sqlite yields hex values', async () => { - const fs = await import('node:fs'); - const { execSync } = await import('node:child_process'); - // Pretend Chrome cookie DB exists - (fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => path.includes('Cookies')); - (fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); - // sqlite output with hex strings ("test_auth", "test_ct0") - (execSync as unknown as vi.Mock).mockImplementation((cmd: string) => { - if (cmd.includes('sqlite3')) { - return 'auth_token|746573745f61757468\nct0|746573745f637430'; - } - return ''; + it('returns cookies when Chrome yields values', async () => { + sweet.results.set('chrome', { + cookies: [ + { name: 'auth_token', value: 'test_auth', domain: 'x.com' }, + { name: 'ct0', value: 'test_ct0', domain: 'x.com' }, + ], + warnings: [], }); const { extractCookiesFromChrome } = await import('../src/lib/cookies.js'); @@ -363,12 +254,8 @@ describe('cookies', () => { expect(result.warnings).toHaveLength(0); }); - it('warns when Chrome DB exists but contains no cookies', async () => { - const fs = await import('node:fs'); - const { execSync } = await import('node:child_process'); - (fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => path.includes('Cookies')); - (fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); - (execSync as unknown as vi.Mock).mockReturnValue(''); + it('warns when Chrome returns no cookies', async () => { + sweet.results.set('chrome', { cookies: [], warnings: [] }); const { extractCookiesFromChrome } = await import('../src/lib/cookies.js'); const result = await extractCookiesFromChrome('Default'); @@ -381,6 +268,11 @@ describe('cookies', () => { describe('extractCookiesFromFirefox', () => { it('warns when Firefox cookies database is missing', async () => { + sweet.results.set('firefox', { + cookies: [], + warnings: ['Firefox cookies database not found.'], + }); + const { extractCookiesFromFirefox } = await import('../src/lib/cookies.js'); const result = await extractCookiesFromFirefox('missing-profile'); @@ -389,21 +281,8 @@ describe('cookies', () => { expect(result.warnings).toContain('Firefox cookies database not found.'); }); - it('warns when Firefox DB exists but contains no cookies', async () => { - const fs = await import('node:fs'); - const { execSync } = await import('node:child_process'); - (fs.existsSync as unknown as vi.Mock).mockImplementation((path: string) => { - const lower = path.toLowerCase(); - if (lower.endsWith('cookies.sqlite')) return true; - if (lower.includes('firefox')) return true; - return false; - }); - (fs.readdirSync as unknown as vi.Mock).mockReturnValue([ - { isDirectory: () => true, name: 'abc.default-release' }, - ]); - (fs.copyFileSync as unknown as vi.Mock).mockImplementation(() => {}); - (fs.mkdtempSync as unknown as vi.Mock).mockReturnValue('/tmp/test-dir'); - (execSync as unknown as vi.Mock).mockReturnValue(''); + it('warns when Firefox returns no cookies', async () => { + sweet.results.set('firefox', { cookies: [], warnings: [] }); const { extractCookiesFromFirefox } = await import('../src/lib/cookies.js'); const result = await extractCookiesFromFirefox('abc.default-release');