| 1 | /** |
| 2 | * Importable browser sources. |
| 3 | * |
| 4 | * Chromium-family browsers keep cookies in an |
| 5 | * encrypted SQLite database whose key lives in an OS credential store; Firefox |
| 6 | * keeps them in plain SQLite with no key at all, so it needs no keychain and |
| 7 | * works the same on every platform. Safari uses binary cookie files, with |
| 8 | * separate WebKit data stores for named profiles. |
| 9 | * |
| 10 | * Each entry pins its own paths and credential-store coordinates rather than |
| 11 | * deriving them, because the forks do not agree. macOS uses service/account |
| 12 | * pairs, while Linux Chromium uses a custom libsecret schema keyed by an |
| 13 | * `application` attribute. The user-data directory also differs per fork and |
| 14 | * per platform. |
| 15 | * |
| 16 | * @module BrowserImportSources |
| 17 | */ |
| 18 | import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts"; |
| 19 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; |
| 20 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 21 | import * as Effect from "effect/Effect"; |
| 22 | import * as FileSystem from "effect/FileSystem"; |
| 23 | import * as Path from "effect/Path"; |
| 24 | import * as PlatformError from "effect/PlatformError"; |
| 25 | import * as Schema from "effect/Schema"; |
| 26 | import * as Stream from "effect/Stream"; |
| 27 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 28 | import * as SqlClient from "effect/sql/SqlClient"; |
| 29 | |
| 30 | export type BrowserImportEngine = "chromium" | "firefox" | "safari"; |
| 31 | |
| 32 | /** |
| 33 | * Directory roots a definition builds its paths from. Passed in rather than |
| 34 | * read from `process`, so source resolution stays testable for platforms the |
| 35 | * host is not currently running. |
| 36 | */ |
| 37 | export interface BrowserImportPathContext { |
| 38 | readonly path: Path.Path; |
| 39 | readonly platform: NodeJS.Platform; |
| 40 | readonly home: string; |
| 41 | /** `%APPDATA%` on Windows; unused elsewhere. */ |
| 42 | readonly appData: string | undefined; |
| 43 | /** `%LOCALAPPDATA%` on Windows; unused elsewhere. */ |
| 44 | readonly localAppData: string | undefined; |
| 45 | } |
| 46 | |
| 47 | export interface BrowserImportSourceDefinition { |
| 48 | readonly id: BrowserImportSourceId; |
| 49 | readonly name: string; |
| 50 | readonly engine: BrowserImportEngine; |
| 51 | /** Platforms the definition has paths for. */ |
| 52 | readonly platforms: ReadonlyArray<NodeJS.Platform>; |
| 53 | readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined; |
| 54 | /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */ |
| 55 | readonly keychainService?: string; |
| 56 | readonly keychainAccount?: string; |
| 57 | /** Chromium's `application` attribute in the Linux libsecret schema. */ |
| 58 | readonly linuxSecretApplication?: string; |
| 59 | } |
| 60 | |
| 61 | const macApplicationSupport = ( |
| 62 | context: BrowserImportPathContext, |
| 63 | ...segments: ReadonlyArray<string> |
| 64 | ) => context.path.join(context.home, "Library", "Application Support", ...segments); |
| 65 | |
| 66 | /** |
| 67 | * One Chromium fork. The leaves differ per fork; omitting a platform's |
| 68 | * segments marks the fork as unavailable there. Most Windows Chromium builds |
| 69 | * use App-Bound Encryption, but forks can retain the older DPAPI-backed store. |
| 70 | */ |
| 71 | const chromiumSource = (input: { |
| 72 | readonly id: BrowserImportSourceId; |
| 73 | readonly name: string; |
| 74 | readonly keychainService: string; |
| 75 | readonly keychainAccount: string; |
| 76 | readonly macSegments: ReadonlyArray<string>; |
| 77 | readonly linuxSegments?: ReadonlyArray<string>; |
| 78 | readonly linuxSecretApplication?: string; |
| 79 | readonly windowsSegments?: ReadonlyArray<string>; |
| 80 | }): BrowserImportSourceDefinition => ({ |
| 81 | id: input.id, |
| 82 | name: input.name, |
| 83 | engine: "chromium", |
| 84 | platforms: [ |
| 85 | "darwin" as NodeJS.Platform, |
| 86 | ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []), |
| 87 | ...(input.windowsSegments ? ["win32" as NodeJS.Platform] : []), |
| 88 | ], |
| 89 | keychainService: input.keychainService, |
| 90 | keychainAccount: input.keychainAccount, |
| 91 | ...(input.linuxSecretApplication === undefined |
| 92 | ? {} |
| 93 | : { linuxSecretApplication: input.linuxSecretApplication }), |
| 94 | userDataDirectory: (context) => { |
| 95 | if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments); |
| 96 | if (context.platform === "win32") { |
| 97 | return input.windowsSegments && context.localAppData |
| 98 | ? context.path.join(context.localAppData, ...input.windowsSegments) |
| 99 | : undefined; |
| 100 | } |
| 101 | return input.linuxSegments |
| 102 | ? context.path.join(context.home, ".config", ...input.linuxSegments) |
| 103 | : undefined; |
| 104 | }, |
| 105 | }); |
| 106 | |
| 107 | export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [ |
| 108 | // No Chromium fork is importable on Windows: since Chrome 127 their cookies |
| 109 | // are encrypted to the browser's own identity (App-Bound Encryption), so no |
| 110 | // other process can read them. macOS and Linux keep working, so only the |
| 111 | // Windows segments are omitted. |
| 112 | chromiumSource({ |
| 113 | id: "chrome", |
| 114 | name: "Chrome", |
| 115 | keychainService: "Chrome Safe Storage", |
| 116 | keychainAccount: "Chrome", |
| 117 | macSegments: ["Google", "Chrome"], |
| 118 | linuxSegments: ["google-chrome"], |
| 119 | linuxSecretApplication: "chrome", |
| 120 | }), |
| 121 | chromiumSource({ |
| 122 | id: "edge", |
| 123 | name: "Microsoft Edge", |
| 124 | keychainService: "Microsoft Edge Safe Storage", |
| 125 | keychainAccount: "Microsoft Edge", |
| 126 | macSegments: ["Microsoft Edge"], |
| 127 | linuxSegments: ["microsoft-edge"], |
| 128 | linuxSecretApplication: "msedge", |
| 129 | }), |
| 130 | chromiumSource({ |
| 131 | id: "brave", |
| 132 | name: "Brave", |
| 133 | keychainService: "Brave Safe Storage", |
| 134 | keychainAccount: "Brave", |
| 135 | macSegments: ["BraveSoftware", "Brave-Browser"], |
| 136 | linuxSegments: ["BraveSoftware", "Brave-Browser"], |
| 137 | linuxSecretApplication: "brave", |
| 138 | }), |
| 139 | chromiumSource({ |
| 140 | id: "vivaldi", |
| 141 | name: "Vivaldi", |
| 142 | keychainService: "Vivaldi Safe Storage", |
| 143 | keychainAccount: "Vivaldi", |
| 144 | macSegments: ["Vivaldi"], |
| 145 | linuxSegments: ["vivaldi"], |
| 146 | linuxSecretApplication: "vivaldi", |
| 147 | }), |
| 148 | chromiumSource({ |
| 149 | id: "opera", |
| 150 | name: "Opera", |
| 151 | keychainService: "Opera Safe Storage", |
| 152 | keychainAccount: "Opera", |
| 153 | macSegments: ["com.operasoftware.Opera"], |
| 154 | linuxSegments: ["opera"], |
| 155 | linuxSecretApplication: "opera", |
| 156 | }), |
| 157 | // Arc has no Linux build. |
| 158 | chromiumSource({ |
| 159 | id: "arc", |
| 160 | name: "Arc", |
| 161 | keychainService: "Arc Safe Storage", |
| 162 | keychainAccount: "Arc", |
| 163 | macSegments: ["Arc", "User Data"], |
| 164 | }), |
| 165 | chromiumSource({ |
| 166 | id: "helium", |
| 167 | name: "Helium", |
| 168 | keychainService: "Helium Storage Key", |
| 169 | keychainAccount: "Helium", |
| 170 | macSegments: ["net.imput.helium"], |
| 171 | linuxSegments: ["net.imput.helium"], |
| 172 | windowsSegments: ["imput", "Helium", "User Data"], |
| 173 | // Helium retains Chromium's libsecret application name on Linux. |
| 174 | linuxSecretApplication: "chromium", |
| 175 | }), |
| 176 | { |
| 177 | // The default jar lives here; named profiles use WebKit data stores |
| 178 | // alongside this directory inside the same app container. |
| 179 | id: "safari", |
| 180 | name: "Safari", |
| 181 | engine: "safari", |
| 182 | platforms: ["darwin"], |
| 183 | userDataDirectory: (context) => |
| 184 | context.platform === "darwin" |
| 185 | ? context.path.join( |
| 186 | context.home, |
| 187 | "Library", |
| 188 | "Containers", |
| 189 | "com.apple.Safari", |
| 190 | "Data", |
| 191 | "Library", |
| 192 | "Cookies", |
| 193 | ) |
| 194 | : undefined, |
| 195 | }, |
| 196 | { |
| 197 | id: "firefox", |
| 198 | name: "Firefox", |
| 199 | engine: "firefox", |
| 200 | platforms: ["darwin", "win32", "linux"], |
| 201 | userDataDirectory: (context) => { |
| 202 | if (context.platform === "darwin") return macApplicationSupport(context, "Firefox"); |
| 203 | if (context.platform === "win32") { |
| 204 | return context.appData |
| 205 | ? context.path.join(context.appData, "Mozilla", "Firefox") |
| 206 | : undefined; |
| 207 | } |
| 208 | return context.path.join(context.home, ".mozilla", "firefox"); |
| 209 | }, |
| 210 | }, |
| 211 | ]; |
| 212 | |
| 213 | /** |
| 214 | * Where a profile's cookie database may live, most current first. Chromium 96 |
| 215 | * moved the live jar to `Network/Cookies`; a root-level `Cookies` is either a |
| 216 | * pre-96 install or a leftover from before the move. Importing the leftover |
| 217 | * while sessions live in `Network/` would snapshot a stale or empty database, |
| 218 | * and a fresh install with only `Network/Cookies` would read as not installed. |
| 219 | * Firefox uses `cookies.sqlite`, and its profile paths from `profiles.ini` |
| 220 | * may already be absolute. |
| 221 | * |
| 222 | * Chrome 96+ moved network-related files (including Cookies) into a `Network` |
| 223 | * subdirectory for sandboxing. The candidate list includes both locations so |
| 224 | * callers tolerate fresh and legacy installs alike. |
| 225 | */ |
| 226 | export const cookieDatabaseCandidatePaths = ( |
| 227 | definition: BrowserImportSourceDefinition, |
| 228 | context: BrowserImportPathContext, |
| 229 | profileDirectory: string, |
| 230 | ): ReadonlyArray<string> => { |
| 231 | const root = definition.userDataDirectory(context); |
| 232 | if (root === undefined) return []; |
| 233 | const profilePath = context.path.isAbsolute(profileDirectory) |
| 234 | ? profileDirectory |
| 235 | : context.path.join(root, profileDirectory); |
| 236 | if (definition.engine === "firefox") { |
| 237 | return [context.path.join(profilePath, "cookies.sqlite")]; |
| 238 | } |
| 239 | if (definition.engine === "safari") { |
| 240 | return [context.path.join(profilePath, "Cookies.binarycookies")]; |
| 241 | } |
| 242 | // Chromium: pre-96 uses `Cookies`, 96+ use `Network/Cookies`. An upgrade |
| 243 | // leaves the legacy file behind, so prefer the current one and fall back. |
| 244 | return [ |
| 245 | context.path.join(profilePath, "Network", "Cookies"), |
| 246 | context.path.join(profilePath, "Cookies"), |
| 247 | ]; |
| 248 | }; |
| 249 | |
| 250 | /** The first candidate that is a regular file, or undefined when none is. */ |
| 251 | export const resolveCookieDatabase = Effect.fnUntraced(function* ( |
| 252 | definition: BrowserImportSourceDefinition, |
| 253 | context: BrowserImportPathContext, |
| 254 | profileDirectory: string, |
| 255 | ) { |
| 256 | for (const candidate of cookieDatabaseCandidatePaths(definition, context, profileDirectory)) { |
| 257 | if (yield* databaseFileExists(candidate)) return candidate; |
| 258 | } |
| 259 | return undefined; |
| 260 | }); |
| 261 | |
| 262 | /** |
| 263 | * Firefox records its profiles in `profiles.ini`. `Install*` sections point at |
| 264 | * a default profile but do not describe one, so only `[ProfileN]` blocks |
| 265 | * count. |
| 266 | */ |
| 267 | export function parseFirefoxProfiles( |
| 268 | ini: string, |
| 269 | path: Path.Path, |
| 270 | root: string, |
| 271 | ): ReadonlyArray<BrowserImportSourceProfile> { |
| 272 | const profiles: BrowserImportSourceProfile[] = []; |
| 273 | let current: { name?: string; path?: string; isRelative?: string } | null = null; |
| 274 | |
| 275 | const flush = () => { |
| 276 | if (current?.path) { |
| 277 | const candidate = current.path; |
| 278 | const isRelative = current.isRelative === undefined || current.isRelative === "1"; |
| 279 | const validIsRelative = current.isRelative === undefined || /^[01]$/.test(current.isRelative); |
| 280 | if (!validIsRelative || candidate.includes("\u0000")) { |
| 281 | current = null; |
| 282 | return; |
| 283 | } |
| 284 | |
| 285 | let directory: string | undefined; |
| 286 | if (isRelative) { |
| 287 | if (!path.isAbsolute(candidate)) { |
| 288 | const resolved = path.resolve(root, candidate); |
| 289 | const relative = path.relative(root, resolved); |
| 290 | const escapesRoot = |
| 291 | relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative); |
| 292 | if (!escapesRoot) directory = path.normalize(candidate); |
| 293 | } |
| 294 | } else if (path.isAbsolute(candidate)) { |
| 295 | // Firefox supports profiles on arbitrary custom roots when |
| 296 | // IsRelative=0. Do not constrain them to the standard Firefox root. |
| 297 | directory = path.normalize(candidate); |
| 298 | } |
| 299 | |
| 300 | if (directory !== undefined) { |
| 301 | profiles.push({ directory, name: current.name?.trim() || directory }); |
| 302 | } |
| 303 | } |
| 304 | current = null; |
| 305 | }; |
| 306 | |
| 307 | for (const rawLine of ini.split(/\r?\n/)) { |
| 308 | const line = rawLine.trim(); |
| 309 | if (line.startsWith("[")) { |
| 310 | flush(); |
| 311 | current = /^\[Profile\d+\]$/i.test(line) ? {} : null; |
| 312 | continue; |
| 313 | } |
| 314 | if (!current) continue; |
| 315 | const separator = line.indexOf("="); |
| 316 | if (separator === -1) continue; |
| 317 | const key = line.slice(0, separator).trim().toLowerCase(); |
| 318 | const value = line.slice(separator + 1).trim(); |
| 319 | if (key === "name") current.name = value; |
| 320 | if (key === "path") current.path = value; |
| 321 | if (key === "isrelative") current.isRelative = value; |
| 322 | } |
| 323 | flush(); |
| 324 | return profiles; |
| 325 | } |
| 326 | |
| 327 | /** |
| 328 | * Resolves the roots the registry builds its paths from, from the ambient |
| 329 | * process. Tests build a context directly instead. |
| 330 | */ |
| 331 | export const sourcePathContext = Effect.gen(function* () { |
| 332 | const path = yield* Path.Path; |
| 333 | const platform = yield* HostProcess.Platform; |
| 334 | const environment = yield* HostProcess.Environment; |
| 335 | return { |
| 336 | path, |
| 337 | platform, |
| 338 | home: environment.HOME ?? environment.USERPROFILE ?? "", |
| 339 | appData: environment.APPDATA, |
| 340 | localAppData: environment.LOCALAPPDATA, |
| 341 | } satisfies BrowserImportPathContext; |
| 342 | }); |
| 343 | |
| 344 | /** Shape of the slice of Chromium's `Local State` that names its profiles. */ |
| 345 | const LocalState = Schema.Struct({ |
| 346 | profile: Schema.optional( |
| 347 | Schema.Struct({ |
| 348 | info_cache: Schema.optional( |
| 349 | Schema.Record(Schema.String, Schema.Struct({ name: Schema.optional(Schema.String) })), |
| 350 | ), |
| 351 | }), |
| 352 | ), |
| 353 | }); |
| 354 | const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState)); |
| 355 | |
| 356 | /** A single plain path segment: no separators, no `.`/`..`, not empty. */ |
| 357 | const isSafeProfileDirectory = (directory: string): boolean => |
| 358 | directory.length > 0 && |
| 359 | directory !== "." && |
| 360 | directory !== ".." && |
| 361 | !/[\\/]/.test(directory) && |
| 362 | !directory.includes("\u0000"); |
| 363 | |
| 364 | const CookieCountRow = Schema.Struct({ count: Schema.Number }); |
| 365 | const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow)); |
| 366 | |
| 367 | /** |
| 368 | * How many importable cookies a profile holds, counted without decrypting |
| 369 | * anything. Firefox containers use identities Electron cannot represent, so |
| 370 | * its count uses the same default-container predicate as the reader. Best |
| 371 | * effort: a locked, missing or unexpected database yields `undefined` rather |
| 372 | * than failing the listing. |
| 373 | */ |
| 374 | const countProfileCookies = Effect.fnUntraced(function* ( |
| 375 | definition: BrowserImportSourceDefinition, |
| 376 | context: BrowserImportPathContext, |
| 377 | directory: string, |
| 378 | ): Effect.fn.Return<number | undefined, never, FileSystem.FileSystem> { |
| 379 | const database = yield* resolveCookieDatabase(definition, context, directory); |
| 380 | if (database === undefined) return undefined; |
| 381 | return yield* Effect.gen(function* () { |
| 382 | const sql = yield* SqlClient.SqlClient; |
| 383 | const rows = |
| 384 | definition.engine === "firefox" |
| 385 | ? yield* sql`select count(*) as count from moz_cookies where originAttributes = ''` |
| 386 | : yield* sql`select count(*) as count from cookies`; |
| 387 | const [row] = yield* decodeCookieCount(rows); |
| 388 | return row?.count; |
| 389 | }).pipe( |
| 390 | Effect.provide(NodeSqliteClient.layer({ filename: database, readonly: true })), |
| 391 | Effect.orElseSucceed(() => undefined), |
| 392 | ); |
| 393 | }); |
| 394 | |
| 395 | const withCookieCounts = ( |
| 396 | definition: BrowserImportSourceDefinition, |
| 397 | context: BrowserImportPathContext, |
| 398 | profiles: ReadonlyArray<BrowserImportSourceProfile>, |
| 399 | ) => |
| 400 | Effect.forEach(profiles, (profile) => |
| 401 | countProfileCookies(definition, context, profile.directory).pipe( |
| 402 | Effect.map((cookieCount) => |
| 403 | cookieCount === undefined ? profile : { ...profile, cookieCount }, |
| 404 | ), |
| 405 | ), |
| 406 | ); |
| 407 | |
| 408 | const SafariProfileRows = Schema.Array( |
| 409 | Schema.Struct({ title: Schema.NullOr(Schema.String), external_uuid: Schema.String }), |
| 410 | ); |
| 411 | const decodeSafariProfiles = Schema.decodeUnknownEffect(SafariProfileRows); |
| 412 | const isSafariProfileUuid = (value: string) => |
| 413 | /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); |
| 414 | |
| 415 | const listSafariProfiles = Effect.fnUntraced(function* ( |
| 416 | context: BrowserImportPathContext, |
| 417 | root: string, |
| 418 | ) { |
| 419 | const fileSystem = yield* FileSystem.FileSystem; |
| 420 | const library = context.path.dirname(root); |
| 421 | const metadata = context.path.join(library, "Safari", "SafariTabs.db"); |
| 422 | const declared = yield* Effect.gen(function* () { |
| 423 | const sql = yield* SqlClient.SqlClient; |
| 424 | return yield* decodeSafariProfiles( |
| 425 | yield* sql` |
| 426 | select title, external_uuid from bookmarks |
| 427 | where parent = 0 and type = 1 and subtype = 2 and deleted = 0 |
| 428 | order by order_index |
| 429 | `, |
| 430 | ); |
| 431 | }).pipe( |
| 432 | Effect.provide(NodeSqliteClient.layer({ filename: metadata, readonly: true })), |
| 433 | Effect.orElseSucceed(() => []), |
| 434 | ); |
| 435 | const defaultProfile = declared.find((profile) => profile.external_uuid === "DefaultProfile"); |
| 436 | const profiles: Array<BrowserImportSourceProfile> = [ |
| 437 | { |
| 438 | directory: ".", |
| 439 | name: defaultProfile ? defaultProfile.title?.trim() || "Personal" : "Safari", |
| 440 | }, |
| 441 | ]; |
| 442 | const stores = context.path.join(library, "WebKit", "WebsiteDataStore"); |
| 443 | const profileDirectory = (uuid: string) => |
| 444 | context.path.join(stores, uuid.toLowerCase(), "Cookies"); |
| 445 | for (const profile of declared) { |
| 446 | if (!isSafariProfileUuid(profile.external_uuid)) continue; |
| 447 | profiles.push({ |
| 448 | directory: profileDirectory(profile.external_uuid), |
| 449 | name: profile.title?.trim() || profile.external_uuid, |
| 450 | }); |
| 451 | } |
| 452 | // If Safari's metadata is unavailable, recover stores that have cookies. |
| 453 | // With readable metadata, avoid resurrecting deleted profiles left on disk. |
| 454 | if (declared.length === 0) { |
| 455 | const entries = yield* fileSystem.readDirectory(stores).pipe(Effect.orElseSucceed(() => [])); |
| 456 | for (const entry of entries.filter(isSafariProfileUuid).sort()) { |
| 457 | const directory = context.path.join(stores, entry, "Cookies"); |
| 458 | if (yield* databaseFileExists(context.path.join(directory, "Cookies.binarycookies"))) { |
| 459 | profiles.push({ directory, name: entry }); |
| 460 | } |
| 461 | } |
| 462 | } |
| 463 | return profiles; |
| 464 | }); |
| 465 | |
| 466 | /** |
| 467 | * Profiles the source browser knows about. |
| 468 | * |
| 469 | * Firefox declares them in `profiles.ini`; Chromium in `Local State`. When |
| 470 | * that metadata is missing, unreadable or malformed, the directories that |
| 471 | * actually hold a cookie database are scanned instead. Assuming a single |
| 472 | * `Default` would report a browser whose cookies live in `Profile 1` as having |
| 473 | * nothing to import — and it is then left out of the menu entirely. |
| 474 | */ |
| 475 | const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( |
| 476 | definition: BrowserImportSourceDefinition, |
| 477 | context: BrowserImportPathContext, |
| 478 | ): Effect.fn.Return<ReadonlyArray<BrowserImportSourceProfile>, never, FileSystem.FileSystem> { |
| 479 | const fileSystem = yield* FileSystem.FileSystem; |
| 480 | const root = definition.userDataDirectory(context); |
| 481 | if (root === undefined) return []; |
| 482 | |
| 483 | if (definition.engine === "safari") { |
| 484 | return yield* listSafariProfiles(context, root); |
| 485 | } |
| 486 | |
| 487 | if (definition.engine === "firefox") { |
| 488 | const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe( |
| 489 | Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)), |
| 490 | Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>), |
| 491 | ); |
| 492 | // `profiles.ini` also lists profiles the installer created but the user |
| 493 | // never launched, which hold no cookie database and nothing to import. |
| 494 | // Only keep the ones a database proves exist, like the directory scans |
| 495 | // below do. When none of the declared profiles has one, fall through to |
| 496 | // the scan rather than returning empty: `profiles.ini` can list stale or |
| 497 | // never-launched profiles while the cookies live in one it does not |
| 498 | // mention, and an empty answer here hides the browser entirely. |
| 499 | if (declared.length > 0) { |
| 500 | const found = yield* Effect.forEach(declared, (profile) => |
| 501 | Effect.forEach( |
| 502 | cookieDatabaseCandidatePaths(definition, context, profile.directory), |
| 503 | (candidate) => databaseFileExists(candidate), |
| 504 | ).pipe(Effect.map((results) => (results.some(Boolean) ? profile : undefined))), |
| 505 | ); |
| 506 | const withDatabase = found.filter((profile) => profile !== undefined); |
| 507 | if (withDatabase.length > 0) { |
| 508 | return yield* withCookieCounts(definition, context, withDatabase); |
| 509 | } |
| 510 | } |
| 511 | |
| 512 | // No usable `profiles.ini`, so fall back to scanning the directory the |
| 513 | // profiles actually live in, keeping only the ones a cookie database |
| 514 | // proves were launched. |
| 515 | const fallbackDirectory = |
| 516 | context.platform === "linux" ? root : context.path.join(root, "Profiles"); |
| 517 | const scanned = yield* fileSystem |
| 518 | .readDirectory(fallbackDirectory) |
| 519 | .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>)); |
| 520 | const found = yield* Effect.forEach(scanned, (entry) => { |
| 521 | const directory = context.platform === "linux" ? entry : context.path.join("Profiles", entry); |
| 522 | return resolveCookieDatabase(definition, context, directory).pipe( |
| 523 | Effect.map((database) => (database === undefined ? undefined : { directory, name: entry })), |
| 524 | ); |
| 525 | }); |
| 526 | return yield* withCookieCounts( |
| 527 | definition, |
| 528 | context, |
| 529 | found.filter((profile) => profile !== undefined), |
| 530 | ); |
| 531 | } |
| 532 | |
| 533 | const declared = yield* fileSystem.readFileString(context.path.join(root, "Local State")).pipe( |
| 534 | Effect.flatMap(decodeLocalState), |
| 535 | Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})), |
| 536 | // The keys are directory names from the browser's own metadata file, which |
| 537 | // anything running as the user can write. Anything but a single plain |
| 538 | // segment is dropped: `..` or a path separator would otherwise be handed |
| 539 | // to `cookieDatabasePath` and read a database outside the user-data |
| 540 | // directory. |
| 541 | Effect.map((entries) => entries.filter(([directory]) => isSafeProfileDirectory(directory))), |
| 542 | Effect.map((entries) => |
| 543 | entries.map(([directory, info]) => ({ directory, name: info.name?.trim() || directory })), |
| 544 | ), |
| 545 | Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>), |
| 546 | ); |
| 547 | if (declared.length > 0) return yield* withCookieCounts(definition, context, declared); |
| 548 | |
| 549 | // `Local State` is missing, unreadable or malformed. Scanning for directories |
| 550 | // that hold a cookie database finds the profiles anyway. |
| 551 | const entries = yield* fileSystem |
| 552 | .readDirectory(root) |
| 553 | .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>)); |
| 554 | const found = yield* Effect.forEach(entries.filter(isSafeProfileDirectory), (directory) => |
| 555 | resolveCookieDatabase(definition, context, directory).pipe( |
| 556 | Effect.map((database) => |
| 557 | database === undefined ? undefined : { directory, name: directory }, |
| 558 | ), |
| 559 | ), |
| 560 | ); |
| 561 | return yield* withCookieCounts( |
| 562 | definition, |
| 563 | context, |
| 564 | found.filter((profile) => profile !== undefined), |
| 565 | ); |
| 566 | }); |
| 567 | |
| 568 | /** |
| 569 | * Include Firefox's Snap home alongside its native home. Snap profiles use |
| 570 | * absolute directories so cookie reads and lock checks keep pointing at the |
| 571 | * installation they came from, even when both installs use the same name. |
| 572 | */ |
| 573 | export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* ( |
| 574 | definition: BrowserImportSourceDefinition, |
| 575 | context: BrowserImportPathContext, |
| 576 | ): Effect.fn.Return<ReadonlyArray<BrowserImportSourceProfile>, never, FileSystem.FileSystem> { |
| 577 | if (definition.engine !== "firefox" || context.platform !== "linux") { |
| 578 | return yield* listSourceProfilesInDirectory(definition, context); |
| 579 | } |
| 580 | |
| 581 | const root = definition.userDataDirectory(context); |
| 582 | if (root === undefined) return []; |
| 583 | const roots = [ |
| 584 | root, |
| 585 | context.path.join(context.home, "snap", "firefox", "common", ".mozilla", "firefox"), |
| 586 | ]; |
| 587 | const profiles = new Map<string, BrowserImportSourceProfile>(); |
| 588 | for (const directory of roots) { |
| 589 | const found = yield* listSourceProfilesInDirectory( |
| 590 | { ...definition, userDataDirectory: () => directory }, |
| 591 | context, |
| 592 | ); |
| 593 | for (const profile of found) { |
| 594 | const absolute = context.path.resolve(directory, profile.directory); |
| 595 | if (!profiles.has(absolute)) { |
| 596 | profiles.set(absolute, directory === root ? profile : { ...profile, directory: absolute }); |
| 597 | } |
| 598 | } |
| 599 | } |
| 600 | return [...profiles.values()]; |
| 601 | }); |
| 602 | |
| 603 | /** |
| 604 | * Whether a cookie database candidate is a regular file. Presence alone is |
| 605 | * not enough: a directory at the path would list as an importable profile and |
| 606 | * then fail the SQLite open, so anything but a file is treated as absent. |
| 607 | */ |
| 608 | const databaseFileExists = Effect.fnUntraced(function* (path: string) { |
| 609 | const fileSystem = yield* FileSystem.FileSystem; |
| 610 | return yield* fileSystem.stat(path).pipe( |
| 611 | Effect.map((info) => info.type === "File"), |
| 612 | Effect.orElseSucceed(() => false), |
| 613 | ); |
| 614 | }); |
| 615 | |
| 616 | type ProcessLivenessProbe = (pid: number) => Effect.Effect<boolean>; |
| 617 | |
| 618 | export const chromiumProcessIsAlive = ( |
| 619 | pid: number, |
| 620 | signalProcess: (pid: number, signal: 0) => unknown = process.kill.bind(process), |
| 621 | ) => |
| 622 | Effect.sync(() => { |
| 623 | try { |
| 624 | // Signal 0 performs a read-only existence/permission check. |
| 625 | signalProcess(pid, 0); |
| 626 | return true; |
| 627 | } catch (cause) { |
| 628 | // Only ESRCH positively proves the process is gone. Permission errors |
| 629 | // and unknown failures stay conservative so an active browser is never |
| 630 | // mistaken for a stale lock. |
| 631 | return !( |
| 632 | typeof cause === "object" && |
| 633 | cause !== null && |
| 634 | "code" in cause && |
| 635 | cause.code === "ESRCH" |
| 636 | ); |
| 637 | } |
| 638 | }); |
| 639 | |
| 640 | const processIsAlive: ProcessLivenessProbe = (pid) => chromiumProcessIsAlive(pid); |
| 641 | |
| 642 | /** Whether a Chromium `<host>-<pid>` lock target may still name its owner. */ |
| 643 | export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* ( |
| 644 | target: string, |
| 645 | currentHost: string, |
| 646 | isProcessAlive: ProcessLivenessProbe, |
| 647 | ) { |
| 648 | const separator = target.lastIndexOf("-"); |
| 649 | if (separator <= 0) return true; |
| 650 | const host = target.slice(0, separator); |
| 651 | const pidText = target.slice(separator + 1); |
| 652 | if (!/^\d+$/.test(pidText)) return true; |
| 653 | const pid = Number(pidText); |
| 654 | if (!Number.isSafeInteger(pid) || pid <= 0) return true; |
| 655 | // A PID is meaningful only on this host. A foreign hostname can come from a |
| 656 | // shared home directory, and cannot safely be declared stale from here. |
| 657 | if (host !== currentHost) return true; |
| 658 | return yield* isProcessAlive(pid); |
| 659 | }); |
| 660 | |
| 661 | /** Windows sharing and lock violations are translated by libuv to `Busy`. */ |
| 662 | export const isWindowsLockHeldError = (error: PlatformError.PlatformError): boolean => |
| 663 | error.reason._tag === "Busy"; |
| 664 | |
| 665 | /** |
| 666 | * Whether a Windows `parent.lock` is actually held by a running process. It |
| 667 | * is opened with no sharing, so it persists on disk after the process exits |
| 668 | * and `stat` always succeeds; only trying to open it for write reveals an |
| 669 | * active holder, which surfaces as `Busy`. |
| 670 | */ |
| 671 | const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) { |
| 672 | // Permission failures are distinct: they do not prove a browser owns the |
| 673 | // lock, so they must not hide the source as running. |
| 674 | const fileSystem = yield* FileSystem.FileSystem; |
| 675 | return yield* fileSystem.open(lockPath, { flag: "r+" }).pipe( |
| 676 | Effect.as(false), |
| 677 | Effect.catchIf(isWindowsLockHeldError, () => Effect.succeed(true)), |
| 678 | Effect.orElseSucceed(() => false), |
| 679 | Effect.scoped, |
| 680 | ); |
| 681 | }); |
| 682 | |
| 683 | type WindowsLockProbe = (path: string) => Effect.Effect<boolean, never, FileSystem.FileSystem>; |
| 684 | |
| 685 | /** |
| 686 | * Chromium does not create its POSIX `SingletonLock` symlink on Windows. The |
| 687 | * live cookie database is opened without sharing instead, so probing each |
| 688 | * profile's current jar is the reliable running signal there. |
| 689 | */ |
| 690 | export const windowsChromiumCookiesAreHeld = Effect.fnUntraced(function* ( |
| 691 | definition: BrowserImportSourceDefinition, |
| 692 | context: BrowserImportPathContext, |
| 693 | lockIsHeld: WindowsLockProbe = windowsLockIsHeld, |
| 694 | ) { |
| 695 | const profiles = yield* listSourceProfiles(definition, context); |
| 696 | const held = yield* Effect.forEach(profiles, (profile) => |
| 697 | resolveCookieDatabase(definition, context, profile.directory).pipe( |
| 698 | Effect.flatMap((database) => |
| 699 | database === undefined ? Effect.succeed(false) : lockIsHeld(database), |
| 700 | ), |
| 701 | ), |
| 702 | ); |
| 703 | return held.some(Boolean); |
| 704 | }); |
| 705 | |
| 706 | /** |
| 707 | * Whether a Firefox `lock` symlink's `<ip>:[+]<pid>` target still names a |
| 708 | * live owner. Firefox writes this symlink beside the profile while it runs and |
| 709 | * unlinks it on a clean exit, so a dangling one is either live or a crash. |
| 710 | */ |
| 711 | export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( |
| 712 | target: string, |
| 713 | localAddresses: ReadonlySet<string>, |
| 714 | isProcessAlive: ProcessLivenessProbe, |
| 715 | ) { |
| 716 | const separator = target.lastIndexOf(":"); |
| 717 | if (separator < 0) return true; |
| 718 | // The owner half is whatever Firefox's resolver returned for the machine's |
| 719 | // hostname — 127.0.0.1 when the lookup fails, but often 127.0.1.1 or a LAN |
| 720 | // address — so a pid is only meaningful when that address is one of ours. |
| 721 | // A shared (NFS) profile locked from another machine names a foreign |
| 722 | // address whose pid cannot be probed here, nor could a reused local pid |
| 723 | // vouch for it, so it stays conservatively held. |
| 724 | const owner = target.slice(0, separator); |
| 725 | if (!localAddresses.has(owner)) return true; |
| 726 | // A `+` marks an fcntl-holding owner; the pid follows either way. |
| 727 | const pidText = target.slice(separator + 1).replace(/^\+/, ""); |
| 728 | if (!/^\d+$/.test(pidText)) return true; |
| 729 | const pid = Number(pidText); |
| 730 | if (!Number.isSafeInteger(pid) || pid <= 0) return true; |
| 731 | return yield* isProcessAlive(pid); |
| 732 | }); |
| 733 | |
| 734 | /** |
| 735 | * Interpreters that can run the fcntl probe, tried in order. `/usr/bin/python3` |
| 736 | * is named absolutely first so a Dock-launched app with launchd's bare `PATH` |
| 737 | * still finds it without depending on the login-shell PATH merge; Linux |
| 738 | * distributions carry python3 on the default path. |
| 739 | */ |
| 740 | const FCNTL_PROBE_INTERPRETERS = ["/usr/bin/python3", "python3"] as const; |
| 741 | |
| 742 | /** |
| 743 | * The probe prints exactly one of these. Anything else means the script never |
| 744 | * ran — most importantly Apple's `/usr/bin/python3` shim, which on a Mac |
| 745 | * without the Command Line Tools exits non-zero after printing an install |
| 746 | * prompt, without ever reaching our code. |
| 747 | */ |
| 748 | const FCNTL_PROBE_SCRIPT = |
| 749 | "import fcntl,os,sys\n" + |
| 750 | "fd=os.open(sys.argv[1],os.O_WRONLY)\n" + |
| 751 | "try:\n" + |
| 752 | " fcntl.lockf(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n" + |
| 753 | "except BlockingIOError:\n" + |
| 754 | " print('held')\n" + |
| 755 | "else:\n" + |
| 756 | " print('free')"; |
| 757 | |
| 758 | /** |
| 759 | * Whether another process holds an fcntl write lock on `path`. |
| 760 | * |
| 761 | * Firefox's `.parentlock` is an empty file whose only signal is the kernel |
| 762 | * lock, and Node exposes no fcntl, so a throwaway interpreter tries a |
| 763 | * non-blocking `F_SETLK` and reports `EWOULDBLOCK`. The lock is never |
| 764 | * acquired for real: on success the child exits and the kernel drops it. |
| 765 | * |
| 766 | * The answer is trusted only when the script itself spoke. A verdict of |
| 767 | * `held` or `free` on stdout is the probe's own, and stands. Anything else — |
| 768 | * no interpreter on any candidate path, or one that refused to run the script |
| 769 | * (Apple's shim without the developer tools) — is the probe being unavailable, |
| 770 | * not evidence about the lock. That case falls back to "not held" rather than |
| 771 | * "held": reporting every profile as locked forever would block Firefox import |
| 772 | * outright on such machines, and the SQLite snapshot already copes with a |
| 773 | * live database's WAL, as it does for every other engine. |
| 774 | */ |
| 775 | export const posixLockIsHeld = Effect.fnUntraced(function* ( |
| 776 | path: string, |
| 777 | interpreters: ReadonlyArray<string> = FCNTL_PROBE_INTERPRETERS, |
| 778 | ) { |
| 779 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 780 | const environment = yield* HostProcess.Environment; |
| 781 | for (const interpreter of interpreters) { |
| 782 | const verdict = yield* Effect.scoped( |
| 783 | Effect.gen(function* () { |
| 784 | const handle = yield* spawner.spawn( |
| 785 | ChildProcess.make(interpreter, ["-c", FCNTL_PROBE_SCRIPT, path], { |
| 786 | stdin: "ignore", |
| 787 | env: environment, |
| 788 | }), |
| 789 | ); |
| 790 | const [stdout] = yield* Effect.all( |
| 791 | [handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], |
| 792 | { concurrency: "unbounded" }, |
| 793 | ); |
| 794 | return stdout.trim(); |
| 795 | }), |
| 796 | ).pipe(Effect.orElseSucceed(() => "")); |
| 797 | if (verdict === "held") return true; |
| 798 | if (verdict === "free") return false; |
| 799 | } |
| 800 | return false; |
| 801 | }); |
| 802 | |
| 803 | /** |
| 804 | * Whether Firefox holds a profile. |
| 805 | * |
| 806 | * Firefox leaves two kinds of lock behind, and they mean different things. |
| 807 | * On Linux the `lock` symlink (target `<ip>:+<pid>`) is removed on a clean |
| 808 | * exit, so its presence is evidence — provided the pid it names is alive. But |
| 809 | * `.parentlock` (macOS/Linux) and `parent.lock` (Windows) are regular files |
| 810 | * held with fcntl or a Windows handle and are *deliberately left on disk* |
| 811 | * after exit, as a last-used marker; treating them as proof of a running |
| 812 | * browser blocks every import after Firefox has been used once. On POSIX the |
| 813 | * fcntl lock itself is the truth, and macOS in particular writes nothing else |
| 814 | * (no symlink, no pid), so `.parentlock` is probed for the kernel lock. On |
| 815 | * Windows the held handle denies our open, which `windowsLockIsHeld` reads as `Busy`. |
| 816 | */ |
| 817 | const firefoxProfileIsHeld = Effect.fnUntraced(function* ( |
| 818 | directory: string, |
| 819 | context: BrowserImportPathContext, |
| 820 | // Resolved once by the caller: it involves a DNS lookup of the hostname and |
| 821 | // is the same for every profile. |
| 822 | localAddresses: ReadonlySet<string>, |
| 823 | ) { |
| 824 | const fileSystem = yield* FileSystem.FileSystem; |
| 825 | if (context.platform === "win32") { |
| 826 | return yield* windowsLockIsHeld(context.path.join(directory, "parent.lock")); |
| 827 | } |
| 828 | // Linux additionally writes the `lock` symlink; a live pid there settles it |
| 829 | // without spawning anything. |
| 830 | const symlinkHeld = yield* fileSystem.readLink(context.path.join(directory, "lock")).pipe( |
| 831 | Effect.flatMap((target) => firefoxSymlinkLockIsHeld(target, localAddresses, processIsAlive)), |
| 832 | Effect.orElseSucceed(() => false), |
| 833 | ); |
| 834 | if (symlinkHeld) return true; |
| 835 | const parentLock = context.path.join(directory, ".parentlock"); |
| 836 | const present = yield* fileSystem.stat(parentLock).pipe( |
| 837 | Effect.map((info) => info.type === "File"), |
| 838 | Effect.orElseSucceed(() => false), |
| 839 | ); |
| 840 | if (!present) return false; |
| 841 | return yield* posixLockIsHeld(parentLock); |
| 842 | }); |
| 843 | |
| 844 | /** Whether the browser is running, which leaves its cookie DB mid-write. */ |
| 845 | export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")(function* ( |
| 846 | definition: BrowserImportSourceDefinition, |
| 847 | context: BrowserImportPathContext, |
| 848 | ): Effect.fn.Return< |
| 849 | boolean, |
| 850 | never, |
| 851 | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner |
| 852 | > { |
| 853 | const fileSystem = yield* FileSystem.FileSystem; |
| 854 | const root = definition.userDataDirectory(context); |
| 855 | if (root === undefined) return false; |
| 856 | // Probe the source's own lock state rather than scanning the process table. |
| 857 | // Safari keeps no lock and writes its jar atomically, so a running instance |
| 858 | // is not a hazard there. |
| 859 | // |
| 860 | // Chromium exposes its lock through the cookie jar on Windows and through a |
| 861 | // user-data SingletonLock on POSIX. Firefox keeps its locks inside each |
| 862 | // profile under three names across platforms (`lock` on macOS and Linux, |
| 863 | // `.parentlock` beside it, `parent.lock` on Windows). Looking for Firefox's |
| 864 | // at the root finds nothing and reports a running browser as importable. |
| 865 | if (definition.engine === "safari") return false; |
| 866 | if (definition.engine !== "firefox") { |
| 867 | if (context.platform === "win32") { |
| 868 | return yield* windowsChromiumCookiesAreHeld(definition, context); |
| 869 | } |
| 870 | const currentHost = yield* HostProcess.Hostname; |
| 871 | const lock = context.path.join(root, "SingletonLock"); |
| 872 | return yield* fileSystem.readLink(lock).pipe( |
| 873 | Effect.flatMap((target) => chromiumSingletonLockIsHeld(target, currentHost, processIsAlive)), |
| 874 | Effect.catch((error) => Effect.succeed(error.reason._tag !== "NotFound")), |
| 875 | ); |
| 876 | } |
| 877 | |
| 878 | const profiles = yield* listSourceProfiles(definition, context); |
| 879 | // Only the Linux `lock` symlink names an address, so Windows skips the lookup. |
| 880 | const localAddresses: ReadonlySet<string> = |
| 881 | context.platform === "win32" ? new Set() : yield* yield* HostProcess.Addresses; |
| 882 | const found = yield* Effect.forEach(profiles, (profile) => { |
| 883 | const directory = context.path.isAbsolute(profile.directory) |
| 884 | ? profile.directory |
| 885 | : context.path.join(root, profile.directory); |
| 886 | return firefoxProfileIsHeld(directory, context, localAddresses); |
| 887 | }); |
| 888 | return found.some(Boolean); |
| 889 | }); |
| 890 | |
| 891 | /** |
| 892 | * Whether the source has cookies to import. |
| 893 | * |
| 894 | * Keyed off the cookie database rather than the user-data directory, because |
| 895 | * that directory is not evidence the browser exists: installers for native |
| 896 | * messaging hosts create an empty one for every Chromium fork they know about, |
| 897 | * so a machine with only Chrome reports Edge, Brave, Vivaldi, Opera and Arc as |
| 898 | * present. The database is the thing an import actually needs, so its absence |
| 899 | * is the honest answer either way. |
| 900 | * |
| 901 | * Existence is checked without opening the file, which matters for Safari: TCC |
| 902 | * permits `stat` on the jar inside its container but refuses a read, so this |
| 903 | * still sees it and the user gets the Full Disk Access prompt rather than |
| 904 | * having Safari disappear. |
| 905 | */ |
| 906 | export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstalled")(function* ( |
| 907 | definition: BrowserImportSourceDefinition, |
| 908 | context: BrowserImportPathContext, |
| 909 | ): Effect.fn.Return<boolean, never, FileSystem.FileSystem> { |
| 910 | const profiles = yield* listSourceProfiles(definition, context); |
| 911 | const found = yield* Effect.forEach(profiles, (profile) => |
| 912 | resolveCookieDatabase(definition, context, profile.directory).pipe( |
| 913 | Effect.map((database) => database !== undefined), |
| 914 | ), |
| 915 | ); |
| 916 | return found.some(Boolean); |
| 917 | }); |