922 lines · 36.5 KB · TypeScript
RawDownload
/** * Importable browser sources. * * Chromium-family browsers keep cookies in an * encrypted SQLite database whose key lives in an OS credential store; Firefox * keeps them in plain SQLite with no key at all, so it needs no keychain and * works the same on every platform. Safari uses binary cookie files, with * separate WebKit data stores for named profiles. * * Each entry pins its own paths and credential-store coordinates rather than * deriving them, because the forks do not agree. macOS uses service/account * pairs, while Linux Chromium uses a custom libsecret schema keyed by an * `application` attribute. The user-data directory also differs per fork and * per platform. * * @module BrowserImportSources */import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";import {  HostProcessEnvironment,  HostProcessAddresses,  HostProcessHostname,  HostProcessPlatform,} from "@t3tools/shared/hostProcess";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Path from "effect/Path";import * as PlatformError from "effect/PlatformError";import * as Schema from "effect/Schema";import * as Stream from "effect/Stream";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";import * as SqlClient from "effect/unstable/sql/SqlClient"; export type BrowserImportEngine = "chromium" | "firefox" | "safari"; /** * Directory roots a definition builds its paths from. Passed in rather than * read from `process`, so source resolution stays testable for platforms the * host is not currently running. */export interface BrowserImportPathContext {  readonly path: Path.Path;  readonly platform: NodeJS.Platform;  readonly home: string;  /** `%APPDATA%` on Windows; unused elsewhere. */  readonly appData: string | undefined;  /** `%LOCALAPPDATA%` on Windows; unused elsewhere. */  readonly localAppData: string | undefined;} export interface BrowserImportSourceDefinition {  readonly id: BrowserImportSourceId;  readonly name: string;  readonly engine: BrowserImportEngine;  /** Platforms the definition has paths for. */  readonly platforms: ReadonlyArray<NodeJS.Platform>;  readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined;  /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */  readonly keychainService?: string;  readonly keychainAccount?: string;  /** Chromium's `application` attribute in the Linux libsecret schema. */  readonly linuxSecretApplication?: string;} const macApplicationSupport = (  context: BrowserImportPathContext,  ...segments: ReadonlyArray<string>) => context.path.join(context.home, "Library", "Application Support", ...segments); /** * One Chromium fork. The leaves differ per fork; omitting a platform's * segments marks the fork as unavailable there. Most Windows Chromium builds * use App-Bound Encryption, but forks can retain the older DPAPI-backed store. */const chromiumSource = (input: {  readonly id: BrowserImportSourceId;  readonly name: string;  readonly keychainService: string;  readonly keychainAccount: string;  readonly macSegments: ReadonlyArray<string>;  readonly linuxSegments?: ReadonlyArray<string>;  readonly linuxSecretApplication?: string;  readonly windowsSegments?: ReadonlyArray<string>;}): BrowserImportSourceDefinition => ({  id: input.id,  name: input.name,  engine: "chromium",  platforms: [    "darwin" as NodeJS.Platform,    ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []),    ...(input.windowsSegments ? ["win32" as NodeJS.Platform] : []),  ],  keychainService: input.keychainService,  keychainAccount: input.keychainAccount,  ...(input.linuxSecretApplication === undefined    ? {}    : { linuxSecretApplication: input.linuxSecretApplication }),  userDataDirectory: (context) => {    if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments);    if (context.platform === "win32") {      return input.windowsSegments && context.localAppData        ? context.path.join(context.localAppData, ...input.windowsSegments)        : undefined;    }    return input.linuxSegments      ? context.path.join(context.home, ".config", ...input.linuxSegments)      : undefined;  },}); export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [  // No Chromium fork is importable on Windows: since Chrome 127 their cookies  // are encrypted to the browser's own identity (App-Bound Encryption), so no  // other process can read them. macOS and Linux keep working, so only the  // Windows segments are omitted.  chromiumSource({    id: "chrome",    name: "Chrome",    keychainService: "Chrome Safe Storage",    keychainAccount: "Chrome",    macSegments: ["Google", "Chrome"],    linuxSegments: ["google-chrome"],    linuxSecretApplication: "chrome",  }),  chromiumSource({    id: "edge",    name: "Microsoft Edge",    keychainService: "Microsoft Edge Safe Storage",    keychainAccount: "Microsoft Edge",    macSegments: ["Microsoft Edge"],    linuxSegments: ["microsoft-edge"],    linuxSecretApplication: "msedge",  }),  chromiumSource({    id: "brave",    name: "Brave",    keychainService: "Brave Safe Storage",    keychainAccount: "Brave",    macSegments: ["BraveSoftware", "Brave-Browser"],    linuxSegments: ["BraveSoftware", "Brave-Browser"],    linuxSecretApplication: "brave",  }),  chromiumSource({    id: "vivaldi",    name: "Vivaldi",    keychainService: "Vivaldi Safe Storage",    keychainAccount: "Vivaldi",    macSegments: ["Vivaldi"],    linuxSegments: ["vivaldi"],    linuxSecretApplication: "vivaldi",  }),  chromiumSource({    id: "opera",    name: "Opera",    keychainService: "Opera Safe Storage",    keychainAccount: "Opera",    macSegments: ["com.operasoftware.Opera"],    linuxSegments: ["opera"],    linuxSecretApplication: "opera",  }),  // Arc has no Linux build.  chromiumSource({    id: "arc",    name: "Arc",    keychainService: "Arc Safe Storage",    keychainAccount: "Arc",    macSegments: ["Arc", "User Data"],  }),  chromiumSource({    id: "helium",    name: "Helium",    keychainService: "Helium Storage Key",    keychainAccount: "Helium",    macSegments: ["net.imput.helium"],    linuxSegments: ["net.imput.helium"],    windowsSegments: ["imput", "Helium", "User Data"],    // Helium retains Chromium's libsecret application name on Linux.    linuxSecretApplication: "chromium",  }),  {    // The default jar lives here; named profiles use WebKit data stores    // alongside this directory inside the same app container.    id: "safari",    name: "Safari",    engine: "safari",    platforms: ["darwin"],    userDataDirectory: (context) =>      context.platform === "darwin"        ? context.path.join(            context.home,            "Library",            "Containers",            "com.apple.Safari",            "Data",            "Library",            "Cookies",          )        : undefined,  },  {    id: "firefox",    name: "Firefox",    engine: "firefox",    platforms: ["darwin", "win32", "linux"],    userDataDirectory: (context) => {      if (context.platform === "darwin") return macApplicationSupport(context, "Firefox");      if (context.platform === "win32") {        return context.appData          ? context.path.join(context.appData, "Mozilla", "Firefox")          : undefined;      }      return context.path.join(context.home, ".mozilla", "firefox");    },  },]; /** * Where a profile's cookie database may live, most current first. Chromium 96 * moved the live jar to `Network/Cookies`; a root-level `Cookies` is either a * pre-96 install or a leftover from before the move. Importing the leftover * while sessions live in `Network/` would snapshot a stale or empty database, * and a fresh install with only `Network/Cookies` would read as not installed. * Firefox uses `cookies.sqlite`, and its profile paths from `profiles.ini` * may already be absolute. * * Chrome 96+ moved network-related files (including Cookies) into a `Network` * subdirectory for sandboxing. The candidate list includes both locations so * callers tolerate fresh and legacy installs alike. */export const cookieDatabaseCandidatePaths = (  definition: BrowserImportSourceDefinition,  context: BrowserImportPathContext,  profileDirectory: string,): ReadonlyArray<string> => {  const root = definition.userDataDirectory(context);  if (root === undefined) return [];  const profilePath = context.path.isAbsolute(profileDirectory)    ? profileDirectory    : context.path.join(root, profileDirectory);  if (definition.engine === "firefox") {    return [context.path.join(profilePath, "cookies.sqlite")];  }  if (definition.engine === "safari") {    return [context.path.join(profilePath, "Cookies.binarycookies")];  }  // Chromium: pre-96 uses `Cookies`, 96+ use `Network/Cookies`. An upgrade  // leaves the legacy file behind, so prefer the current one and fall back.  return [    context.path.join(profilePath, "Network", "Cookies"),    context.path.join(profilePath, "Cookies"),  ];}; /** The first candidate that is a regular file, or undefined when none is. */export const resolveCookieDatabase = Effect.fnUntraced(function* (  definition: BrowserImportSourceDefinition,  context: BrowserImportPathContext,  profileDirectory: string,) {  for (const candidate of cookieDatabaseCandidatePaths(definition, context, profileDirectory)) {    if (yield* databaseFileExists(candidate)) return candidate;  }  return undefined;}); /** * Firefox records its profiles in `profiles.ini`. `Install*` sections point at * a default profile but do not describe one, so only `[ProfileN]` blocks * count. */export function parseFirefoxProfiles(  ini: string,  path: Path.Path,  root: string,): ReadonlyArray<BrowserImportSourceProfile> {  const profiles: BrowserImportSourceProfile[] = [];  let current: { name?: string; path?: string; isRelative?: string } | null = null;   const flush = () => {    if (current?.path) {      const candidate = current.path;      const isRelative = current.isRelative === undefined || current.isRelative === "1";      const validIsRelative = current.isRelative === undefined || /^[01]$/.test(current.isRelative);      if (!validIsRelative || candidate.includes("\u0000")) {        current = null;        return;      }       let directory: string | undefined;      if (isRelative) {        if (!path.isAbsolute(candidate)) {          const resolved = path.resolve(root, candidate);          const relative = path.relative(root, resolved);          const escapesRoot =            relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);          if (!escapesRoot) directory = path.normalize(candidate);        }      } else if (path.isAbsolute(candidate)) {        // Firefox supports profiles on arbitrary custom roots when        // IsRelative=0. Do not constrain them to the standard Firefox root.        directory = path.normalize(candidate);      }       if (directory !== undefined) {        profiles.push({ directory, name: current.name?.trim() || directory });      }    }    current = null;  };   for (const rawLine of ini.split(/\r?\n/)) {    const line = rawLine.trim();    if (line.startsWith("[")) {      flush();      current = /^\[Profile\d+\]$/i.test(line) ? {} : null;      continue;    }    if (!current) continue;    const separator = line.indexOf("=");    if (separator === -1) continue;    const key = line.slice(0, separator).trim().toLowerCase();    const value = line.slice(separator + 1).trim();    if (key === "name") current.name = value;    if (key === "path") current.path = value;    if (key === "isrelative") current.isRelative = value;  }  flush();  return profiles;} /** * Resolves the roots the registry builds its paths from, from the ambient * process. Tests build a context directly instead. */export const sourcePathContext = Effect.gen(function* () {  const path = yield* Path.Path;  const platform = yield* HostProcessPlatform;  const environment = yield* HostProcessEnvironment;  return {    path,    platform,    home: environment.HOME ?? environment.USERPROFILE ?? "",    appData: environment.APPDATA,    localAppData: environment.LOCALAPPDATA,  } satisfies BrowserImportPathContext;}); /** Shape of the slice of Chromium's `Local State` that names its profiles. */const LocalState = Schema.Struct({  profile: Schema.optional(    Schema.Struct({      info_cache: Schema.optional(        Schema.Record(Schema.String, Schema.Struct({ name: Schema.optional(Schema.String) })),      ),    }),  ),});const decodeLocalState = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalState)); /** A single plain path segment: no separators, no `.`/`..`, not empty. */const isSafeProfileDirectory = (directory: string): boolean =>  directory.length > 0 &&  directory !== "." &&  directory !== ".." &&  !/[\\/]/.test(directory) &&  !directory.includes("\u0000"); const CookieCountRow = Schema.Struct({ count: Schema.Number });const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow)); /** * How many importable cookies a profile holds, counted without decrypting * anything. Firefox containers use identities Electron cannot represent, so * its count uses the same default-container predicate as the reader. Best * effort: a locked, missing or unexpected database yields `undefined` rather * than failing the listing. */const countProfileCookies = Effect.fnUntraced(function* (  definition: BrowserImportSourceDefinition,  context: BrowserImportPathContext,  directory: string,): Effect.fn.Return<number | undefined, never, FileSystem.FileSystem> {  const database = yield* resolveCookieDatabase(definition, context, directory);  if (database === undefined) return undefined;  return yield* Effect.gen(function* () {    const sql = yield* SqlClient.SqlClient;    const rows =      definition.engine === "firefox"        ? yield* sql`select count(*) as count from moz_cookies where originAttributes = ''`        : yield* sql`select count(*) as count from cookies`;    const [row] = yield* decodeCookieCount(rows);    return row?.count;  }).pipe(    Effect.provide(NodeSqliteClient.layer({ filename: database, readonly: true })),    Effect.orElseSucceed(() => undefined),  );}); const withCookieCounts = (  definition: BrowserImportSourceDefinition,  context: BrowserImportPathContext,  profiles: ReadonlyArray<BrowserImportSourceProfile>,) =>  Effect.forEach(profiles, (profile) =>    countProfileCookies(definition, context, profile.directory).pipe(      Effect.map((cookieCount) =>        cookieCount === undefined ? profile : { ...profile, cookieCount },      ),    ),  ); const SafariProfileRows = Schema.Array(  Schema.Struct({ title: Schema.NullOr(Schema.String), external_uuid: Schema.String }),);const decodeSafariProfiles = Schema.decodeUnknownEffect(SafariProfileRows);const isSafariProfileUuid = (value: string) =>  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); const listSafariProfiles = Effect.fnUntraced(function* (  context: BrowserImportPathContext,  root: string,) {  const fileSystem = yield* FileSystem.FileSystem;  const library = context.path.dirname(root);  const metadata = context.path.join(library, "Safari", "SafariTabs.db");  const declared = yield* Effect.gen(function* () {    const sql = yield* SqlClient.SqlClient;    return yield* decodeSafariProfiles(      yield* sql`      select title, external_uuid from bookmarks      where parent = 0 and type = 1 and subtype = 2 and deleted = 0      order by order_index    `,    );  }).pipe(    Effect.provide(NodeSqliteClient.layer({ filename: metadata, readonly: true })),    Effect.orElseSucceed(() => []),  );  const defaultProfile = declared.find((profile) => profile.external_uuid === "DefaultProfile");  const profiles: Array<BrowserImportSourceProfile> = [    {      directory: ".",      name: defaultProfile ? defaultProfile.title?.trim() || "Personal" : "Safari",    },  ];  const stores = context.path.join(library, "WebKit", "WebsiteDataStore");  const profileDirectory = (uuid: string) =>    context.path.join(stores, uuid.toLowerCase(), "Cookies");  for (const profile of declared) {    if (!isSafariProfileUuid(profile.external_uuid)) continue;    profiles.push({      directory: profileDirectory(profile.external_uuid),      name: profile.title?.trim() || profile.external_uuid,    });  }  // If Safari's metadata is unavailable, recover stores that have cookies.  // With readable metadata, avoid resurrecting deleted profiles left on disk.  if (declared.length === 0) {    const entries = yield* fileSystem.readDirectory(stores).pipe(Effect.orElseSucceed(() => []));    for (const entry of entries.filter(isSafariProfileUuid).sort()) {      const directory = context.path.join(stores, entry, "Cookies");      if (yield* databaseFileExists(context.path.join(directory, "Cookies.binarycookies"))) {        profiles.push({ directory, name: entry });      }    }  }  return profiles;}); /** * Profiles the source browser knows about. * * Firefox declares them in `profiles.ini`; Chromium in `Local State`. When * that metadata is missing, unreadable or malformed, the directories that * actually hold a cookie database are scanned instead. Assuming a single * `Default` would report a browser whose cookies live in `Profile 1` as having * nothing to import — and it is then left out of the menu entirely. */const listSourceProfilesInDirectory = Effect.fnUntraced(function* (  definition: BrowserImportSourceDefinition,  context: BrowserImportPathContext,): Effect.fn.Return<ReadonlyArray<BrowserImportSourceProfile>, never, FileSystem.FileSystem> {  const fileSystem = yield* FileSystem.FileSystem;  const root = definition.userDataDirectory(context);  if (root === undefined) return [];   if (definition.engine === "safari") {    return yield* listSafariProfiles(context, root);  }   if (definition.engine === "firefox") {    const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe(      Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)),      Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),    );    // `profiles.ini` also lists profiles the installer created but the user    // never launched, which hold no cookie database and nothing to import.    // Only keep the ones a database proves exist, like the directory scans    // below do. When none of the declared profiles has one, fall through to

Lines 1–500 of 922.