diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts index b9c0dd14c3df..9b0a652f09f1 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts @@ -13,7 +13,7 @@ import * as Ref from "effect/Ref"; import * as BrowserSession from "../BrowserSession.ts"; import * as BrowserImport from "./BrowserImport.ts"; -import { BROWSER_IMPORT_SOURCES, sourcePaths } from "./Sources.ts"; +import { BROWSER_IMPORT_SOURCES, sourcePathContext } from "./Sources.ts"; const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; @@ -52,15 +52,16 @@ const withImporter = Effect.fnUntraced(function* () { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-" }); const environment = Layer.succeed(HostProcessEnvironment, { HOME: home }); - const paths = yield* sourcePaths.pipe( + const context = yield* sourcePathContext.pipe( Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), ); - yield* fileSystem.makeDirectory(`${helium.userDataDirectory(paths)}/Default`, { - recursive: true, - }); + const root = helium.userDataDirectory(context); + if (root === undefined) throw new Error("Helium has no macOS user-data directory"); + yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); // The cookie database is what marks a source as installed, so a fixture // without one is reported as absent before any other check runs. - yield* fileSystem.writeFileString(`${helium.userDataDirectory(paths)}/Default/Cookies`, "db"); + yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); const importer = yield* BrowserImport.BrowserImport.pipe( Effect.provide( @@ -73,7 +74,7 @@ const withImporter = Effect.fnUntraced(function* () { ), ), ); - return { importer, home, paths }; + return { importer, home, root }; }); describe("BrowserImport.importCookies", () => { @@ -107,13 +108,10 @@ describe("BrowserImport.importCookies", () => { it.effect("refuses to import while the source browser holds its profile", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const { importer, paths } = yield* withImporter(); + const { importer, root } = yield* withImporter(); // The lock Chromium leaves while it is running, dangling target and // all. This must stop the import before it ever asks the keychain. - yield* fileSystem.symlink( - "host-that-does-not-exist-1234", - `${helium.userDataDirectory(paths)}/SingletonLock`, - ); + yield* fileSystem.symlink("host-that-does-not-exist-1234", `${root}/SingletonLock`); const error = yield* importer .importCookies({ diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index 62c7416fc71f..eab1ba6b8d95 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -18,20 +18,24 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as BrowserSession from "../BrowserSession.ts"; -import { readChromiumCookies, type CookieReadResult } from "./ChromiumCookies.ts"; +import { ChromiumCookieReadError, readChromiumCookies } from "./ChromiumCookies.ts"; +import type { CookieReadResult } from "./CookieDatabase.ts"; +import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts"; import { BROWSER_IMPORT_SOURCES, resolveCookieDatabase, isSourceInstalled, isSourceRunning, listSourceProfiles, - sourcePaths, + sourcePathContext, + type BrowserImportPathContext, type BrowserImportSourceDefinition, - type SourcePaths, } from "./Sources.ts"; export class BrowserImportFailedError extends Schema.TaggedErrorClass()( @@ -79,12 +83,20 @@ export class BrowserImport extends Context.Service< const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* ( definition: BrowserImportSourceDefinition, - platform: NodeJS.Platform, - paths: SourcePaths, -): Effect.fn.Return { - if (!definition.platforms.includes(platform)) return "unsupportedPlatform"; - if (!(yield* isSourceInstalled(definition, paths))) return "notInstalled"; - if (yield* isSourceRunning(definition, paths)) return "browserRunning"; + context: BrowserImportPathContext, +): Effect.fn.Return< + BrowserImportUnavailableReason | undefined, + never, + FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner +> { + if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform"; + // Chromium's key lives in an OS credential store, and only the macOS one is + // implemented; Firefox needs no key at all, so it works everywhere. + if (definition.engine === "chromium" && context.platform !== "darwin") { + return "unsupportedPlatform"; + } + if (!(yield* isSourceInstalled(definition, context))) return "notInstalled"; + if (yield* isSourceRunning(definition, context)) return "browserRunning"; return undefined; }); @@ -155,19 +167,22 @@ export const make = Effect.gen(function* BrowserImportMake() { const executablePath = yield* HostProcessExecutablePath; // Captured here so the service's methods stay free of a requirements // channel: the layer is built where NodeServices is already in scope. - const platformServices = yield* Effect.context(); - const paths = yield* sourcePaths; + const platformServices = yield* Effect.context< + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + >(); + const pathContext = yield* sourcePathContext; const listSources: Effect.Effect> = Effect.forEach( BROWSER_IMPORT_SOURCES, Effect.fnUntraced(function* (definition) { - const unavailable = yield* unavailableReason(definition, platform, paths); + const unavailable = yield* unavailableReason(definition, pathContext); return { id: definition.id, name: definition.name, // Listing profiles touches the source's own files, so skip it when the // source is unusable anyway. - profiles: unavailable === undefined ? yield* listSourceProfiles(definition, paths) : [], + profiles: + unavailable === undefined ? yield* listSourceProfiles(definition, pathContext) : [], ...(unavailable === undefined ? {} : { unavailable }), } satisfies BrowserImportSource; }), @@ -189,7 +204,7 @@ export const make = Effect.gen(function* BrowserImportMake() { }); } - const blocked = yield* unavailableReason(definition, platform, paths).pipe( + const blocked = yield* unavailableReason(definition, pathContext).pipe( Effect.provide(platformServices), ); if (blocked !== undefined) { @@ -199,16 +214,21 @@ export const make = Effect.gen(function* BrowserImportMake() { // macOS attributes the Keychain prompt and the resulting ACL grant to the // executable that asks, so record which one that was — in a packaged build // it is the signed app, in dev whatever binary hosts the main process. - yield* Effect.logInfo("Reading browser cookie key from the keychain", { - sourceId: definition.id, - executablePath, - }); + // Only Chromium reads a key; a Firefox import touches no keychain, and + // logging that it did would put a false security-sensitive event in the + // audit trail. + if (definition.engine === "chromium") { + yield* Effect.logInfo("Reading browser cookie key from the keychain", { + sourceId: definition.id, + executablePath, + }); + } // The profile directory arrives over IPC, so it is only honoured when the // source itself reported it. Forwarding it unchecked would let `..` // segments walk out of the browser's user-data directory and read any // cookie database reachable on disk. - const sourceProfiles = yield* listSourceProfiles(definition, paths).pipe( + const sourceProfiles = yield* listSourceProfiles(definition, pathContext).pipe( Effect.provide(platformServices), ); const requestedProfile = sourceProfiles.find( @@ -222,28 +242,57 @@ export const make = Effect.gen(function* BrowserImportMake() { } // The profile was listed against a database moments ago; resolve it again - // rather than assume a path, since the live jar may sit under `Network/`. + // rather than assume a path, since a Chromium jar may sit under `Network/`. const databasePath = yield* resolveCookieDatabase( definition, - paths, + pathContext, requestedProfile.directory, ).pipe(Effect.provide(platformServices)); if (databasePath === undefined) { + // A profile we listed moments ago can lose its database before the + // import runs (browser data cleanup, a profile reset). That is a read + // failure, not a platform problem. return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed" }); } - const read = yield* readChromiumCookies({ - cookieDatabasePath: databasePath, - keychainService: definition.keychainService, - keychainAccount: definition.keychainAccount, - platform, - }).pipe( + // Both branches fail with a tagged error, so the union stays structurally + // identifiable and each tag is handled on its own below. The success side + // is normalized to one shape too, so the skipped tally survives either + // engine — Firefox stores plaintext, so nothing there is ever unreadable. + const read: Effect.Effect< + CookieReadResult, + ChromiumCookieReadError | FirefoxCookieReadError, + FileSystem.FileSystem | Path.Path | Scope.Scope + > = + definition.engine === "firefox" + ? readFirefoxCookies(databasePath).pipe( + Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })), + ) + : readChromiumCookies({ + cookieDatabasePath: databasePath, + // Only reached on macOS: `unavailableReason` rejects Chromium + // elsewhere until those key stores are implemented. + keychainService: definition.keychainService ?? "", + keychainAccount: definition.keychainAccount ?? "", + platform, + }); + + const result = yield* read.pipe( Effect.scoped, Effect.provide(platformServices), - Effect.mapError( - (cause) => - new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), - ), + Effect.catchTags({ + ChromiumCookieReadError: (cause) => + Effect.fail( + new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }), + ), + // Firefox has one failure mode — its plaintext database would not open + // — so its error carries no reason of its own and the user-facing one + // is supplied here. + FirefoxCookieReadError: (cause) => + Effect.fail( + new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed", cause }), + ), + }), ); const session = yield* browserSession @@ -261,7 +310,7 @@ export const make = Effect.gen(function* BrowserImportMake() { // Written one at a time rather than in parallel: Chromium's cookie store // serialises writes anyway, and a rejected cookie should only cost itself. - return yield* writeCookies(session, read); + return yield* writeCookies(session, result); }); return BrowserImport.of({ listSources, importCookies }); diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts index d1ceee8e66ad..f24ffc1bf31b 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts @@ -6,103 +6,16 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as NodeCrypto from "node:crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; -import * as Scope from "effect/Scope"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { - cookieScope, - readChromiumCookieDatabase, - snapshotCookieDatabase, -} from "./ChromiumCookies.ts"; +import { readChromiumCookieDatabase } from "./ChromiumCookies.ts"; +import { cookieScope } from "./CookieDatabase.ts"; const encryptV10 = (value: string | Buffer, key: Buffer): Uint8Array => { const cipher = NodeCrypto.createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20)); return Buffer.concat([Buffer.from("v10"), cipher.update(value), cipher.final()]); }; -const runNode = ( - effect: Effect.Effect, -) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); - -describe("snapshotCookieDatabase", () => { - it.effect("includes committed WAL data in one consistent database", () => - runNode( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-cookie-source-", - }); - const source = path.join(sourceDirectory, "Cookies"); - - const snapshot = yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`PRAGMA journal_mode = WAL`; - yield* sql`PRAGMA wal_autocheckpoint = 0`; - yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`; - yield* sql`INSERT INTO cookies(name) VALUES (${"committed-in-wal"})`; - - expect(yield* fileSystem.exists(`${source}-wal`)).toBe(true); - return yield* snapshotCookieDatabase(source); - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source }))); - - const rows = yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - return yield* sql<{ readonly name: string }>`SELECT name FROM cookies`; - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshot, readonly: true }))); - - expect(rows).toEqual([{ name: "committed-in-wal" }]); - }), - ), - ); - - it.effect("propagates snapshot failures and removes its temporary directory", () => - runNode( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-cookie-invalid-source-", - }); - const source = path.join(sourceDirectory, "Cookies"); - yield* fileSystem.writeFileString(source, "not a sqlite database"); - - const prefix = `t3code-cookie-failed-${process.pid}-`; - const error = yield* snapshotCookieDatabase(source, prefix).pipe( - Effect.scoped, - Effect.flip, - ); - - expect(error._tag).toBe("SqlError"); - const temporaryEntries = yield* fileSystem.readDirectory(path.dirname(sourceDirectory)); - expect(temporaryEntries.some((entry) => entry.startsWith(prefix))).toBe(false); - }), - ), - ); - - it.effect("removes a successful snapshot when its scope closes", () => - runNode( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3code-cookie-cleanup-source-", - }); - const source = path.join(sourceDirectory, "Cookies"); - - yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`; - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source }))); - - const snapshot = yield* snapshotCookieDatabase(source).pipe(Effect.scoped); - expect(yield* fileSystem.exists(snapshot)).toBe(false); - }), - ), - ); -}); - describe("cookieScope", () => { it("keeps a host-only cookie host-only", () => { // Chromium stores a host-only cookie without a leading dot. Passing any diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts index 8000f69b3acd..6e814a5065e3 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts @@ -19,11 +19,17 @@ import * as NodeCrypto from "node:crypto"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as Effect from "effect/Effect"; -import * as FileSystem from "effect/FileSystem"; -import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { + bareHost, + cookieScope, + snapshotCookieDatabase, + type CookieReadResult, + type ImportedCookie, +} from "./CookieDatabase.ts"; + /** macOS OSCrypt parameters. Chromium has used these since the feature landed. */ const MAC_KEY_ITERATIONS = 1003; const MAC_KEY_SALT = "saltysalt"; @@ -32,25 +38,6 @@ const MAC_KEY_LENGTH = 16; const AES_IV = Buffer.alloc(16, 0x20); const V10_PREFIX = "v10"; -export interface ChromiumCookie { - readonly url: string; - readonly name: string; - readonly value: string; - /** - * Set only for domain cookies, which Chromium stores with a leading dot. - * A host-only cookie leaves this undefined: Electron treats any `domain` it - * is given as a domain cookie and re-adds the dot, which would widen the - * cookie to every subdomain of the host it was scoped to. - */ - readonly domain: string | undefined; - readonly path: string; - readonly secure: boolean; - readonly httpOnly: boolean; - /** Seconds since the UNIX epoch, or undefined for a session cookie. */ - readonly expirationDate: number | undefined; - readonly sameSite: "no_restriction" | "lax" | "strict"; -} - export const ChromiumCookieReadReason = Schema.Literals([ "needsKeychainApproval", "keychainItemMissing", @@ -104,14 +91,17 @@ const decodeSchemaVersion = Schema.decodeUnknownEffect( ); /** - * Chromium stores `SameSite` as an int; unspecified (-1) behaves as Lax in - * modern Chromium, so it maps there rather than to `no_restriction`, which - * would widen the cookie's scope on import. + * Chromium stores `SameSite` as an int: -1 = unspecified, 0 = none, 1 = lax, + * 2 = strict. Unspecified is imported as Electron's own `unspecified` rather + * than pinned to Lax, so the target browser applies its default just as the + * source did; anything unrecognised lands there too, since guessing "none" + * would widen a cookie's scope on import. */ -const sameSiteFromColumn = (value: number): ChromiumCookie["sameSite"] => { +const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => { if (value === 0) return "no_restriction"; + if (value === 1) return "lax"; if (value === 2) return "strict"; - return "lax"; + return "unspecified"; }; /** @@ -171,60 +161,6 @@ const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPasswo return password; }); -/** - * Chromium keeps the cookie DB open with WAL. SQLite must create the snapshot - * itself so the main database and WAL are read from one transactionally - * consistent generation. Copying those files one after another can pair a - * newer database with an older WAL (or the reverse). - * - * Scoped: the temp directory is removed when the caller's scope closes. - */ -export const snapshotCookieDatabase = Effect.fn("ChromiumCookies.snapshotCookieDatabase")( - function* (cookiePath: string, tempPrefix = "t3code-cookie-import-") { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - - const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: tempPrefix }); - const target = path.join(directory, "Cookies"); - - yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - yield* sql`VACUUM INTO ${target}`; - }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: cookiePath, readonly: true }))); - - return target; - }, -); - -/** - * The URL and domain Electron should register a stored row under. - * - * Chromium marks a domain cookie with a leading dot on `host_key`. Electron - * matches on a URL, so the dot comes off for that; `domain` is passed through - * only for domain cookies, because supplying it at all makes Electron treat - * the cookie as one and re-add the dot — widening a host-only cookie to every - * subdomain of the host it was scoped to, and rejecting `__Host-` cookies, - * which require it to be absent. - */ -export const cookieScope = ( - hostKey: string, - path: string, - secure: boolean, -): { readonly url: string; readonly domain: string | undefined } => { - const isDomainCookie = hostKey.startsWith("."); - const host = isDomainCookie ? hostKey.slice(1) : hostKey; - const urlAuthority = - host.includes(":") && !(host.startsWith("[") && host.endsWith("]")) ? `[${host}]` : host; - return { - url: `${secure ? "https" : "http"}://${urlAuthority}${path}`, - ...(isDomainCookie ? { domain: hostKey } : { domain: undefined }), - }; -}; - -/** The host without Chromium's domain-cookie leading dot, for display. */ -const bareHost = (hostKey: string): string => - hostKey.startsWith(".") ? hostKey.slice(1) : hostKey; - const decryptValue = ( encrypted: Uint8Array, key: Buffer, @@ -294,7 +230,7 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu return { rows: yield* decodeCookieRows(raw), schemaVersion }; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true }))); - const cookies: ChromiumCookie[] = []; + const cookies: ImportedCookie[] = []; let undecryptable = 0; const undecryptableHosts = new Set(); for (const row of rows.rows) { @@ -340,18 +276,6 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu }, ); -/** - * What a reader produces: the cookies it could recover, and how many stored - * rows it could not. The count reaches the user as part of the skipped total - * rather than disappearing. - */ -export interface CookieReadResult { - readonly cookies: ReadonlyArray; - readonly undecryptable: number; - /** Distinct hosts of the rows that could not be decrypted. */ - readonly undecryptableHosts: ReadonlyArray; -} - export interface ChromiumCookieSource { readonly cookieDatabasePath: string; readonly keychainService: string; diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts new file mode 100644 index 000000000000..8ae178e17eff --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts @@ -0,0 +1,84 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Scope from "effect/Scope"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { snapshotCookieDatabase } from "./CookieDatabase.ts"; + +const runNode = ( + effect: Effect.Effect, +) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("snapshotCookieDatabase", () => { + it.effect("includes committed WAL data in one consistent database", () => + runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-cookie-source-", + }); + const source = path.join(sourceDirectory, "Cookies"); + const snapshot = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`PRAGMA journal_mode = WAL`; + yield* sql`PRAGMA wal_autocheckpoint = 0`; + yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`; + yield* sql`INSERT INTO cookies(name) VALUES (${"committed-in-wal"})`; + expect(yield* fileSystem.exists(`${source}-wal`)).toBe(true); + return yield* snapshotCookieDatabase(source); + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source }))); + const rows = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + return yield* sql<{ readonly name: string }>`SELECT name FROM cookies`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshot, readonly: true }))); + expect(rows).toEqual([{ name: "committed-in-wal" }]); + }), + ), + ); + + it.effect("propagates snapshot failures and removes its temporary directory", () => + runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-cookie-invalid-source-", + }); + const source = path.join(sourceDirectory, "Cookies"); + yield* fileSystem.writeFileString(source, "not a sqlite database"); + const prefix = `t3code-cookie-failed-${process.pid}-`; + const error = yield* snapshotCookieDatabase(source, prefix).pipe( + Effect.scoped, + Effect.flip, + ); + expect(error._tag).toBe("SqlError"); + const temporaryEntries = yield* fileSystem.readDirectory(path.dirname(sourceDirectory)); + expect(temporaryEntries.some((entry) => entry.startsWith(prefix))).toBe(false); + }), + ), + ); + + it.effect("removes a successful snapshot when its scope closes", () => + runNode( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-cookie-cleanup-source-", + }); + const source = path.join(sourceDirectory, "Cookies"); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source }))); + const snapshot = yield* snapshotCookieDatabase(source).pipe(Effect.scoped); + expect(yield* fileSystem.exists(snapshot)).toBe(false); + }), + ), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts new file mode 100644 index 000000000000..a9e6be495c05 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/CookieDatabase.ts @@ -0,0 +1,100 @@ +/** + * Shared pieces of cookie extraction: the shape both engines produce, and the + * snapshot every reader takes before touching a live database. + * + * @module CookieDatabase + */ +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +/** A cookie in the shape Electron's `session.cookies.set` accepts. */ +export interface ImportedCookie { + readonly url: string; + readonly name: string; + readonly value: string; + /** + * Set only for domain cookies, which the sources mark with a leading dot. + * A host-only cookie leaves this undefined: Electron treats any `domain` it + * is given as marking a domain cookie and re-adds the dot, which would widen + * the cookie to every subdomain of the host it was scoped to, and rejects + * `__Host-` cookies, which require it to be absent. + */ + readonly domain: string | undefined; + readonly path: string; + readonly secure: boolean; + readonly httpOnly: boolean; + /** Seconds since the UNIX epoch, or undefined for a session cookie. */ + readonly expirationDate: number | undefined; + readonly sameSite: "unspecified" | "no_restriction" | "lax" | "strict"; +} + +/** + * Cookies recovered from one database and rows that could not be decrypted. + * The skipped count reaches the user instead of disappearing from a partial + * import result. + */ +export interface CookieReadResult { + readonly cookies: ReadonlyArray; + readonly undecryptable: number; + /** Distinct hosts of the rows that could not be decrypted. */ + readonly undecryptableHosts: ReadonlyArray; +} + +/** + * The URL and domain Electron should register a stored row under. + * + * Both engines mark a domain cookie with a leading dot on the host. Electron + * matches on a URL, so the dot comes off for that; `domain` is passed through + * only for domain cookies, because supplying it at all makes Electron treat + * the cookie as one and re-add the dot — widening a host-only cookie to every + * subdomain of the host it was scoped to, and rejecting `__Host-` cookies, + * which require it to be absent. + */ +export const cookieScope = ( + host: string, + path: string, + secure: boolean, +): { readonly url: string; readonly domain: string | undefined } => { + const isDomainCookie = host.startsWith("."); + const unwrappedHost = bareHost(host); + const authority = + unwrappedHost.includes(":") && !(unwrappedHost.startsWith("[") && unwrappedHost.endsWith("]")) + ? `[${unwrappedHost}]` + : unwrappedHost; + return { + url: `${secure ? "https" : "http"}://${authority}${path}`, + domain: isDomainCookie ? host : undefined, + }; +}; + +/** A host without the leading dot both engines put on a domain cookie, for display. */ +export const bareHost = (host: string): string => (host.startsWith(".") ? host.slice(1) : host); + +/** + * Creates a transactionally consistent snapshot of a cookie database in a + * temporary directory and returns the snapshot's path. + * + * Both engines keep the file open with WAL while the browser runs, so reading + * in place can observe a torn write. Copying also guarantees we never open the + * browser's own file for writing. + * + * Scoped: the temporary directory goes away when the caller's scope closes. + */ +export const snapshotCookieDatabase = Effect.fn("CookieDatabase.snapshotCookieDatabase")(function* ( + cookiePath: string, + tempPrefix = "t3code-cookie-import-", +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: tempPrefix }); + const target = path.join(directory, path.basename(cookiePath)); + yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`VACUUM INTO ${target}`; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: cookiePath, readonly: true }))); + return target; +}); diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts new file mode 100644 index 000000000000..84e7678cce4a --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts @@ -0,0 +1,459 @@ +// @effect-diagnostics nodeBuiltinImport:off - Builds a Firefox-shaped +// `cookies.sqlite` fixture with the same native bindings Firefox itself uses. +import * as NodePath from "@effect/platform-node/NodePath"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Scope from "effect/Scope"; +import * as NodeSqlite from "node:sqlite"; + +import { readFirefoxCookies } from "./FirefoxCookies.ts"; +import { parseFirefoxProfiles } from "./Sources.ts"; + +const parsePosixFirefoxProfiles = (ini: string, root = "/home/user/.mozilla/firefox") => + Effect.gen(function* () { + const path = yield* Path.Path; + return parseFirefoxProfiles(ini, path, root); + }).pipe(Effect.provide(NodePath.layerPosix)); + +const parseWindowsFirefoxProfiles = (ini: string, root = "C:\\Users\\user\\Firefox") => + Effect.gen(function* () { + const path = yield* Path.Path; + return parseFirefoxProfiles(ini, path, root); + }).pipe(Effect.provide(NodePath.layerWin32)); + +/** Builds a `cookies.sqlite` with Firefox's real `moz_cookies` shape. */ +const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( + rows: ReadonlyArray<{ + host: string; + name: string; + value: string; + path: string; + expiry: number; + isSecure: number; + isHttpOnly: number; + sameSite: number | null; + rawSameSite?: number; + originAttributes?: string; + }>, + // Firefox stamps `PRAGMA user_version`; schema 16+ stores `expiry` in + // milliseconds, earlier ones in seconds. + schemaVersion = 15, +) { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-test-" }); + const file = `${directory}/cookies.sqlite`; + const database = new NodeSqlite.DatabaseSync(file); + database.exec(`pragma user_version = ${schemaVersion}`); + // Only schemas 10–14 have `rawSameSite`; the schema-15 migration dropped it. + const hasRawSameSite = schemaVersion >= 10 && schemaVersion <= 14; + database.exec( + `create table moz_cookies ( + id integer primary key, host text, name text, value text, path text, + expiry integer, isSecure integer, isHttpOnly integer, sameSite integer, + ${hasRawSameSite ? "rawSameSite integer," : ""} + originAttributes text not null default '' + )`, + ); + const insert = database.prepare( + `insert into moz_cookies + (host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, + ${hasRawSameSite ? "rawSameSite," : ""} originAttributes) + values (?, ?, ?, ?, ?, ?, ?, ?, ${hasRawSameSite ? "?," : ""} ?)`, + ); + for (const row of rows) { + insert.run( + row.host, + row.name, + row.value, + row.path, + row.expiry, + row.isSecure, + row.isHttpOnly, + row.sameSite, + ...(hasRawSameSite ? [row.rawSameSite ?? row.sameSite] : []), + row.originAttributes ?? "", + ); + } + database.close(); + return file; +}); + +const run = (effect: Effect.Effect) => + effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); + +describe("readFirefoxCookies", () => { + it.effect("converts millisecond expiries from schema 16 and newer", () => + run( + Effect.gen(function* () { + // Firefox 129 (schema 16) migrated `expiry` to milliseconds; older + // profiles still hold seconds. Both must land as seconds for Electron. + const row = { + host: "example.test", + name: "c", + value: "v", + path: "/", + expiry: 1_800_000_000_000, + isSecure: 0, + isHttpOnly: 0, + sameSite: 0, + }; + const modern = yield* readFirefoxCookies(yield* writeFirefoxCookieDatabase([row], 16)); + expect(modern[0]?.expirationDate).toBe(1_800_000_000); + + const legacy = yield* readFirefoxCookies( + yield* writeFirefoxCookieDatabase([{ ...row, expiry: 1_800_000_000 }], 15), + ); + expect(legacy[0]?.expirationDate).toBe(1_800_000_000); + }), + ), + ); + + it.effect("maps moz_cookies onto the shape Electron accepts", () => + run( + Effect.gen(function* () { + const file = yield* writeFirefoxCookieDatabase([ + { + host: ".github.com", + name: "session", + value: "abc", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 1, + sameSite: 1, + }, + { + host: "example.test", + name: "plain", + value: "v", + path: "/app", + // Firefox writes 0 for a session cookie. + expiry: 0, + isSecure: 0, + isHttpOnly: 0, + sameSite: 0, + }, + ]); + + const cookies = yield* readFirefoxCookies(file); + + expect(cookies).toEqual([ + { + // The leading dot stays on the domain but not in the URL, which is + // what Electron matches against. + url: "https://github.com/", + name: "session", + value: "abc", + domain: ".github.com", + path: "/", + secure: true, + httpOnly: true, + expirationDate: 1_800_000_000, + sameSite: "lax", + }, + { + url: "http://example.test/app", + name: "plain", + value: "v", + // Host-only in Firefox, so no `domain`: supplying one would make + // Electron widen it to every subdomain of example.test. + domain: undefined, + path: "/app", + secure: false, + httpOnly: false, + // Session cookies carry no expiry rather than one at the epoch. + expirationDate: undefined, + sameSite: "no_restriction", + }, + ]); + }), + ), + ); + + it.effect("keeps an unset SameSite unspecified instead of widening it to none", () => + run( + Effect.gen(function* () { + // nsICookie::SAMESITE_UNSET is 256, a cookie that carried no SameSite + // attribute. It is not SAMESITE_NONE (0), which is an explicit opt-in + // to cross-site use; importing it as "none" would widen its scope. + const row = { + host: "example.test", + name: "c", + value: "v", + path: "/", + expiry: 0, + isSecure: 0, + isHttpOnly: 0, + }; + const cookies = yield* readFirefoxCookies( + yield* writeFirefoxCookieDatabase([ + { ...row, name: "unset", sameSite: 256 }, + { ...row, name: "none", sameSite: 0 }, + ]), + ); + expect(cookies.map(({ name, sameSite }) => ({ name, sameSite }))).toEqual([ + { name: "unset", sameSite: "unspecified" }, + { name: "none", sameSite: "no_restriction" }, + ]); + }), + ), + ); + + it.effect("imports rows whose SameSite was never written", () => + run( + Effect.gen(function* () { + // Schema 9 added `sameSite` without a default, so rows from before the + // upgrade hold NULL. One such row must not fail the whole import. + const row = { + host: "example.test", + name: "c", + value: "v", + path: "/", + expiry: 0, + isSecure: 0, + isHttpOnly: 0, + }; + const cookies = yield* readFirefoxCookies( + yield* writeFirefoxCookieDatabase( + [ + { ...row, name: "legacy", sameSite: null }, + { ...row, name: "strict", sameSite: 2 }, + ], + 9, + ), + ); + expect(cookies.map(({ name, sameSite }) => ({ name, sameSite }))).toEqual([ + { name: "legacy", sameSite: "unspecified" }, + { name: "strict", sameSite: "strict" }, + ]); + }), + ), + ); + + it.effect("applies the schema-15 rawSameSite rule to older databases", () => + run( + Effect.gen(function* () { + // Schemas 10–14 defaulted `sameSite` to Lax and kept the declared value + // in `rawSameSite`. Firefox's own migration to 15 turns "Lax by + // default, None declared" into Unset; an unmigrated database has to be + // read the same way or an undeclared cookie becomes an explicit Lax. + const row = { + host: "example.test", + name: "c", + value: "v", + path: "/", + expiry: 0, + isSecure: 0, + isHttpOnly: 0, + }; + const cookies = yield* readFirefoxCookies( + yield* writeFirefoxCookieDatabase( + [ + { ...row, name: "defaulted", sameSite: 1, rawSameSite: 0 }, + { ...row, name: "declared", sameSite: 1, rawSameSite: 1 }, + { ...row, name: "none", sameSite: 0, rawSameSite: 0 }, + ], + 14, + ), + ); + expect(cookies.map(({ name, sameSite }) => ({ name, sameSite }))).toEqual([ + { name: "defaulted", sameSite: "unspecified" }, + { name: "declared", sameSite: "lax" }, + { name: "none", sameSite: "no_restriction" }, + ]); + }), + ), + ); + + it.effect("imports only the default container", () => + run( + Effect.gen(function* () { + const file = yield* writeFirefoxCookieDatabase([ + { + host: "mail.test", + name: "session", + value: "default-container", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 0, + sameSite: 1, + }, + { + // Same host, name and path as above: Firefox keeps these apart by + // container, Electron cannot, so importing both would hand the + // profile whichever one happened to be written last. + host: "mail.test", + name: "session", + value: "work-container", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 0, + sameSite: 1, + originAttributes: "^userContextId=2", + }, + { + host: "mail.test", + name: "private", + value: "private-window", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 0, + sameSite: 1, + originAttributes: "^privateBrowsingId=1", + }, + ]); + + const cookies = yield* readFirefoxCookies(file); + + expect(cookies.map((cookie) => cookie.value)).toEqual(["default-container"]); + }), + ), + ); + + it.effect("reads without mutating the source database", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const file = yield* writeFirefoxCookieDatabase([ + { + host: "a.test", + name: "n", + value: "v", + path: "/", + expiry: 1_800_000_000, + isSecure: 1, + isHttpOnly: 0, + sameSite: 2, + }, + ]); + const before = yield* fileSystem.stat(file); + + yield* readFirefoxCookies(file); + + // The browser's own file is snapshotted, never opened for writing. + const after = yield* fileSystem.stat(file); + expect(after.mtime).toEqual(before.mtime); + expect(after.size).toBe(before.size); + }), + ), + ); +}); + +describe("parseFirefoxProfiles", () => { + it.effect("reads named profiles and ignores Install sections", () => + Effect.gen(function* () { + // `Install*` sections name a default profile but do not describe one, so + // counting them would invent a profile whose directory does not exist. + const parsed = yield* parsePosixFirefoxProfiles( + [ + "[Install4F96D1932A9F858E]", + "Default=Profiles/abcd1234.default-release", + "Locked=1", + "", + "[Profile0]", + "Name=default-release", + "IsRelative=1", + "Path=Profiles/abcd1234.default-release", + "", + "[Profile1]", + "Name=Work", + "IsRelative=0", + "Path=/Volumes/External/firefox-work", + "", + "[General]", + "StartWithLastProfile=1", + ].join("\n"), + ); + + expect(parsed).toEqual([ + { directory: "Profiles/abcd1234.default-release", name: "default-release" }, + { directory: "/Volumes/External/firefox-work", name: "Work" }, + ]); + }), + ); + + it.effect("falls back to the path when a profile has no name", () => + Effect.gen(function* () { + expect( + yield* parsePosixFirefoxProfiles(["[Profile0]", "Path=Profiles/x.default"].join("\n")), + ).toEqual([{ directory: "Profiles/x.default", name: "Profiles/x.default" }]); + }), + ); + + for (const [platform, root] of [ + ["Linux", "/home/user/.mozilla/firefox"], + ["macOS", "/Users/user/Library/Application Support/Firefox"], + ] as const) { + it.effect(`validates relative and absolute ${platform} profile paths`, () => + Effect.gen(function* () { + const parsed = yield* parsePosixFirefoxProfiles( + [ + "[Profile0]", + "Name=Relative", + "IsRelative=1", + "Path=Profiles/relative.default", + "[Profile1]", + "Name=Custom", + "IsRelative=0", + "Path=/mnt/custom/firefox-profile", + "[Profile2]", + "IsRelative=1", + "Path=../../escape", + "[Profile3]", + "IsRelative=1", + "Path=/absolute-marked-relative", + "[Profile4]", + "IsRelative=0", + "Path=relative-marked-absolute", + "[Profile5]", + "IsRelative=1", + "Path=Profiles/nul\u0000escape", + ].join("\n"), + root, + ); + + expect(parsed).toEqual([ + { directory: "Profiles/relative.default", name: "Relative" }, + { directory: "/mnt/custom/firefox-profile", name: "Custom" }, + ]); + }), + ); + } + + it.effect("uses Windows path rules for relative and absolute profiles", () => + Effect.gen(function* () { + const parsed = yield* parseWindowsFirefoxProfiles( + [ + "[Profile0]", + "Name=Relative", + "IsRelative=1", + "Path=Profiles\\relative.default", + "[Profile1]", + "Name=Custom", + "IsRelative=0", + "Path=D:\\Firefox Profiles\\Work", + "[Profile2]", + "IsRelative=1", + "Path=..\\..\\escape", + "[Profile3]", + "IsRelative=1", + "Path=D:\\absolute-marked-relative", + "[Profile4]", + "IsRelative=0", + "Path=relative-marked-absolute", + ].join("\n"), + ); + + expect(parsed).toEqual([ + { directory: "Profiles\\relative.default", name: "Relative" }, + { directory: "D:\\Firefox Profiles\\Work", name: "Custom" }, + ]); + }), + ); +}); diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts new file mode 100644 index 000000000000..f757f1ce01f5 --- /dev/null +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts @@ -0,0 +1,169 @@ +/** + * Firefox cookie extraction. + * + * Firefox stores cookies unencrypted in `cookies.sqlite`, so there is no key + * to fetch and no consent prompt — the file is readable by anything running as + * the user. That is Mozilla's design choice, not a control being circumvented, + * which is why this path works identically on macOS, Windows, and Linux while + * the Chromium one needs a per-platform credential store. + * + * @module FirefoxCookies + */ +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { cookieScope, snapshotCookieDatabase, type ImportedCookie } from "./CookieDatabase.ts"; + +/** + * Mirrors `ChromiumCookieReadError` so both engines fail with a tagged error + * the service can tell apart, rather than one of them widening the channel to + * an anonymous shape. + * + * No `reason` field: unlike Chromium there is only one way this fails — the + * plaintext database would not open — and the tag already says which engine it + * was. `BrowserImport` supplies the user-facing reason when it maps the union. + */ +export class FirefoxCookieReadError extends Schema.TaggedErrorClass()( + "FirefoxCookieReadError", + { + /** + * Which database the read was for. Firefox keeps one per profile, so + * without it a failure cannot be traced back to the profile that caused + * it. + */ + cookieDatabasePath: Schema.String, + /** Always present: every construction site wraps a real failure. */ + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not read Firefox cookies at ${this.cookieDatabasePath}.`; + } +} + +/** + * `moz_cookies.sameSite` holds nsICookie's constants: 0 = None, 1 = Lax, + * 2 = Strict, and 256 = Unset for a cookie that carried no SameSite attribute + * at all. Unset is not the same thing as None — None is an explicit opt-in to + * cross-site use — so it is imported as Electron's `unspecified`, which lets + * the target browser apply its own default exactly as Firefox did. Anything + * unrecognised also lands there rather than on `no_restriction`, since + * guessing "none" would widen a cookie's scope on import. + */ +const SAMESITE_NONE = 0; +const SAMESITE_LAX = 1; +const SAMESITE_STRICT = 2; + +/** + * Schemas 10–14 carried a second column, `rawSameSite`: the value the cookie + * actually declared, beside a `sameSite` that Firefox had already defaulted to + * Lax. The schema-15 migration folded them back together with + * `sameSite = UNSET where sameSite = LAX and rawSameSite = NONE`, i.e. a row + * that "is Lax" only because nothing was declared. Reading such a database + * before Firefox has migrated it must apply the same rule, or an undeclared + * cookie is imported as an explicit Lax. + */ +const FIREFOX_RAW_SAMESITE_FIRST_SCHEMA = 10; +const FIREFOX_RAW_SAMESITE_LAST_SCHEMA = 14; + +const sameSiteFromColumn = ( + value: number | null, + rawValue: number | null, +): ImportedCookie["sameSite"] => { + // Schema 9 added the column with no default, so older rows carry NULL. + if (value === null) return "unspecified"; + if (value === SAMESITE_LAX && rawValue === SAMESITE_NONE) return "unspecified"; + if (value === SAMESITE_NONE) return "no_restriction"; + if (value === SAMESITE_LAX) return "lax"; + if (value === SAMESITE_STRICT) return "strict"; + return "unspecified"; +}; + +const CookieRow = Schema.Struct({ + host: Schema.String, + name: Schema.String, + value: Schema.String, + path: Schema.String, + // UNIX-epoch based, unlike Chromium's 1601-based microseconds — but the + // unit depends on the schema version; see `expiryToSeconds`. + expiry: Schema.Number, + isSecure: Schema.Number, + isHttpOnly: Schema.Number, + sameSite: Schema.NullOr(Schema.Number), + // Present only for schemas 10–14; selected as NULL elsewhere. + rawSameSite: Schema.NullOr(Schema.Number), +}); +const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow)); + +/** + * Firefox schema 16 (Firefox 129) moved `expiry` from seconds to milliseconds + * — the migration is `UPDATE moz_cookies SET expiry = expiry * 1000`. Electron + * wants seconds, so the unit is decided by `PRAGMA user_version` rather than + * assumed: importing a pre-16 profile as milliseconds would expire every cookie + * at once, and a post-16 one as seconds would keep them for ~1000× too long. + */ +const FIREFOX_EXPIRY_MILLISECONDS_SCHEMA = 16; + +const UserVersionRow = Schema.Struct({ user_version: Schema.Number }); +const decodeUserVersion = Schema.decodeUnknownEffect(Schema.Array(UserVersionRow)); + +const expiryToSeconds = (expiry: number, schemaVersion: number): number | undefined => { + if (expiry <= 0) return undefined; + return schemaVersion >= FIREFOX_EXPIRY_MILLISECONDS_SCHEMA ? Math.floor(expiry / 1000) : expiry; +}; + +export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies")(function* ( + cookieDatabasePath: string, +) { + const snapshotPath = yield* snapshotCookieDatabase(cookieDatabasePath).pipe( + Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })), + ); + + const { rows, schemaVersion } = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const [versionRow] = yield* decodeUserVersion(yield* sql`pragma user_version`); + const schemaVersion = versionRow?.user_version ?? 0; + const hasRawSameSite = + schemaVersion >= FIREFOX_RAW_SAMESITE_FIRST_SCHEMA && + schemaVersion <= FIREFOX_RAW_SAMESITE_LAST_SCHEMA; + // Only the default container. Firefox isolates cookies per container and + // per private window via `originAttributes` (`^userContextId=2`, + // `^privateBrowsingId=1`); Electron has no equivalent, so importing them + // all would collapse several identities onto one host/name/path and hand + // the profile an arbitrary container's session. + const raw = hasRawSameSite + ? yield* sql` + select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, rawSameSite + from moz_cookies + where originAttributes = '' + ` + : yield* sql` + select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, + null as rawSameSite + from moz_cookies + where originAttributes = '' + `; + return { rows: yield* decodeCookieRows(raw), schemaVersion }; + }).pipe( + Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })), + Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })), + ); + + return rows.map((row) => { + const secure = row.isSecure === 1; + const scope = cookieScope(row.host, row.path, secure); + return { + url: scope.url, + name: row.name, + value: row.value, + domain: scope.domain, + path: row.path, + secure, + httpOnly: row.isHttpOnly === 1, + expirationDate: expiryToSeconds(row.expiry, schemaVersion), + sameSite: sameSiteFromColumn(row.sameSite, row.rawSameSite), + } satisfies ImportedCookie; + }); +}); diff --git a/apps/desktop/src/preview/BrowserImport/Sources.test.ts b/apps/desktop/src/preview/BrowserImport/Sources.test.ts index 00757b3d4d4b..fe408bb2d4dd 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.test.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.test.ts @@ -2,23 +2,32 @@ // table with the same native bindings the source reads. import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; -import { HostProcessEnvironment, HostProcessHostname } from "@t3tools/shared/hostProcess"; +import { + HostProcessEnvironment, + 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 Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as NodeSqlite from "node:sqlite"; +import type { BrowserImportPathContext } from "./Sources.ts"; import { BROWSER_IMPORT_SOURCES, chromiumProcessIsAlive, chromiumSingletonLockIsHeld, cookieDatabaseCandidatePaths, + firefoxSymlinkLockIsHeld, resolveCookieDatabase, isSourceInstalled, isSourceRunning, + posixLockIsHeld, listSourceProfiles, - sourcePaths, + sourcePathContext, } from "./Sources.ts"; const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; @@ -27,15 +36,28 @@ const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!; const withSourceHome = Effect.fnUntraced(function* () { const fileSystem = yield* FileSystem.FileSystem; const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-sources-" }); - const paths = yield* sourcePaths.pipe( + const context = yield* sourcePathContext.pipe( Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), ); - yield* fileSystem.makeDirectory(helium.userDataDirectory(paths), { recursive: true }); - return paths; + yield* fileSystem.makeDirectory(userDataDirectory(context), { recursive: true }); + return context; }); -const run = (effect: Effect.Effect) => - effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); +/** Every case here runs on darwin, where Helium always resolves a directory. */ +const userDataDirectory = (context: BrowserImportPathContext) => { + const root = helium.userDataDirectory(context); + if (root === undefined) throw new Error("Helium has no macOS user-data directory"); + return root; +}; + +const run = ( + effect: Effect.Effect< + A, + E, + FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner + >, +) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); /** Writes a Chromium-shaped cookie table with `count` rows. */ const writeCookieDatabase = (file: string, count: number) => @@ -47,23 +69,37 @@ const writeCookieDatabase = (file: string, count: number) => database.close(); }); +const writeFirefoxCookieDatabase = ( + file: string, + defaultContainerCount: number, + containerCount: number, +) => + Effect.sync(() => { + const database = new NodeSqlite.DatabaseSync(file); + database.exec("create table moz_cookies (originAttributes text not null)"); + const insert = database.prepare("insert into moz_cookies (originAttributes) values (?)"); + for (let index = 0; index < defaultContainerCount; index += 1) insert.run(""); + for (let index = 0; index < containerCount; index += 1) insert.run("^userContextId=2"); + database.close(); + }); + describe("isSourceRunning", () => { it.effect("reads Chromium's dangling SingletonLock symlink as a running browser", () => run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); - assert.isFalse(yield* isSourceRunning(helium, paths)); + const context = yield* withSourceHome(); + assert.isFalse(yield* isSourceRunning(helium, context)); // Chromium points the lock at `-`, a target that never // exists on disk. A check that follows the link reports a running // browser as closed, letting an import read a live, mid-write database. yield* fileSystem.symlink( "host-that-does-not-exist-1234", - `${helium.userDataDirectory(paths)}/SingletonLock`, + `${userDataDirectory(context)}/SingletonLock`, ); - assert.isTrue(yield* isSourceRunning(helium, paths)); + assert.isTrue(yield* isSourceRunning(helium, context)); }), ), ); @@ -172,28 +208,49 @@ describe("isSourceInstalled", () => { run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); - const root = helium.userDataDirectory(paths); + const context = yield* withSourceHome(); + const root = userDataDirectory(context); // Installers for native messaging hosts create an empty user-data // directory for every Chromium fork they know about, so treating the // directory as evidence lists browsers the user does not have. yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); - assert.isFalse(yield* isSourceInstalled(helium, paths)); + assert.isFalse(yield* isSourceInstalled(helium, context)); yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); - assert.isTrue(yield* isSourceInstalled(helium, paths)); + assert.isTrue(yield* isSourceInstalled(helium, context)); // A real install whose cookies live outside `Default` still counts: // reporting it as absent hides the source from the menu entirely. yield* fileSystem.remove(`${root}/Default`, { recursive: true }); yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true }); yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db"); - assert.isTrue(yield* isSourceInstalled(helium, paths)); + assert.isTrue(yield* isSourceInstalled(helium, context)); yield* fileSystem.remove(root, { recursive: true }); - assert.isFalse(yield* isSourceInstalled(helium, paths)); + assert.isFalse(yield* isSourceInstalled(helium, context)); + }), + ), + ); + + it.effect("follows cookie database symlinks when detecting profiles", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const context = yield* withSourceHome(); + const root = userDataDirectory(context); + yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); + yield* fileSystem.symlink("missing-cookies", `${root}/Default/Cookies`); + + assert.deepEqual(yield* listSourceProfiles(helium, context), []); + assert.isFalse(yield* isSourceInstalled(helium, context)); + + yield* fileSystem.writeFileString(`${root}/Default/missing-cookies`, "db"); + assert.deepEqual(yield* listSourceProfiles(helium, context), [ + { directory: "Default", name: "Default" }, + ]); + assert.isTrue(yield* isSourceInstalled(helium, context)); }), ), ); @@ -224,15 +281,15 @@ describe("listSourceProfiles", () => { run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); - const root = helium.userDataDirectory(paths); + const context = yield* withSourceHome(); + const root = userDataDirectory(context); // Assuming `Default` would report a browser whose cookies live in // `Profile 1` as having nothing to import, and it is then hidden. yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true }); yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db"); yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true }); - assert.deepEqual(yield* listSourceProfiles(helium, paths), [ + assert.deepEqual(yield* listSourceProfiles(helium, context), [ { directory: "Profile 1", name: "Profile 1" }, ]); }), @@ -243,13 +300,13 @@ describe("listSourceProfiles", () => { run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); + const context = yield* withSourceHome(); yield* fileSystem.writeFileString( - `${helium.userDataDirectory(paths)}/Local State`, + `${userDataDirectory(context)}/Local State`, `{"profile":{"info_cache":{"Default":{"name":"You"},"Profile 2":{"name":" "}}}}`, ); - assert.deepEqual(yield* listSourceProfiles(helium, paths), [ + assert.deepEqual(yield* listSourceProfiles(helium, context), [ { directory: "Default", name: "You" }, // Blank display name falls back to the directory rather than // rendering an empty row. @@ -263,13 +320,13 @@ describe("listSourceProfiles", () => { run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); - const root = helium.userDataDirectory(paths); + const context = yield* withSourceHome(); + const root = userDataDirectory(context); yield* fileSystem.writeFileString(`${root}/Local State`, "{not-json"); yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true }); yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db"); - assert.deepEqual(yield* listSourceProfiles(helium, paths), [ + assert.deepEqual(yield* listSourceProfiles(helium, context), [ { directory: "Default", name: "Default" }, ]); }), @@ -279,8 +336,8 @@ describe("listSourceProfiles", () => { it.effect("reports nothing when no directory holds a cookie database", () => run( Effect.gen(function* () { - const paths = yield* withSourceHome(); - assert.deepEqual(yield* listSourceProfiles(helium, paths), []); + const context = yield* withSourceHome(); + assert.deepEqual(yield* listSourceProfiles(helium, context), []); }), ), ); @@ -305,9 +362,9 @@ describe("cookieDatabaseCandidatePaths", () => { it.effect("prefers Network/Cookies and falls back to the legacy Cookies", () => run( Effect.gen(function* () { - const paths = yield* withSourceHome(); - const profile = `${paths.home}/Library/Application Support/net.imput.helium/Profile 1`; - assert.deepEqual(cookieDatabaseCandidatePaths(helium, paths, "Profile 1"), [ + const context = yield* withSourceHome(); + const profile = `${context.home}/Library/Application Support/net.imput.helium/Profile 1`; + assert.deepEqual(cookieDatabaseCandidatePaths(helium, context, "Profile 1"), [ `${profile}/Network/Cookies`, `${profile}/Cookies`, ]); @@ -319,8 +376,8 @@ describe("cookieDatabaseCandidatePaths", () => { run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); - const root = helium.userDataDirectory(paths); + const context = yield* withSourceHome(); + const root = helium.userDataDirectory(context); // Chromium 96+ keeps sessions in Network/; a root Cookies left behind // by the move is stale and must not be the one imported. yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true }); @@ -328,15 +385,312 @@ describe("cookieDatabaseCandidatePaths", () => { yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "stale"); assert.equal( - yield* resolveCookieDatabase(helium, paths, "Default"), + yield* resolveCookieDatabase(helium, context, "Default"), `${root}/Default/Network/Cookies`, ); // A fresh install with only the Network/ jar is installed, not hidden. yield* fileSystem.remove(`${root}/Default/Cookies`); - assert.isTrue(yield* isSourceInstalled(helium, paths)); + assert.isTrue(yield* isSourceInstalled(helium, context)); + }), + ), + ); +}); + +const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!; + +describe("Firefox Snap profiles", () => { + it.effect("finds Snap profiles with or without profiles.ini and checks their locks", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-snap-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "linux"), + ); + const root = `${home}/snap/firefox/common/.mozilla/firefox`; + const directory = `${root}/abcd.default`; + yield* fileSystem.makeDirectory(directory, { recursive: true }); + yield* writeFirefoxCookieDatabase(`${directory}/cookies.sqlite`, 2, 1); + yield* fileSystem.writeFileString( + `${root}/profiles.ini`, + "[Profile0]\nName=Personal\nIsRelative=1\nPath=abcd.default\n", + ); + + assert.isTrue(yield* isSourceInstalled(firefox, context)); + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { directory, name: "Personal", cookieCount: 2 }, + ]); + assert.equal( + yield* resolveCookieDatabase(firefox, context, directory), + `${directory}/cookies.sqlite`, + ); + assert.isFalse(yield* isSourceRunning(firefox, context)); + yield* fileSystem.symlink("foreign-host:+4242", `${directory}/lock`); + assert.isTrue(yield* isSourceRunning(firefox, context)); + yield* fileSystem.remove(`${directory}/lock`); + assert.isFalse(yield* isSourceRunning(firefox, context)); + + yield* fileSystem.remove(`${root}/profiles.ini`); + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { directory, name: "abcd.default", cookieCount: 2 }, + ]); + }), + ), + ); + + it.effect("keeps matching profile names in native and Snap installs distinct", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-snap-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "linux"), + ); + const native = `${home}/.mozilla/firefox`; + const snap = `${home}/snap/firefox/common/.mozilla/firefox`; + for (const root of [native, snap]) { + yield* fileSystem.makeDirectory(`${root}/abcd.default`, { recursive: true }); + yield* writeFirefoxCookieDatabase(`${root}/abcd.default/cookies.sqlite`, 1, 0); + yield* fileSystem.writeFileString( + `${root}/profiles.ini`, + "[Profile0]\nName=Personal\nIsRelative=1\nPath=abcd.default\n" + + `[Profile1]\nName=Shared\nIsRelative=0\nPath=${snap}/abcd.default\n`, + ); + } + + const profiles = yield* listSourceProfiles(firefox, context); + assert.deepEqual( + profiles.map((profile) => profile.directory), + ["abcd.default", `${snap}/abcd.default`], + ); + const databases = yield* Effect.forEach(profiles, (profile) => + resolveCookieDatabase(firefox, context, profile.directory), + ); + assert.deepEqual(databases, [ + `${native}/abcd.default/cookies.sqlite`, + `${snap}/abcd.default/cookies.sqlite`, + ]); + }), + ), + ); +}); + +describe("listSourceProfiles Firefox fallback", () => { + const cases = [ + { platform: "linux" as const, profileDirectory: "linux.default" }, + { platform: "darwin" as const, profileDirectory: "Profiles/macos.default" }, + { platform: "win32" as const, profileDirectory: "Profiles/windows.default" }, + ]; + + for (const { platform, profileDirectory } of cases) { + it.effect(`scans the ${platform} profile location and excludes stale entries`, () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fileSystem.makeTempDirectoryScoped({ + prefix: `t3code-firefox-${platform}-`, + }); + const appData = path.join(home, "AppData", "Roaming"); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { + HOME: home, + APPDATA: appData, + }), + Effect.provideService(HostProcessPlatform, platform), + ); + const root = firefox.userDataDirectory(context)!; + const scanRoot = platform === "linux" ? root : path.join(root, "Profiles"); + yield* fileSystem.makeDirectory(path.join(root, profileDirectory), { recursive: true }); + yield* fileSystem.writeFileString( + path.join(root, profileDirectory, "cookies.sqlite"), + "db", + ); + yield* fileSystem.makeDirectory(path.join(scanRoot, "stale.default"), { + recursive: true, + }); + yield* fileSystem.writeFileString(path.join(scanRoot, "stale-file.default"), "not-dir"); + + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { + directory: profileDirectory, + name: path.basename(profileDirectory), + }, + ]); + }), + ), + ); + } + + it.effect("counts only importable cookies for declared and fallback profiles", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3code-firefox-counts-", + }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + const root = firefox.userDataDirectory(context)!; + const declaredDirectory = path.join(root, "Profiles", "declared.default"); + yield* fileSystem.makeDirectory(declaredDirectory, { recursive: true }); + yield* writeFirefoxCookieDatabase(path.join(declaredDirectory, "cookies.sqlite"), 2, 3); + yield* fileSystem.writeFileString( + path.join(root, "profiles.ini"), + ["[Profile0]", "Name=Declared", "IsRelative=1", "Path=Profiles/declared.default"].join( + "\n", + ), + ); + + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { directory: "Profiles/declared.default", name: "Declared", cookieCount: 2 }, + ]); + + yield* fileSystem.remove(path.join(root, "profiles.ini")); + const fallbackDirectory = path.join(root, "Profiles", "fallback.default"); + yield* fileSystem.makeDirectory(fallbackDirectory, { recursive: true }); + yield* writeFirefoxCookieDatabase(path.join(fallbackDirectory, "cookies.sqlite"), 1, 4); + + assert.deepEqual(yield* listSourceProfiles(firefox, context), [ + { directory: "Profiles/declared.default", name: "declared.default", cookieCount: 2 }, + { directory: "Profiles/fallback.default", name: "fallback.default", cookieCount: 1 }, + ]); + }), + ), + ); +}); + +describe("isSourceRunning for Firefox", () => { + it.effect("finds the lock inside the profile, not at the root", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + const root = firefox.userDataDirectory(context)!; + const profile = `${root}/Profiles/abcd.default-release`; + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); + + assert.isFalse(yield* isSourceRunning(firefox, context)); + + // Firefox keeps its locks per profile. A root-level lock is not one, + // and looking there was why a running Firefox read as importable. + yield* fileSystem.writeFileString(`${root}/lock`, ""); + assert.isFalse(yield* isSourceRunning(firefox, context)); + + // `.parentlock` is deliberately left on disk after a clean exit as a + // last-used marker, so an unlocked one is not evidence of a running + // browser — treating it as one blocked every import after first use. + yield* fileSystem.writeFileString(`${profile}/.parentlock`, ""); + assert.isFalse(yield* isSourceRunning(firefox, context)); + + // The `lock` symlink is what Firefox removes on exit; a live pid in + // its target means the profile is held. + yield* fileSystem.symlink(`127.0.0.1:+${process.pid}`, `${profile}/lock`); + assert.isTrue(yield* isSourceRunning(firefox, context)); + }), + ), + ); + + it.effect("reports not-held when no interpreter can run the fcntl probe", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-lock-" }); + const lock = `${directory}/.parentlock`; + yield* fileSystem.writeFileString(lock, ""); + // A Mac without the developer tools has only Apple's shim, which + // refuses to run the script; a machine with no python at all has + // nothing. Either way the probe is unavailable, not the lock held — + // treating it as held would block Firefox import on that machine for + // good. + assert.isFalse(yield* posixLockIsHeld(lock, ["/nonexistent/python3"])); + // And a fake "interpreter" that exits non-zero without a verdict, as + // the shim does, is the same case. + assert.isFalse(yield* posixLockIsHeld(lock, ["/usr/bin/false"])); + }), + ), + ); + + it.effect("detects a live fcntl lock on .parentlock, as macOS Firefox leaves it", () => + run( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); + const context = yield* sourcePathContext.pipe( + Effect.provideService(HostProcessEnvironment, { HOME: home }), + Effect.provideService(HostProcessPlatform, "darwin"), + ); + const root = firefox.userDataDirectory(context)!; + const profile = `${root}/Profiles/abcd.default-release`; + yield* fileSystem.makeDirectory(profile, { recursive: true }); + yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); + const parentLock = `${profile}/.parentlock`; + yield* fileSystem.writeFileString(parentLock, ""); + + // Hold the lock from a child the way Firefox does (F_SETLK, write), + // and keep it until the scope closes. + const holder = yield* spawner.spawn( + ChildProcess.make( + "python3", + [ + "-c", + "import fcntl,os,sys,time\n" + + "fd=os.open(sys.argv[1],os.O_WRONLY)\n" + + "fcntl.lockf(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n" + + "print('locked',flush=True)\n" + + "time.sleep(30)", + parentLock, + ], + { stdin: "ignore" }, + ), + ); + // Wait for the child to confirm it holds the lock before probing. + yield* holder.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.filter((line) => line.trim() === "locked"), + Stream.take(1), + Stream.runDrain, + ); + + assert.isTrue(yield* isSourceRunning(firefox, context)); + yield* holder.kill(); }), ), ); + + it.effect("reads a Firefox lock symlink's pid to tell live from crashed", () => + Effect.gen(function* () { + const alive = (pid: number) => Effect.succeed(pid === 4242); + // The resolver may hand Firefox any of the machine's addresses, not + // just 127.0.0.1 — 127.0.1.1 on Debian-style hosts, a LAN address + // elsewhere — so every local address counts as ours. + const local = new Set(["127.0.0.1", "127.0.1.1", "192.168.1.20"]); + // Both the plain and the fcntl-marked (`+`) forms carry the pid. + assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.0.1:4242", local, alive)); + assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.1.1:+4242", local, alive)); + assert.isTrue(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+4242", local, alive)); + // A crash leaves the symlink behind with a dead pid, on any local address. + assert.isFalse(yield* firefoxSymlinkLockIsHeld("127.0.0.1:+9999", local, alive)); + assert.isFalse(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+9999", local, alive)); + // Anything unparseable stays conservative. + assert.isTrue(yield* firefoxSymlinkLockIsHeld("garbage", local, alive)); + // A foreign owner (a shared profile locked from another machine) names + // a pid we cannot probe, so it is held regardless of local liveness. + assert.isTrue(yield* firefoxSymlinkLockIsHeld("10.0.0.7:+9999", local, alive)); + }), + ); }); describe("listSourceProfiles hardening", () => { @@ -344,16 +698,16 @@ describe("listSourceProfiles hardening", () => { run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; - const paths = yield* withSourceHome(); + const context = yield* withSourceHome(); // `Local State` is writable by anything running as the user, so a // crafted key must not reach `cookieDatabasePath` and read a database // outside the browser's user-data directory. yield* fileSystem.writeFileString( - `${helium.userDataDirectory(paths)}/Local State`, + `${userDataDirectory(context)}/Local State`, `{"profile":{"info_cache":{"Default":{"name":"You"},"../../../../secrets":{"name":"Escape"},"a/b":{"name":"Nested"},"..":{"name":"Parent"}}}}`, ); - const profiles = yield* listSourceProfiles(helium, paths); + const profiles = yield* listSourceProfiles(helium, context); assert.deepEqual( profiles.map((profile) => profile.directory), diff --git a/apps/desktop/src/preview/BrowserImport/Sources.ts b/apps/desktop/src/preview/BrowserImport/Sources.ts index 71c0624d9051..632535fbd008 100644 --- a/apps/desktop/src/preview/BrowserImport/Sources.ts +++ b/apps/desktop/src/preview/BrowserImport/Sources.ts @@ -1,56 +1,171 @@ /** * Importable browser sources. * - * Each entry pins its own on-disk and keychain coordinates rather than - * deriving them: Chromium forks do not agree on the convention. Helium, for - * instance, uses the keychain service "Helium Storage Key" / account "Helium" - * where Chrome and its closer relatives use " Safe Storage" / "". + * Two engines are modelled. 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. + * + * Each entry pins its own paths and keychain coordinates rather than deriving + * them, because the forks do not agree. Helium uses the keychain service + * "Helium Storage Key" / account "Helium" where Chrome and its closer + * relatives use " Safe Storage" / "", and the user-data directory + * differs per fork and per platform. * * @module BrowserImportSources */ import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; -import { HostProcessEnvironment, HostProcessHostname } from "@t3tools/shared/hostProcess"; +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"; + /** - * Where a source's files live, resolved once per call rather than read from - * the ambient process so the registry stays testable. + * 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 SourcePaths { +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 const sourcePaths = Effect.gen(function* () { - const path = yield* Path.Path; - const environment = yield* HostProcessEnvironment; - return { path, home: environment.HOME ?? environment.USERPROFILE ?? "" } satisfies SourcePaths; -}); - export interface BrowserImportSourceDefinition { readonly id: BrowserImportSourceId; readonly name: string; - /** Platforms the definition's paths are valid for. */ + readonly engine: BrowserImportEngine; + /** Platforms the definition has paths for. */ readonly platforms: ReadonlyArray; - readonly userDataDirectory: (paths: SourcePaths) => string; + readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined; + /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */ + readonly keychainService?: string; + readonly keychainAccount?: string; +} + +const macApplicationSupport = ( + context: BrowserImportPathContext, + ...segments: ReadonlyArray +) => context.path.join(context.home, "Library", "Application Support", ...segments); + +/** + * One Chromium fork on macOS and Linux. The leaves differ per fork; omitting a + * platform's segments marks the fork as unavailable there. Windows is left out + * on purpose: since Chrome 127 those cookies are encrypted to the browser's own + * identity (App-Bound Encryption), so no other process can read them. + */ +const chromiumSource = (input: { + readonly id: BrowserImportSourceId; + readonly name: string; readonly keychainService: string; readonly keychainAccount: string; -} + readonly macSegments: ReadonlyArray; + readonly linuxSegments?: ReadonlyArray; +}): BrowserImportSourceDefinition => ({ + id: input.id, + name: input.name, + engine: "chromium", + platforms: [ + "darwin" as NodeJS.Platform, + ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []), + ], + keychainService: input.keychainService, + keychainAccount: input.keychainAccount, + userDataDirectory: (context) => { + if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments); + return input.linuxSegments + ? context.path.join(context.home, ".config", ...input.linuxSegments) + : undefined; + }, +}); export const BROWSER_IMPORT_SOURCES: ReadonlyArray = [ - { + chromiumSource({ + id: "chrome", + name: "Chrome", + keychainService: "Chrome Safe Storage", + keychainAccount: "Chrome", + macSegments: ["Google", "Chrome"], + linuxSegments: ["google-chrome"], + }), + chromiumSource({ + id: "edge", + name: "Microsoft Edge", + keychainService: "Microsoft Edge Safe Storage", + keychainAccount: "Microsoft Edge", + macSegments: ["Microsoft Edge"], + linuxSegments: ["microsoft-edge"], + }), + chromiumSource({ + id: "brave", + name: "Brave", + keychainService: "Brave Safe Storage", + keychainAccount: "Brave", + macSegments: ["BraveSoftware", "Brave-Browser"], + linuxSegments: ["BraveSoftware", "Brave-Browser"], + }), + chromiumSource({ + id: "vivaldi", + name: "Vivaldi", + keychainService: "Vivaldi Safe Storage", + keychainAccount: "Vivaldi", + macSegments: ["Vivaldi"], + linuxSegments: ["vivaldi"], + }), + chromiumSource({ + id: "opera", + name: "Opera", + keychainService: "Opera Safe Storage", + keychainAccount: "Opera", + macSegments: ["com.operasoftware.Opera"], + linuxSegments: ["opera"], + }), + // Arc and Helium ship macOS-only builds. + chromiumSource({ + id: "arc", + name: "Arc", + keychainService: "Arc Safe Storage", + keychainAccount: "Arc", + macSegments: ["Arc", "User Data"], + }), + chromiumSource({ id: "helium", name: "Helium", - platforms: ["darwin"], - userDataDirectory: ({ path, home }) => - path.join(home, "Library", "Application Support", "net.imput.helium"), keychainService: "Helium Storage Key", keychainAccount: "Helium", + macSegments: ["net.imput.helium"], + }), + { + 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"); + }, }, ]; @@ -60,28 +175,117 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray => { - const profile = paths.path.join(definition.userDataDirectory(paths), profileDirectory); - return [paths.path.join(profile, "Network", "Cookies"), paths.path.join(profile, "Cookies")]; + const root = definition.userDataDirectory(context); + if (root === undefined) return []; + const profile = context.path.isAbsolute(profileDirectory) + ? profileDirectory + : context.path.join(root, profileDirectory); + if (definition.engine === "firefox") return [context.path.join(profile, "cookies.sqlite")]; + return [context.path.join(profile, "Network", "Cookies"), context.path.join(profile, "Cookies")]; }; /** The first candidate that is a regular file, or undefined when none is. */ export const resolveCookieDatabase = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, - paths: SourcePaths, + context: BrowserImportPathContext, profileDirectory: string, ) { - for (const candidate of cookieDatabaseCandidatePaths(definition, paths, profileDirectory)) { + 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 { + 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( @@ -106,21 +310,25 @@ const CookieCountRow = Schema.Struct({ count: Schema.Number }); const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow)); /** - * How many cookies a profile holds, counted without decrypting anything — a - * bare `COUNT(*)` needs no key. Best effort: a locked, missing or non-Chromium - * database (Firefox's table is named differently, Safari's is not SQL) yields - * `undefined` rather than failing the listing. + * 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, - paths: SourcePaths, + context: BrowserImportPathContext, directory: string, ): Effect.fn.Return { - const database = yield* resolveCookieDatabase(definition, paths, directory); + const database = yield* resolveCookieDatabase(definition, context, directory); if (database === undefined) return undefined; return yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; - const rows = yield* sql`select count(*) as count from cookies`; + 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( @@ -131,11 +339,11 @@ const countProfileCookies = Effect.fnUntraced(function* ( const withCookieCounts = ( definition: BrowserImportSourceDefinition, - paths: SourcePaths, + context: BrowserImportPathContext, profiles: ReadonlyArray, ) => Effect.forEach(profiles, (profile) => - countProfileCookies(definition, paths, profile.directory).pipe( + countProfileCookies(definition, context, profile.directory).pipe( Effect.map((cookieCount) => cookieCount === undefined ? profile : { ...profile, cookieCount }, ), @@ -143,26 +351,54 @@ const withCookieCounts = ( ); /** - * Profiles the source browser knows about, read from its `Local State`. + * Profiles the source browser knows about. * - * When that file is missing, unreadable or malformed, the user-data directory - * is scanned for directories that hold a cookie database. Assuming `Default` - * instead would report a browser whose cookies live in `Profile 1` as having + * 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. */ -export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* ( +const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( definition: BrowserImportSourceDefinition, - paths: SourcePaths, -) { + context: BrowserImportPathContext, +): Effect.fn.Return, never, FileSystem.FileSystem> { const fileSystem = yield* FileSystem.FileSystem; - const localStatePath = paths.path.join(definition.userDataDirectory(paths), "Local State"); + const root = definition.userDataDirectory(context); + if (root === undefined) return []; + + 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), + ); + if (declared.length > 0) return yield* withCookieCounts(definition, context, declared); + + // Linux keeps profile directories directly under the Firefox root. macOS + // and Windows put them in `Profiles/`. + const fallbackDirectory = + context.platform === "linux" ? root : context.path.join(root, "Profiles"); + const entries = yield* fileSystem + .readDirectory(fallbackDirectory) + .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); + const found = yield* Effect.forEach(entries, (entry) => { + const directory = context.platform === "linux" ? entry : context.path.join("Profiles", entry); + return resolveCookieDatabase(definition, context, directory).pipe( + Effect.map((database) => (database === undefined ? undefined : { directory, name: entry })), + ); + }); + return yield* withCookieCounts( + definition, + context, + found.filter((profile) => profile !== undefined), + ); + } - const root = definition.userDataDirectory(paths); - const declared = yield* fileSystem.readFileString(localStatePath).pipe( + const declared = yield* fileSystem.readFileString(context.path.join(root, "Local State")).pipe( Effect.flatMap(decodeLocalState), Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})), // The keys are directory names from the browser's own metadata file, which - // is writable by anything running as the user. Anything but a single plain + // anything running as the user can write. Anything but a single plain // segment is dropped: `..` or a path separator would otherwise be handed // to `cookieDatabasePath` and read a database outside the user-data // directory. @@ -172,18 +408,15 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf ), Effect.orElseSucceed(() => [] as ReadonlyArray), ); - if (declared.length > 0) return yield* withCookieCounts(definition, paths, declared); + if (declared.length > 0) return yield* withCookieCounts(definition, context, declared); - // `Local State` is missing, unreadable or malformed. Scanning for - // directories that hold a cookie database finds the profiles anyway; - // assuming `Default` would report a browser whose cookies live in - // `Profile 1` as having nothing to import, and it is then hidden entirely. + // `Local State` is missing, unreadable or malformed. Scanning for directories + // that hold a cookie database finds the profiles anyway. const entries = yield* fileSystem .readDirectory(root) .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray)); - const candidates = entries.filter(isSafeProfileDirectory); - const found = yield* Effect.forEach(candidates, (directory) => - resolveCookieDatabase(definition, paths, directory).pipe( + const found = yield* Effect.forEach(entries.filter(isSafeProfileDirectory), (directory) => + resolveCookieDatabase(definition, context, directory).pipe( Effect.map((database) => database === undefined ? undefined : { directory, name: directory }, ), @@ -191,11 +424,46 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf ); return yield* withCookieCounts( definition, - paths, + context, found.filter((profile) => profile !== undefined), ); }); +/** + * Include Firefox's Snap home alongside its native home. Snap profiles use + * absolute directories so cookie reads and lock checks keep pointing at the + * installation they came from, even when both installs use the same name. + */ +export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* ( + definition: BrowserImportSourceDefinition, + context: BrowserImportPathContext, +): Effect.fn.Return, never, FileSystem.FileSystem> { + if (definition.engine !== "firefox" || context.platform !== "linux") { + return yield* listSourceProfilesInDirectory(definition, context); + } + + const root = definition.userDataDirectory(context); + if (root === undefined) return []; + const roots = [ + root, + context.path.join(context.home, "snap", "firefox", "common", ".mozilla", "firefox"), + ]; + const profiles = new Map(); + for (const directory of roots) { + const found = yield* listSourceProfilesInDirectory( + { ...definition, userDataDirectory: () => directory }, + context, + ); + for (const profile of found) { + const absolute = context.path.resolve(directory, profile.directory); + if (!profiles.has(absolute)) { + profiles.set(absolute, directory === root ? profile : { ...profile, directory: absolute }); + } + } + } + return [...profiles.values()]; +}); + /** * Whether a cookie database candidate is a regular file. Presence alone is * not enough: a directory at the path would list as an importable profile and @@ -254,28 +522,200 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* ( return yield* isProcessAlive(pid); }); +/** Windows sharing and lock violations are translated by libuv to `Busy`. */ +export const isWindowsLockHeldError = (error: PlatformError.PlatformError): boolean => + error.reason._tag === "Busy"; + +/** + * Whether a Windows `parent.lock` is actually held by a running process. It + * is opened with no sharing, so it persists on disk after the process exits + * and `stat` always succeeds; only trying to open it for write reveals an + * active holder, which surfaces as `Busy`. + */ +const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) { + // Permission failures are distinct: they do not prove a browser owns the + // lock, so they must not hide the source as running. + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.open(lockPath, { flag: "r+" }).pipe( + Effect.as(false), + Effect.catchIf(isWindowsLockHeldError, () => Effect.succeed(true)), + Effect.orElseSucceed(() => false), + Effect.scoped, + ); +}); + +/** + * Whether a Firefox `lock` symlink's `:[+]` target still names a + * live owner. Firefox writes this symlink beside the profile while it runs and + * unlinks it on a clean exit, so a dangling one is either live or a crash. + */ +export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* ( + target: string, + localAddresses: ReadonlySet, + isProcessAlive: ProcessLivenessProbe, +) { + const separator = target.lastIndexOf(":"); + if (separator < 0) return true; + // The owner half is whatever Firefox's resolver returned for the machine's + // hostname — 127.0.0.1 when the lookup fails, but often 127.0.1.1 or a LAN + // address — so a pid is only meaningful when that address is one of ours. + // A shared (NFS) profile locked from another machine names a foreign + // address whose pid cannot be probed here, nor could a reused local pid + // vouch for it, so it stays conservatively held. + const owner = target.slice(0, separator); + if (!localAddresses.has(owner)) return true; + // A `+` marks an fcntl-holding owner; the pid follows either way. + const pidText = target.slice(separator + 1).replace(/^\+/, ""); + if (!/^\d+$/.test(pidText)) return true; + const pid = Number(pidText); + if (!Number.isSafeInteger(pid) || pid <= 0) return true; + return yield* isProcessAlive(pid); +}); + +/** + * Interpreters that can run the fcntl probe, tried in order. `/usr/bin/python3` + * is named absolutely first so a Dock-launched app with launchd's bare `PATH` + * still finds it without depending on the login-shell PATH merge; Linux + * distributions carry python3 on the default path. + */ +const FCNTL_PROBE_INTERPRETERS = ["/usr/bin/python3", "python3"] as const; + +/** + * The probe prints exactly one of these. Anything else means the script never + * ran — most importantly Apple's `/usr/bin/python3` shim, which on a Mac + * without the Command Line Tools exits non-zero after printing an install + * prompt, without ever reaching our code. + */ +const FCNTL_PROBE_SCRIPT = + "import fcntl,os,sys\n" + + "fd=os.open(sys.argv[1],os.O_WRONLY)\n" + + "try:\n" + + " fcntl.lockf(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n" + + "except BlockingIOError:\n" + + " print('held')\n" + + "else:\n" + + " print('free')"; + +/** + * Whether another process holds an fcntl write lock on `path`. + * + * Firefox's `.parentlock` is an empty file whose only signal is the kernel + * lock, and Node exposes no fcntl, so a throwaway interpreter tries a + * non-blocking `F_SETLK` and reports `EWOULDBLOCK`. The lock is never + * acquired for real: on success the child exits and the kernel drops it. + * + * The answer is trusted only when the script itself spoke. A verdict of + * `held` or `free` on stdout is the probe's own, and stands. Anything else — + * no interpreter on any candidate path, or one that refused to run the script + * (Apple's shim without the developer tools) — is the probe being unavailable, + * not evidence about the lock. That case falls back to "not held" rather than + * "held": reporting every profile as locked forever would block Firefox import + * outright on such machines, and the SQLite snapshot already copes with a + * live database's WAL, as it does for every other engine. + */ +export const posixLockIsHeld = Effect.fnUntraced(function* ( + path: string, + interpreters: ReadonlyArray = FCNTL_PROBE_INTERPRETERS, +) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const environment = yield* HostProcessEnvironment; + for (const interpreter of interpreters) { + const verdict = yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn( + ChildProcess.make(interpreter, ["-c", FCNTL_PROBE_SCRIPT, path], { + stdin: "ignore", + env: environment, + }), + ); + const [stdout] = yield* Effect.all( + [handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode], + { concurrency: "unbounded" }, + ); + return stdout.trim(); + }), + ).pipe(Effect.orElseSucceed(() => "")); + if (verdict === "held") return true; + if (verdict === "free") return false; + } + return false; +}); + +/** + * Whether Firefox holds a profile. + * + * Firefox leaves two kinds of lock behind, and they mean different things. + * On Linux the `lock` symlink (target `:+`) is removed on a clean + * exit, so its presence is evidence — provided the pid it names is alive. But + * `.parentlock` (macOS/Linux) and `parent.lock` (Windows) are regular files + * held with fcntl or a Windows handle and are *deliberately left on disk* + * after exit, as a last-used marker; treating them as proof of a running + * browser blocks every import after Firefox has been used once. On POSIX the + * fcntl lock itself is the truth, and macOS in particular writes nothing else + * (no symlink, no pid), so `.parentlock` is probed for the kernel lock. On + * Windows the held handle denies our open, which `windowsLockIsHeld` reads as `Busy`. + */ +const firefoxProfileIsHeld = Effect.fnUntraced(function* ( + directory: string, + context: BrowserImportPathContext, + // Resolved once by the caller: it involves a DNS lookup of the hostname and + // is the same for every profile. + localAddresses: ReadonlySet, +) { + const fileSystem = yield* FileSystem.FileSystem; + if (context.platform === "win32") { + return yield* windowsLockIsHeld(context.path.join(directory, "parent.lock")); + } + // Linux additionally writes the `lock` symlink; a live pid there settles it + // without spawning anything. + const symlinkHeld = yield* fileSystem.readLink(context.path.join(directory, "lock")).pipe( + Effect.flatMap((target) => firefoxSymlinkLockIsHeld(target, localAddresses, processIsAlive)), + Effect.orElseSucceed(() => false), + ); + if (symlinkHeld) return true; + const parentLock = context.path.join(directory, ".parentlock"); + const present = yield* fileSystem.stat(parentLock).pipe( + Effect.map((info) => info.type === "File"), + Effect.orElseSucceed(() => false), + ); + if (!present) return false; + return yield* posixLockIsHeld(parentLock); +}); + /** Whether the browser is running, which leaves its cookie DB mid-write. */ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")(function* ( definition: BrowserImportSourceDefinition, - paths: SourcePaths, -) { + context: BrowserImportPathContext, +): Effect.fn.Return< + boolean, + never, + FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner +> { const fileSystem = yield* FileSystem.FileSystem; - const currentHost = yield* HostProcessHostname; - const lock = paths.path.join(definition.userDataDirectory(paths), "SingletonLock"); - // Chromium writes a `SingletonLock` symlink for as long as an instance holds - // the profile. Its presence is a far cheaper and more targeted signal than - // scanning the process table for a name. - // - // The link points at `-`, a target that never exists, and both - // `stat` and `exists` follow links — so they report every running browser as - // closed, which would let an import read a live, mid-write database. - // `readLink` is the one probe that answers for the entry itself. Chromium - // can leave this link behind after a crash, so a positively dead local PID - // is stale. Every ambiguous target or liveness result stays conservative. - return yield* fileSystem.readLink(lock).pipe( - Effect.flatMap((target) => chromiumSingletonLockIsHeld(target, currentHost, processIsAlive)), - Effect.catch((error) => Effect.succeed(error.reason._tag !== "NotFound")), - ); + const root = definition.userDataDirectory(context); + if (root === undefined) return false; + // Both engines leave a lock file for as long as an instance holds a profile, + // which is far cheaper and more targeted than scanning the process table. + if (definition.engine !== "firefox") { + const currentHost = yield* HostProcessHostname; + const lock = context.path.join(root, "SingletonLock"); + return yield* fileSystem.readLink(lock).pipe( + Effect.flatMap((target) => chromiumSingletonLockIsHeld(target, currentHost, processIsAlive)), + Effect.catch((error) => Effect.succeed(error.reason._tag !== "NotFound")), + ); + } + + const profiles = yield* listSourceProfiles(definition, context); + // Only the Linux `lock` symlink names an address, so Windows skips the lookup. + const localAddresses: ReadonlySet = + context.platform === "win32" ? new Set() : yield* yield* HostProcessAddresses; + const found = yield* Effect.forEach(profiles, (profile) => { + const directory = context.path.isAbsolute(profile.directory) + ? profile.directory + : context.path.join(root, profile.directory); + return firefoxProfileIsHeld(directory, context, localAddresses); + }); + return found.some(Boolean); }); /** @@ -295,11 +735,11 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") */ export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstalled")(function* ( definition: BrowserImportSourceDefinition, - paths: SourcePaths, -) { - const profiles = yield* listSourceProfiles(definition, paths); + context: BrowserImportPathContext, +): Effect.fn.Return { + const profiles = yield* listSourceProfiles(definition, context); const found = yield* Effect.forEach(profiles, (profile) => - resolveCookieDatabase(definition, paths, profile.directory).pipe( + resolveCookieDatabase(definition, context, profile.directory).pipe( Effect.map((database) => database !== undefined), ), ); diff --git a/docs/user/browser-import.md b/docs/user/browser-import.md new file mode 100644 index 000000000000..3d743ab4c102 --- /dev/null +++ b/docs/user/browser-import.md @@ -0,0 +1,10 @@ +# Import browser logins + +In the desktop app, open **Settings → Integrations → Browser profiles → Add profile** +and choose a browser under **Import from**. The import copies cookies into a T3 Code browser +profile so you can use existing logins in the preview browser. Changes made afterward stay +separate from the source browser. + +On Linux, Firefox profiles are discovered from both native and Snap installations. A browser +appears once it has a profile with a cookie database. Close the source browser before importing; +the import wizard will prompt you if it is still running. diff --git a/packages/contracts/src/browserImport.ts b/packages/contracts/src/browserImport.ts index d0f9e992f1e8..dafa3d2ca319 100644 --- a/packages/contracts/src/browserImport.ts +++ b/packages/contracts/src/browserImport.ts @@ -16,7 +16,16 @@ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; import { BrowserProfileId } from "./browserProfile.ts"; -export const BROWSER_IMPORT_SOURCE_IDS = ["helium"] as const; +export const BROWSER_IMPORT_SOURCE_IDS = [ + "chrome", + "edge", + "brave", + "vivaldi", + "opera", + "arc", + "helium", + "firefox", +] as const; export const BrowserImportSourceId = Schema.Literals(BROWSER_IMPORT_SOURCE_IDS); export type BrowserImportSourceId = typeof BrowserImportSourceId.Type; diff --git a/packages/shared/src/hostProcess.ts b/packages/shared/src/hostProcess.ts index 7a6699ea546a..dd08dfb86153 100644 --- a/packages/shared/src/hostProcess.ts +++ b/packages/shared/src/hostProcess.ts @@ -1,5 +1,6 @@ import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as NodeDns from "node:dns"; import * as NodeOS from "node:os"; export const HostProcessPlatform = Context.Reference( @@ -51,6 +52,35 @@ export const HostProcessArguments = Context.Reference>( }, ); +/** + * Every IP address this machine answers to: the interface addresses, plus + * whatever the resolver returns for the machine's own hostname. The latter + * matters because a hostname can map to an address no interface carries — + * Debian-style hosts put `127.0.1.1` in `/etc/hosts` — and a program that + * records "its" address by resolving its hostname (Firefox's profile lock + * does) will write that one. "Is this address ours" has to accept both. + * + * Best effort: a failed lookup just leaves the interface set. + */ +export const HostProcessAddresses = Context.Reference>>( + "@t3tools/shared/hostProcess/HostProcessAddresses", + { + defaultValue: () => + Effect.gen(function* () { + const interfaces = Object.values(NodeOS.networkInterfaces()) + .flat() + .flatMap((entry) => (entry ? [entry.address] : [])); + const resolved = yield* Effect.tryPromise(() => + NodeDns.promises.lookup(NodeOS.hostname(), { all: true }), + ).pipe( + Effect.map((entries) => entries.map((entry) => entry.address)), + Effect.orElseSucceed(() => [] as ReadonlyArray), + ); + return new Set([...interfaces, ...resolved]); + }), + }, +); + /** Undefined on platforms without POSIX uids (Windows). */ export const HostProcessUserId = Context.Reference( "@t3tools/shared/hostProcess/HostProcessUserId",