authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-03 16:30:20-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 16:30:20-07:00
logff5843410d44796676b06627788d94354849c4d7
tree2fa36b203924b181ee0109307cb645823b127ef1
parent39449e53e31a56103192aa7905e89fc92c977a4a
signature Signed by PGP key B5690EEEBB952194

feat(desktop): import from Chrome, Edge, Brave, Vivaldi, Opera, Arc and Firefox (#7260)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

13 files changed, 1881 insertions(+), 342 deletions(-)

apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts+10-12
......@@ -13,7 +13,7 @@ import * as Ref from "effect/Ref";
1313
1414import * as BrowserSession from "../BrowserSession.ts";
1515import * as BrowserImport from "./BrowserImport.ts";
16import { BROWSER_IMPORT_SOURCES, sourcePaths } from "./Sources.ts";
16import { BROWSER_IMPORT_SOURCES, sourcePathContext } from "./Sources.ts";
1717
1818const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;
1919
......@@ -52,15 +52,16 @@ const withImporter = Effect.fnUntraced(function* () {
5252 const fileSystem = yield* FileSystem.FileSystem;
5353 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-" });
5454 const environment = Layer.succeed(HostProcessEnvironment, { HOME: home });
55 const paths = yield* sourcePaths.pipe(
55 const context = yield* sourcePathContext.pipe(
5656 Effect.provideService(HostProcessEnvironment, { HOME: home }),
57 Effect.provideService(HostProcessPlatform, "darwin"),
5758 );
58 yield* fileSystem.makeDirectory(`${helium.userDataDirectory(paths)}/Default`, {
59 recursive: true,
60 });
59 const root = helium.userDataDirectory(context);
60 if (root === undefined) throw new Error("Helium has no macOS user-data directory");
61 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
6162 // The cookie database is what marks a source as installed, so a fixture
6263 // without one is reported as absent before any other check runs.
63 yield* fileSystem.writeFileString(`${helium.userDataDirectory(paths)}/Default/Cookies`, "db");
64 yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db");
6465
6566 const importer = yield* BrowserImport.BrowserImport.pipe(
6667 Effect.provide(
......@@ -73,7 +74,7 @@ const withImporter = Effect.fnUntraced(function* () {
7374 ),
7475 ),
7576 );
76 return { importer, home, paths };
77 return { importer, home, root };
7778});
7879
7980describe("BrowserImport.importCookies", () => {
......@@ -107,13 +108,10 @@ describe("BrowserImport.importCookies", () => {
107108 it.effect("refuses to import while the source browser holds its profile", () =>
108109 Effect.gen(function* () {
109110 const fileSystem = yield* FileSystem.FileSystem;
110 const { importer, paths } = yield* withImporter();
111 const { importer, root } = yield* withImporter();
111112 // The lock Chromium leaves while it is running, dangling target and
112113 // all. This must stop the import before it ever asks the keychain.
113 yield* fileSystem.symlink(
114 "host-that-does-not-exist-1234",
115 `${helium.userDataDirectory(paths)}/SingletonLock`,
116 );
114 yield* fileSystem.symlink("host-that-does-not-exist-1234", `${root}/SingletonLock`);
117115
118116 const error = yield* importer
119117 .importCookies({
apps/desktop/src/preview/BrowserImport/BrowserImport.ts+81-32
......@@ -18,20 +18,24 @@ import * as FileSystem from "effect/FileSystem";
1818import * as Layer from "effect/Layer";
1919import * as Path from "effect/Path";
2020import * as Schema from "effect/Schema";
21import * as Scope from "effect/Scope";
22import { ChildProcessSpawner } from "effect/unstable/process";
2123
2224import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";
2325
2426import * as BrowserSession from "../BrowserSession.ts";
25import { readChromiumCookies, type CookieReadResult } from "./ChromiumCookies.ts";
27import { ChromiumCookieReadError, readChromiumCookies } from "./ChromiumCookies.ts";
28import type { CookieReadResult } from "./CookieDatabase.ts";
29import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts";
2630import {
2731 BROWSER_IMPORT_SOURCES,
2832 resolveCookieDatabase,
2933 isSourceInstalled,
3034 isSourceRunning,
3135 listSourceProfiles,
32 sourcePaths,
36 sourcePathContext,
37 type BrowserImportPathContext,
3338 type BrowserImportSourceDefinition,
34 type SourcePaths,
3539} from "./Sources.ts";
3640
3741export class BrowserImportFailedError extends Schema.TaggedErrorClass<BrowserImportFailedError>()(
......@@ -79,12 +83,20 @@ export class BrowserImport extends Context.Service<
7983
8084const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* (
8185 definition: BrowserImportSourceDefinition,
82 platform: NodeJS.Platform,
83 paths: SourcePaths,
84): Effect.fn.Return<BrowserImportUnavailableReason | undefined, never, FileSystem.FileSystem> {
85 if (!definition.platforms.includes(platform)) return "unsupportedPlatform";
86 if (!(yield* isSourceInstalled(definition, paths))) return "notInstalled";
87 if (yield* isSourceRunning(definition, paths)) return "browserRunning";
86 context: BrowserImportPathContext,
87): Effect.fn.Return<
88 BrowserImportUnavailableReason | undefined,
89 never,
90 FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
91> {
92 if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform";
93 // Chromium's key lives in an OS credential store, and only the macOS one is
94 // implemented; Firefox needs no key at all, so it works everywhere.
95 if (definition.engine === "chromium" && context.platform !== "darwin") {
96 return "unsupportedPlatform";
97 }
98 if (!(yield* isSourceInstalled(definition, context))) return "notInstalled";
99 if (yield* isSourceRunning(definition, context)) return "browserRunning";
88100 return undefined;
89101});
90102
......@@ -155,19 +167,22 @@ export const make = Effect.gen(function* BrowserImportMake() {
155167 const executablePath = yield* HostProcessExecutablePath;
156168 // Captured here so the service's methods stay free of a requirements
157169 // channel: the layer is built where NodeServices is already in scope.
158 const platformServices = yield* Effect.context<FileSystem.FileSystem | Path.Path>();
159 const paths = yield* sourcePaths;
170 const platformServices = yield* Effect.context<
171 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
172 >();
173 const pathContext = yield* sourcePathContext;
160174
161175 const listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>> = Effect.forEach(
162176 BROWSER_IMPORT_SOURCES,
163177 Effect.fnUntraced(function* (definition) {
164 const unavailable = yield* unavailableReason(definition, platform, paths);
178 const unavailable = yield* unavailableReason(definition, pathContext);
165179 return {
166180 id: definition.id,
167181 name: definition.name,
168182 // Listing profiles touches the source's own files, so skip it when the
169183 // source is unusable anyway.
170 profiles: unavailable === undefined ? yield* listSourceProfiles(definition, paths) : [],
184 profiles:
185 unavailable === undefined ? yield* listSourceProfiles(definition, pathContext) : [],
171186 ...(unavailable === undefined ? {} : { unavailable }),
172187 } satisfies BrowserImportSource;
173188 }),
......@@ -189,7 +204,7 @@ export const make = Effect.gen(function* BrowserImportMake() {
189204 });
190205 }
191206
192 const blocked = yield* unavailableReason(definition, platform, paths).pipe(
207 const blocked = yield* unavailableReason(definition, pathContext).pipe(
193208 Effect.provide(platformServices),
194209 );
195210 if (blocked !== undefined) {
......@@ -199,16 +214,21 @@ export const make = Effect.gen(function* BrowserImportMake() {
199214 // macOS attributes the Keychain prompt and the resulting ACL grant to the
200215 // executable that asks, so record which one that was — in a packaged build
201216 // it is the signed app, in dev whatever binary hosts the main process.
202 yield* Effect.logInfo("Reading browser cookie key from the keychain", {
203 sourceId: definition.id,
204 executablePath,
205 });
217 // Only Chromium reads a key; a Firefox import touches no keychain, and
218 // logging that it did would put a false security-sensitive event in the
219 // audit trail.
220 if (definition.engine === "chromium") {
221 yield* Effect.logInfo("Reading browser cookie key from the keychain", {
222 sourceId: definition.id,
223 executablePath,
224 });
225 }
206226
207227 // The profile directory arrives over IPC, so it is only honoured when the
208228 // source itself reported it. Forwarding it unchecked would let `..`
209229 // segments walk out of the browser's user-data directory and read any
210230 // cookie database reachable on disk.
211 const sourceProfiles = yield* listSourceProfiles(definition, paths).pipe(
231 const sourceProfiles = yield* listSourceProfiles(definition, pathContext).pipe(
212232 Effect.provide(platformServices),
213233 );
214234 const requestedProfile = sourceProfiles.find(
......@@ -222,28 +242,57 @@ export const make = Effect.gen(function* BrowserImportMake() {
222242 }
223243
224244 // The profile was listed against a database moments ago; resolve it again
225 // rather than assume a path, since the live jar may sit under `Network/`.
245 // rather than assume a path, since a Chromium jar may sit under `Network/`.
226246 const databasePath = yield* resolveCookieDatabase(
227247 definition,
228 paths,
248 pathContext,
229249 requestedProfile.directory,
230250 ).pipe(Effect.provide(platformServices));
231251 if (databasePath === undefined) {
252 // A profile we listed moments ago can lose its database before the
253 // import runs (browser data cleanup, a profile reset). That is a read
254 // failure, not a platform problem.
232255 return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed" });
233256 }
234257
235 const read = yield* readChromiumCookies({
236 cookieDatabasePath: databasePath,
237 keychainService: definition.keychainService,
238 keychainAccount: definition.keychainAccount,
239 platform,
240 }).pipe(
258 // Both branches fail with a tagged error, so the union stays structurally
259 // identifiable and each tag is handled on its own below. The success side
260 // is normalized to one shape too, so the skipped tally survives either
261 // engine — Firefox stores plaintext, so nothing there is ever unreadable.
262 const read: Effect.Effect<
263 CookieReadResult,
264 ChromiumCookieReadError | FirefoxCookieReadError,
265 FileSystem.FileSystem | Path.Path | Scope.Scope
266 > =
267 definition.engine === "firefox"
268 ? readFirefoxCookies(databasePath).pipe(
269 Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })),
270 )
271 : readChromiumCookies({
272 cookieDatabasePath: databasePath,
273 // Only reached on macOS: `unavailableReason` rejects Chromium
274 // elsewhere until those key stores are implemented.
275 keychainService: definition.keychainService ?? "",
276 keychainAccount: definition.keychainAccount ?? "",
277 platform,
278 });
279
280 const result = yield* read.pipe(
241281 Effect.scoped,
242282 Effect.provide(platformServices),
243 Effect.mapError(
244 (cause) =>
245 new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }),
246 ),
283 Effect.catchTags({
284 ChromiumCookieReadError: (cause) =>
285 Effect.fail(
286 new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }),
287 ),
288 // Firefox has one failure mode — its plaintext database would not open
289 // — so its error carries no reason of its own and the user-facing one
290 // is supplied here.
291 FirefoxCookieReadError: (cause) =>
292 Effect.fail(
293 new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed", cause }),
294 ),
295 }),
247296 );
248297
249298 const session = yield* browserSession
......@@ -261,7 +310,7 @@ export const make = Effect.gen(function* BrowserImportMake() {
261310
262311 // Written one at a time rather than in parallel: Chromium's cookie store
263312 // serialises writes anyway, and a rejected cookie should only cost itself.
264 return yield* writeCookies(session, read);
313 return yield* writeCookies(session, result);
265314 });
266315
267316 return BrowserImport.of({ listSources, importCookies });
apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts+2-89
......@@ -6,103 +6,16 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
66import * as NodeCrypto from "node:crypto";
77import * as Effect from "effect/Effect";
88import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as Scope from "effect/Scope";
119import * as SqlClient from "effect/unstable/sql/SqlClient";
1210
13import {
14 cookieScope,
15 readChromiumCookieDatabase,
16 snapshotCookieDatabase,
17} from "./ChromiumCookies.ts";
11import { readChromiumCookieDatabase } from "./ChromiumCookies.ts";
12import { cookieScope } from "./CookieDatabase.ts";
1813
1914const encryptV10 = (value: string | Buffer, key: Buffer): Uint8Array => {
2015 const cipher = NodeCrypto.createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20));
2116 return Buffer.concat([Buffer.from("v10"), cipher.update(value), cipher.final()]);
2217};
2318
24const runNode = <A, E>(
25 effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>,
26) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped);
27
28describe("snapshotCookieDatabase", () => {
29 it.effect("includes committed WAL data in one consistent database", () =>
30 runNode(
31 Effect.gen(function* () {
32 const fileSystem = yield* FileSystem.FileSystem;
33 const path = yield* Path.Path;
34 const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({
35 prefix: "t3code-cookie-source-",
36 });
37 const source = path.join(sourceDirectory, "Cookies");
38
39 const snapshot = yield* Effect.gen(function* () {
40 const sql = yield* SqlClient.SqlClient;
41 yield* sql`PRAGMA journal_mode = WAL`;
42 yield* sql`PRAGMA wal_autocheckpoint = 0`;
43 yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`;
44 yield* sql`INSERT INTO cookies(name) VALUES (${"committed-in-wal"})`;
45
46 expect(yield* fileSystem.exists(`${source}-wal`)).toBe(true);
47 return yield* snapshotCookieDatabase(source);
48 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source })));
49
50 const rows = yield* Effect.gen(function* () {
51 const sql = yield* SqlClient.SqlClient;
52 return yield* sql<{ readonly name: string }>`SELECT name FROM cookies`;
53 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshot, readonly: true })));
54
55 expect(rows).toEqual([{ name: "committed-in-wal" }]);
56 }),
57 ),
58 );
59
60 it.effect("propagates snapshot failures and removes its temporary directory", () =>
61 runNode(
62 Effect.gen(function* () {
63 const fileSystem = yield* FileSystem.FileSystem;
64 const path = yield* Path.Path;
65 const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({
66 prefix: "t3code-cookie-invalid-source-",
67 });
68 const source = path.join(sourceDirectory, "Cookies");
69 yield* fileSystem.writeFileString(source, "not a sqlite database");
70
71 const prefix = `t3code-cookie-failed-${process.pid}-`;
72 const error = yield* snapshotCookieDatabase(source, prefix).pipe(
73 Effect.scoped,
74 Effect.flip,
75 );
76
77 expect(error._tag).toBe("SqlError");
78 const temporaryEntries = yield* fileSystem.readDirectory(path.dirname(sourceDirectory));
79 expect(temporaryEntries.some((entry) => entry.startsWith(prefix))).toBe(false);
80 }),
81 ),
82 );
83
84 it.effect("removes a successful snapshot when its scope closes", () =>
85 runNode(
86 Effect.gen(function* () {
87 const fileSystem = yield* FileSystem.FileSystem;
88 const path = yield* Path.Path;
89 const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({
90 prefix: "t3code-cookie-cleanup-source-",
91 });
92 const source = path.join(sourceDirectory, "Cookies");
93
94 yield* Effect.gen(function* () {
95 const sql = yield* SqlClient.SqlClient;
96 yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`;
97 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source })));
98
99 const snapshot = yield* snapshotCookieDatabase(source).pipe(Effect.scoped);
100 expect(yield* fileSystem.exists(snapshot)).toBe(false);
101 }),
102 ),
103 );
104});
105
10619describe("cookieScope", () => {
10720 it("keeps a host-only cookie host-only", () => {
10821 // Chromium stores a host-only cookie without a leading dot. Passing any
apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts+17-93
......@@ -19,11 +19,17 @@ import * as NodeCrypto from "node:crypto";
1919
2020import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
2121import * as Effect from "effect/Effect";
22import * as FileSystem from "effect/FileSystem";
23import * as Path from "effect/Path";
2422import * as Schema from "effect/Schema";
2523import * as SqlClient from "effect/unstable/sql/SqlClient";
2624
25import {
26 bareHost,
27 cookieScope,
28 snapshotCookieDatabase,
29 type CookieReadResult,
30 type ImportedCookie,
31} from "./CookieDatabase.ts";
32
2733/** macOS OSCrypt parameters. Chromium has used these since the feature landed. */
2834const MAC_KEY_ITERATIONS = 1003;
2935const MAC_KEY_SALT = "saltysalt";
......@@ -32,25 +38,6 @@ const MAC_KEY_LENGTH = 16;
3238const AES_IV = Buffer.alloc(16, 0x20);
3339const V10_PREFIX = "v10";
3440
35export interface ChromiumCookie {
36 readonly url: string;
37 readonly name: string;
38 readonly value: string;
39 /**
40 * Set only for domain cookies, which Chromium stores with a leading dot.
41 * A host-only cookie leaves this undefined: Electron treats any `domain` it
42 * is given as a domain cookie and re-adds the dot, which would widen the
43 * cookie to every subdomain of the host it was scoped to.
44 */
45 readonly domain: string | undefined;
46 readonly path: string;
47 readonly secure: boolean;
48 readonly httpOnly: boolean;
49 /** Seconds since the UNIX epoch, or undefined for a session cookie. */
50 readonly expirationDate: number | undefined;
51 readonly sameSite: "no_restriction" | "lax" | "strict";
52}
53
5441export const ChromiumCookieReadReason = Schema.Literals([
5542 "needsKeychainApproval",
5643 "keychainItemMissing",
......@@ -104,14 +91,17 @@ const decodeSchemaVersion = Schema.decodeUnknownEffect(
10491);
10592
10693/**
107 * Chromium stores `SameSite` as an int; unspecified (-1) behaves as Lax in
108 * modern Chromium, so it maps there rather than to `no_restriction`, which
109 * would widen the cookie's scope on import.
94 * Chromium stores `SameSite` as an int: -1 = unspecified, 0 = none, 1 = lax,
95 * 2 = strict. Unspecified is imported as Electron's own `unspecified` rather
96 * than pinned to Lax, so the target browser applies its default just as the
97 * source did; anything unrecognised lands there too, since guessing "none"
98 * would widen a cookie's scope on import.
11099 */
111const sameSiteFromColumn = (value: number): ChromiumCookie["sameSite"] => {
100const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => {
112101 if (value === 0) return "no_restriction";
102 if (value === 1) return "lax";
113103 if (value === 2) return "strict";
114 return "lax";
104 return "unspecified";
115105};
116106
117107/**
......@@ -171,60 +161,6 @@ const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPasswo
171161 return password;
172162});
173163
174/**
175 * Chromium keeps the cookie DB open with WAL. SQLite must create the snapshot
176 * itself so the main database and WAL are read from one transactionally
177 * consistent generation. Copying those files one after another can pair a
178 * newer database with an older WAL (or the reverse).
179 *
180 * Scoped: the temp directory is removed when the caller's scope closes.
181 */
182export const snapshotCookieDatabase = Effect.fn("ChromiumCookies.snapshotCookieDatabase")(
183 function* (cookiePath: string, tempPrefix = "t3code-cookie-import-") {
184 const fileSystem = yield* FileSystem.FileSystem;
185 const path = yield* Path.Path;
186
187 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: tempPrefix });
188 const target = path.join(directory, "Cookies");
189
190 yield* Effect.gen(function* () {
191 const sql = yield* SqlClient.SqlClient;
192 yield* sql`VACUUM INTO ${target}`;
193 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: cookiePath, readonly: true })));
194
195 return target;
196 },
197);
198
199/**
200 * The URL and domain Electron should register a stored row under.
201 *
202 * Chromium marks a domain cookie with a leading dot on `host_key`. Electron
203 * matches on a URL, so the dot comes off for that; `domain` is passed through
204 * only for domain cookies, because supplying it at all makes Electron treat
205 * the cookie as one and re-add the dot — widening a host-only cookie to every
206 * subdomain of the host it was scoped to, and rejecting `__Host-` cookies,
207 * which require it to be absent.
208 */
209export const cookieScope = (
210 hostKey: string,
211 path: string,
212 secure: boolean,
213): { readonly url: string; readonly domain: string | undefined } => {
214 const isDomainCookie = hostKey.startsWith(".");
215 const host = isDomainCookie ? hostKey.slice(1) : hostKey;
216 const urlAuthority =
217 host.includes(":") && !(host.startsWith("[") && host.endsWith("]")) ? `[${host}]` : host;
218 return {
219 url: `${secure ? "https" : "http"}://${urlAuthority}${path}`,
220 ...(isDomainCookie ? { domain: hostKey } : { domain: undefined }),
221 };
222};
223
224/** The host without Chromium's domain-cookie leading dot, for display. */
225const bareHost = (hostKey: string): string =>
226 hostKey.startsWith(".") ? hostKey.slice(1) : hostKey;
227
228164const decryptValue = (
229165 encrypted: Uint8Array,
230166 key: Buffer,
......@@ -294,7 +230,7 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu
294230 return { rows: yield* decodeCookieRows(raw), schemaVersion };
295231 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })));
296232
297 const cookies: ChromiumCookie[] = [];
233 const cookies: ImportedCookie[] = [];
298234 let undecryptable = 0;
299235 const undecryptableHosts = new Set<string>();
300236 for (const row of rows.rows) {
......@@ -340,18 +276,6 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu
340276 },
341277);
342278
343/**
344 * What a reader produces: the cookies it could recover, and how many stored
345 * rows it could not. The count reaches the user as part of the skipped total
346 * rather than disappearing.
347 */
348export interface CookieReadResult {
349 readonly cookies: ReadonlyArray<ChromiumCookie>;
350 readonly undecryptable: number;
351 /** Distinct hosts of the rows that could not be decrypted. */
352 readonly undecryptableHosts: ReadonlyArray<string>;
353}
354
355279export interface ChromiumCookieSource {
356280 readonly cookieDatabasePath: string;
357281 readonly keychainService: string;
apps/desktop/src/preview/BrowserImport/CookieDatabase.test.ts created+84
......@@ -0,0 +1,84 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
4import * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Path from "effect/Path";
7import * as Scope from "effect/Scope";
8import * as SqlClient from "effect/unstable/sql/SqlClient";
9
10import { snapshotCookieDatabase } from "./CookieDatabase.ts";
11
12const runNode = <A, E>(
13 effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>,
14) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped);
15
16describe("snapshotCookieDatabase", () => {
17 it.effect("includes committed WAL data in one consistent database", () =>
18 runNode(
19 Effect.gen(function* () {
20 const fileSystem = yield* FileSystem.FileSystem;
21 const path = yield* Path.Path;
22 const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({
23 prefix: "t3code-cookie-source-",
24 });
25 const source = path.join(sourceDirectory, "Cookies");
26 const snapshot = yield* Effect.gen(function* () {
27 const sql = yield* SqlClient.SqlClient;
28 yield* sql`PRAGMA journal_mode = WAL`;
29 yield* sql`PRAGMA wal_autocheckpoint = 0`;
30 yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`;
31 yield* sql`INSERT INTO cookies(name) VALUES (${"committed-in-wal"})`;
32 expect(yield* fileSystem.exists(`${source}-wal`)).toBe(true);
33 return yield* snapshotCookieDatabase(source);
34 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source })));
35 const rows = yield* Effect.gen(function* () {
36 const sql = yield* SqlClient.SqlClient;
37 return yield* sql<{ readonly name: string }>`SELECT name FROM cookies`;
38 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshot, readonly: true })));
39 expect(rows).toEqual([{ name: "committed-in-wal" }]);
40 }),
41 ),
42 );
43
44 it.effect("propagates snapshot failures and removes its temporary directory", () =>
45 runNode(
46 Effect.gen(function* () {
47 const fileSystem = yield* FileSystem.FileSystem;
48 const path = yield* Path.Path;
49 const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({
50 prefix: "t3code-cookie-invalid-source-",
51 });
52 const source = path.join(sourceDirectory, "Cookies");
53 yield* fileSystem.writeFileString(source, "not a sqlite database");
54 const prefix = `t3code-cookie-failed-${process.pid}-`;
55 const error = yield* snapshotCookieDatabase(source, prefix).pipe(
56 Effect.scoped,
57 Effect.flip,
58 );
59 expect(error._tag).toBe("SqlError");
60 const temporaryEntries = yield* fileSystem.readDirectory(path.dirname(sourceDirectory));
61 expect(temporaryEntries.some((entry) => entry.startsWith(prefix))).toBe(false);
62 }),
63 ),
64 );
65
66 it.effect("removes a successful snapshot when its scope closes", () =>
67 runNode(
68 Effect.gen(function* () {
69 const fileSystem = yield* FileSystem.FileSystem;
70 const path = yield* Path.Path;
71 const sourceDirectory = yield* fileSystem.makeTempDirectoryScoped({
72 prefix: "t3code-cookie-cleanup-source-",
73 });
74 const source = path.join(sourceDirectory, "Cookies");
75 yield* Effect.gen(function* () {
76 const sql = yield* SqlClient.SqlClient;
77 yield* sql`CREATE TABLE cookies(name TEXT NOT NULL)`;
78 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: source })));
79 const snapshot = yield* snapshotCookieDatabase(source).pipe(Effect.scoped);
80 expect(yield* fileSystem.exists(snapshot)).toBe(false);
81 }),
82 ),
83 );
84});
apps/desktop/src/preview/BrowserImport/CookieDatabase.ts created+100
......@@ -0,0 +1,100 @@
1/**
2 * Shared pieces of cookie extraction: the shape both engines produce, and the
3 * snapshot every reader takes before touching a live database.
4 *
5 * @module CookieDatabase
6 */
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as SqlClient from "effect/unstable/sql/SqlClient";
11
12import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
13
14/** A cookie in the shape Electron's `session.cookies.set` accepts. */
15export interface ImportedCookie {
16 readonly url: string;
17 readonly name: string;
18 readonly value: string;
19 /**
20 * Set only for domain cookies, which the sources mark with a leading dot.
21 * A host-only cookie leaves this undefined: Electron treats any `domain` it
22 * is given as marking a domain cookie and re-adds the dot, which would widen
23 * the cookie to every subdomain of the host it was scoped to, and rejects
24 * `__Host-` cookies, which require it to be absent.
25 */
26 readonly domain: string | undefined;
27 readonly path: string;
28 readonly secure: boolean;
29 readonly httpOnly: boolean;
30 /** Seconds since the UNIX epoch, or undefined for a session cookie. */
31 readonly expirationDate: number | undefined;
32 readonly sameSite: "unspecified" | "no_restriction" | "lax" | "strict";
33}
34
35/**
36 * Cookies recovered from one database and rows that could not be decrypted.
37 * The skipped count reaches the user instead of disappearing from a partial
38 * import result.
39 */
40export interface CookieReadResult {
41 readonly cookies: ReadonlyArray<ImportedCookie>;
42 readonly undecryptable: number;
43 /** Distinct hosts of the rows that could not be decrypted. */
44 readonly undecryptableHosts: ReadonlyArray<string>;
45}
46
47/**
48 * The URL and domain Electron should register a stored row under.
49 *
50 * Both engines mark a domain cookie with a leading dot on the host. Electron
51 * matches on a URL, so the dot comes off for that; `domain` is passed through
52 * only for domain cookies, because supplying it at all makes Electron treat
53 * the cookie as one and re-add the dot — widening a host-only cookie to every
54 * subdomain of the host it was scoped to, and rejecting `__Host-` cookies,
55 * which require it to be absent.
56 */
57export const cookieScope = (
58 host: string,
59 path: string,
60 secure: boolean,
61): { readonly url: string; readonly domain: string | undefined } => {
62 const isDomainCookie = host.startsWith(".");
63 const unwrappedHost = bareHost(host);
64 const authority =
65 unwrappedHost.includes(":") && !(unwrappedHost.startsWith("[") && unwrappedHost.endsWith("]"))
66 ? `[${unwrappedHost}]`
67 : unwrappedHost;
68 return {
69 url: `${secure ? "https" : "http"}://${authority}${path}`,
70 domain: isDomainCookie ? host : undefined,
71 };
72};
73
74/** A host without the leading dot both engines put on a domain cookie, for display. */
75export const bareHost = (host: string): string => (host.startsWith(".") ? host.slice(1) : host);
76
77/**
78 * Creates a transactionally consistent snapshot of a cookie database in a
79 * temporary directory and returns the snapshot's path.
80 *
81 * Both engines keep the file open with WAL while the browser runs, so reading
82 * in place can observe a torn write. Copying also guarantees we never open the
83 * browser's own file for writing.
84 *
85 * Scoped: the temporary directory goes away when the caller's scope closes.
86 */
87export const snapshotCookieDatabase = Effect.fn("CookieDatabase.snapshotCookieDatabase")(function* (
88 cookiePath: string,
89 tempPrefix = "t3code-cookie-import-",
90) {
91 const fileSystem = yield* FileSystem.FileSystem;
92 const path = yield* Path.Path;
93 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: tempPrefix });
94 const target = path.join(directory, path.basename(cookiePath));
95 yield* Effect.gen(function* () {
96 const sql = yield* SqlClient.SqlClient;
97 yield* sql`VACUUM INTO ${target}`;
98 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: cookiePath, readonly: true })));
99 return target;
100});
apps/desktop/src/preview/BrowserImport/FirefoxCookies.test.ts created+459
......@@ -0,0 +1,459 @@
1// @effect-diagnostics nodeBuiltinImport:off - Builds a Firefox-shaped
2// `cookies.sqlite` fixture with the same native bindings Firefox itself uses.
3import * as NodePath from "@effect/platform-node/NodePath";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { describe, expect, it } from "@effect/vitest";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Path from "effect/Path";
9import * as Scope from "effect/Scope";
10import * as NodeSqlite from "node:sqlite";
11
12import { readFirefoxCookies } from "./FirefoxCookies.ts";
13import { parseFirefoxProfiles } from "./Sources.ts";
14
15const parsePosixFirefoxProfiles = (ini: string, root = "/home/user/.mozilla/firefox") =>
16 Effect.gen(function* () {
17 const path = yield* Path.Path;
18 return parseFirefoxProfiles(ini, path, root);
19 }).pipe(Effect.provide(NodePath.layerPosix));
20
21const parseWindowsFirefoxProfiles = (ini: string, root = "C:\\Users\\user\\Firefox") =>
22 Effect.gen(function* () {
23 const path = yield* Path.Path;
24 return parseFirefoxProfiles(ini, path, root);
25 }).pipe(Effect.provide(NodePath.layerWin32));
26
27/** Builds a `cookies.sqlite` with Firefox's real `moz_cookies` shape. */
28const writeFirefoxCookieDatabase = Effect.fnUntraced(function* (
29 rows: ReadonlyArray<{
30 host: string;
31 name: string;
32 value: string;
33 path: string;
34 expiry: number;
35 isSecure: number;
36 isHttpOnly: number;
37 sameSite: number | null;
38 rawSameSite?: number;
39 originAttributes?: string;
40 }>,
41 // Firefox stamps `PRAGMA user_version`; schema 16+ stores `expiry` in
42 // milliseconds, earlier ones in seconds.
43 schemaVersion = 15,
44) {
45 const fileSystem = yield* FileSystem.FileSystem;
46 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-test-" });
47 const file = `${directory}/cookies.sqlite`;
48 const database = new NodeSqlite.DatabaseSync(file);
49 database.exec(`pragma user_version = ${schemaVersion}`);
50 // Only schemas 10–14 have `rawSameSite`; the schema-15 migration dropped it.
51 const hasRawSameSite = schemaVersion >= 10 && schemaVersion <= 14;
52 database.exec(
53 `create table moz_cookies (
54 id integer primary key, host text, name text, value text, path text,
55 expiry integer, isSecure integer, isHttpOnly integer, sameSite integer,
56 ${hasRawSameSite ? "rawSameSite integer," : ""}
57 originAttributes text not null default ''
58 )`,
59 );
60 const insert = database.prepare(
61 `insert into moz_cookies
62 (host, name, value, path, expiry, isSecure, isHttpOnly, sameSite,
63 ${hasRawSameSite ? "rawSameSite," : ""} originAttributes)
64 values (?, ?, ?, ?, ?, ?, ?, ?, ${hasRawSameSite ? "?," : ""} ?)`,
65 );
66 for (const row of rows) {
67 insert.run(
68 row.host,
69 row.name,
70 row.value,
71 row.path,
72 row.expiry,
73 row.isSecure,
74 row.isHttpOnly,
75 row.sameSite,
76 ...(hasRawSameSite ? [row.rawSameSite ?? row.sameSite] : []),
77 row.originAttributes ?? "",
78 );
79 }
80 database.close();
81 return file;
82});
83
84const run = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>) =>
85 effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped);
86
87describe("readFirefoxCookies", () => {
88 it.effect("converts millisecond expiries from schema 16 and newer", () =>
89 run(
90 Effect.gen(function* () {
91 // Firefox 129 (schema 16) migrated `expiry` to milliseconds; older
92 // profiles still hold seconds. Both must land as seconds for Electron.
93 const row = {
94 host: "example.test",
95 name: "c",
96 value: "v",
97 path: "/",
98 expiry: 1_800_000_000_000,
99 isSecure: 0,
100 isHttpOnly: 0,
101 sameSite: 0,
102 };
103 const modern = yield* readFirefoxCookies(yield* writeFirefoxCookieDatabase([row], 16));
104 expect(modern[0]?.expirationDate).toBe(1_800_000_000);
105
106 const legacy = yield* readFirefoxCookies(
107 yield* writeFirefoxCookieDatabase([{ ...row, expiry: 1_800_000_000 }], 15),
108 );
109 expect(legacy[0]?.expirationDate).toBe(1_800_000_000);
110 }),
111 ),
112 );
113
114 it.effect("maps moz_cookies onto the shape Electron accepts", () =>
115 run(
116 Effect.gen(function* () {
117 const file = yield* writeFirefoxCookieDatabase([
118 {
119 host: ".github.com",
120 name: "session",
121 value: "abc",
122 path: "/",
123 expiry: 1_800_000_000,
124 isSecure: 1,
125 isHttpOnly: 1,
126 sameSite: 1,
127 },
128 {
129 host: "example.test",
130 name: "plain",
131 value: "v",
132 path: "/app",
133 // Firefox writes 0 for a session cookie.
134 expiry: 0,
135 isSecure: 0,
136 isHttpOnly: 0,
137 sameSite: 0,
138 },
139 ]);
140
141 const cookies = yield* readFirefoxCookies(file);
142
143 expect(cookies).toEqual([
144 {
145 // The leading dot stays on the domain but not in the URL, which is
146 // what Electron matches against.
147 url: "https://github.com/",
148 name: "session",
149 value: "abc",
150 domain: ".github.com",
151 path: "/",
152 secure: true,
153 httpOnly: true,
154 expirationDate: 1_800_000_000,
155 sameSite: "lax",
156 },
157 {
158 url: "http://example.test/app",
159 name: "plain",
160 value: "v",
161 // Host-only in Firefox, so no `domain`: supplying one would make
162 // Electron widen it to every subdomain of example.test.
163 domain: undefined,
164 path: "/app",
165 secure: false,
166 httpOnly: false,
167 // Session cookies carry no expiry rather than one at the epoch.
168 expirationDate: undefined,
169 sameSite: "no_restriction",
170 },
171 ]);
172 }),
173 ),
174 );
175
176 it.effect("keeps an unset SameSite unspecified instead of widening it to none", () =>
177 run(
178 Effect.gen(function* () {
179 // nsICookie::SAMESITE_UNSET is 256, a cookie that carried no SameSite
180 // attribute. It is not SAMESITE_NONE (0), which is an explicit opt-in
181 // to cross-site use; importing it as "none" would widen its scope.
182 const row = {
183 host: "example.test",
184 name: "c",
185 value: "v",
186 path: "/",
187 expiry: 0,
188 isSecure: 0,
189 isHttpOnly: 0,
190 };
191 const cookies = yield* readFirefoxCookies(
192 yield* writeFirefoxCookieDatabase([
193 { ...row, name: "unset", sameSite: 256 },
194 { ...row, name: "none", sameSite: 0 },
195 ]),
196 );
197 expect(cookies.map(({ name, sameSite }) => ({ name, sameSite }))).toEqual([
198 { name: "unset", sameSite: "unspecified" },
199 { name: "none", sameSite: "no_restriction" },
200 ]);
201 }),
202 ),
203 );
204
205 it.effect("imports rows whose SameSite was never written", () =>
206 run(
207 Effect.gen(function* () {
208 // Schema 9 added `sameSite` without a default, so rows from before the
209 // upgrade hold NULL. One such row must not fail the whole import.
210 const row = {
211 host: "example.test",
212 name: "c",
213 value: "v",
214 path: "/",
215 expiry: 0,
216 isSecure: 0,
217 isHttpOnly: 0,
218 };
219 const cookies = yield* readFirefoxCookies(
220 yield* writeFirefoxCookieDatabase(
221 [
222 { ...row, name: "legacy", sameSite: null },
223 { ...row, name: "strict", sameSite: 2 },
224 ],
225 9,
226 ),
227 );
228 expect(cookies.map(({ name, sameSite }) => ({ name, sameSite }))).toEqual([
229 { name: "legacy", sameSite: "unspecified" },
230 { name: "strict", sameSite: "strict" },
231 ]);
232 }),
233 ),
234 );
235
236 it.effect("applies the schema-15 rawSameSite rule to older databases", () =>
237 run(
238 Effect.gen(function* () {
239 // Schemas 10–14 defaulted `sameSite` to Lax and kept the declared value
240 // in `rawSameSite`. Firefox's own migration to 15 turns "Lax by
241 // default, None declared" into Unset; an unmigrated database has to be
242 // read the same way or an undeclared cookie becomes an explicit Lax.
243 const row = {
244 host: "example.test",
245 name: "c",
246 value: "v",
247 path: "/",
248 expiry: 0,
249 isSecure: 0,
250 isHttpOnly: 0,
251 };
252 const cookies = yield* readFirefoxCookies(
253 yield* writeFirefoxCookieDatabase(
254 [
255 { ...row, name: "defaulted", sameSite: 1, rawSameSite: 0 },
256 { ...row, name: "declared", sameSite: 1, rawSameSite: 1 },
257 { ...row, name: "none", sameSite: 0, rawSameSite: 0 },
258 ],
259 14,
260 ),
261 );
262 expect(cookies.map(({ name, sameSite }) => ({ name, sameSite }))).toEqual([
263 { name: "defaulted", sameSite: "unspecified" },
264 { name: "declared", sameSite: "lax" },
265 { name: "none", sameSite: "no_restriction" },
266 ]);
267 }),
268 ),
269 );
270
271 it.effect("imports only the default container", () =>
272 run(
273 Effect.gen(function* () {
274 const file = yield* writeFirefoxCookieDatabase([
275 {
276 host: "mail.test",
277 name: "session",
278 value: "default-container",
279 path: "/",
280 expiry: 1_800_000_000,
281 isSecure: 1,
282 isHttpOnly: 0,
283 sameSite: 1,
284 },
285 {
286 // Same host, name and path as above: Firefox keeps these apart by
287 // container, Electron cannot, so importing both would hand the
288 // profile whichever one happened to be written last.
289 host: "mail.test",
290 name: "session",
291 value: "work-container",
292 path: "/",
293 expiry: 1_800_000_000,
294 isSecure: 1,
295 isHttpOnly: 0,
296 sameSite: 1,
297 originAttributes: "^userContextId=2",
298 },
299 {
300 host: "mail.test",
301 name: "private",
302 value: "private-window",
303 path: "/",
304 expiry: 1_800_000_000,
305 isSecure: 1,
306 isHttpOnly: 0,
307 sameSite: 1,
308 originAttributes: "^privateBrowsingId=1",
309 },
310 ]);
311
312 const cookies = yield* readFirefoxCookies(file);
313
314 expect(cookies.map((cookie) => cookie.value)).toEqual(["default-container"]);
315 }),
316 ),
317 );
318
319 it.effect("reads without mutating the source database", () =>
320 run(
321 Effect.gen(function* () {
322 const fileSystem = yield* FileSystem.FileSystem;
323 const file = yield* writeFirefoxCookieDatabase([
324 {
325 host: "a.test",
326 name: "n",
327 value: "v",
328 path: "/",
329 expiry: 1_800_000_000,
330 isSecure: 1,
331 isHttpOnly: 0,
332 sameSite: 2,
333 },
334 ]);
335 const before = yield* fileSystem.stat(file);
336
337 yield* readFirefoxCookies(file);
338
339 // The browser's own file is snapshotted, never opened for writing.
340 const after = yield* fileSystem.stat(file);
341 expect(after.mtime).toEqual(before.mtime);
342 expect(after.size).toBe(before.size);
343 }),
344 ),
345 );
346});
347
348describe("parseFirefoxProfiles", () => {
349 it.effect("reads named profiles and ignores Install sections", () =>
350 Effect.gen(function* () {
351 // `Install*` sections name a default profile but do not describe one, so
352 // counting them would invent a profile whose directory does not exist.
353 const parsed = yield* parsePosixFirefoxProfiles(
354 [
355 "[Install4F96D1932A9F858E]",
356 "Default=Profiles/abcd1234.default-release",
357 "Locked=1",
358 "",
359 "[Profile0]",
360 "Name=default-release",
361 "IsRelative=1",
362 "Path=Profiles/abcd1234.default-release",
363 "",
364 "[Profile1]",
365 "Name=Work",
366 "IsRelative=0",
367 "Path=/Volumes/External/firefox-work",
368 "",
369 "[General]",
370 "StartWithLastProfile=1",
371 ].join("\n"),
372 );
373
374 expect(parsed).toEqual([
375 { directory: "Profiles/abcd1234.default-release", name: "default-release" },
376 { directory: "/Volumes/External/firefox-work", name: "Work" },
377 ]);
378 }),
379 );
380
381 it.effect("falls back to the path when a profile has no name", () =>
382 Effect.gen(function* () {
383 expect(
384 yield* parsePosixFirefoxProfiles(["[Profile0]", "Path=Profiles/x.default"].join("\n")),
385 ).toEqual([{ directory: "Profiles/x.default", name: "Profiles/x.default" }]);
386 }),
387 );
388
389 for (const [platform, root] of [
390 ["Linux", "/home/user/.mozilla/firefox"],
391 ["macOS", "/Users/user/Library/Application Support/Firefox"],
392 ] as const) {
393 it.effect(`validates relative and absolute ${platform} profile paths`, () =>
394 Effect.gen(function* () {
395 const parsed = yield* parsePosixFirefoxProfiles(
396 [
397 "[Profile0]",
398 "Name=Relative",
399 "IsRelative=1",
400 "Path=Profiles/relative.default",
401 "[Profile1]",
402 "Name=Custom",
403 "IsRelative=0",
404 "Path=/mnt/custom/firefox-profile",
405 "[Profile2]",
406 "IsRelative=1",
407 "Path=../../escape",
408 "[Profile3]",
409 "IsRelative=1",
410 "Path=/absolute-marked-relative",
411 "[Profile4]",
412 "IsRelative=0",
413 "Path=relative-marked-absolute",
414 "[Profile5]",
415 "IsRelative=1",
416 "Path=Profiles/nul\u0000escape",
417 ].join("\n"),
418 root,
419 );
420
421 expect(parsed).toEqual([
422 { directory: "Profiles/relative.default", name: "Relative" },
423 { directory: "/mnt/custom/firefox-profile", name: "Custom" },
424 ]);
425 }),
426 );
427 }
428
429 it.effect("uses Windows path rules for relative and absolute profiles", () =>
430 Effect.gen(function* () {
431 const parsed = yield* parseWindowsFirefoxProfiles(
432 [
433 "[Profile0]",
434 "Name=Relative",
435 "IsRelative=1",
436 "Path=Profiles\\relative.default",
437 "[Profile1]",
438 "Name=Custom",
439 "IsRelative=0",
440 "Path=D:\\Firefox Profiles\\Work",
441 "[Profile2]",
442 "IsRelative=1",
443 "Path=..\\..\\escape",
444 "[Profile3]",
445 "IsRelative=1",
446 "Path=D:\\absolute-marked-relative",
447 "[Profile4]",
448 "IsRelative=0",
449 "Path=relative-marked-absolute",
450 ].join("\n"),
451 );
452
453 expect(parsed).toEqual([
454 { directory: "Profiles\\relative.default", name: "Relative" },
455 { directory: "D:\\Firefox Profiles\\Work", name: "Custom" },
456 ]);
457 }),
458 );
459});
apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts created+169
......@@ -0,0 +1,169 @@
1/**
2 * Firefox cookie extraction.
3 *
4 * Firefox stores cookies unencrypted in `cookies.sqlite`, so there is no key
5 * to fetch and no consent prompt — the file is readable by anything running as
6 * the user. That is Mozilla's design choice, not a control being circumvented,
7 * which is why this path works identically on macOS, Windows, and Linux while
8 * the Chromium one needs a per-platform credential store.
9 *
10 * @module FirefoxCookies
11 */
12import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
13import * as Effect from "effect/Effect";
14import * as Schema from "effect/Schema";
15import * as SqlClient from "effect/unstable/sql/SqlClient";
16
17import { cookieScope, snapshotCookieDatabase, type ImportedCookie } from "./CookieDatabase.ts";
18
19/**
20 * Mirrors `ChromiumCookieReadError` so both engines fail with a tagged error
21 * the service can tell apart, rather than one of them widening the channel to
22 * an anonymous shape.
23 *
24 * No `reason` field: unlike Chromium there is only one way this fails — the
25 * plaintext database would not open — and the tag already says which engine it
26 * was. `BrowserImport` supplies the user-facing reason when it maps the union.
27 */
28export class FirefoxCookieReadError extends Schema.TaggedErrorClass<FirefoxCookieReadError>()(
29 "FirefoxCookieReadError",
30 {
31 /**
32 * Which database the read was for. Firefox keeps one per profile, so
33 * without it a failure cannot be traced back to the profile that caused
34 * it.
35 */
36 cookieDatabasePath: Schema.String,
37 /** Always present: every construction site wraps a real failure. */
38 cause: Schema.Defect(),
39 },
40) {
41 override get message(): string {
42 return `Could not read Firefox cookies at ${this.cookieDatabasePath}.`;
43 }
44}
45
46/**
47 * `moz_cookies.sameSite` holds nsICookie's constants: 0 = None, 1 = Lax,
48 * 2 = Strict, and 256 = Unset for a cookie that carried no SameSite attribute
49 * at all. Unset is not the same thing as None — None is an explicit opt-in to
50 * cross-site use — so it is imported as Electron's `unspecified`, which lets
51 * the target browser apply its own default exactly as Firefox did. Anything
52 * unrecognised also lands there rather than on `no_restriction`, since
53 * guessing "none" would widen a cookie's scope on import.
54 */
55const SAMESITE_NONE = 0;
56const SAMESITE_LAX = 1;
57const SAMESITE_STRICT = 2;
58
59/**
60 * Schemas 10–14 carried a second column, `rawSameSite`: the value the cookie
61 * actually declared, beside a `sameSite` that Firefox had already defaulted to
62 * Lax. The schema-15 migration folded them back together with
63 * `sameSite = UNSET where sameSite = LAX and rawSameSite = NONE`, i.e. a row
64 * that "is Lax" only because nothing was declared. Reading such a database
65 * before Firefox has migrated it must apply the same rule, or an undeclared
66 * cookie is imported as an explicit Lax.
67 */
68const FIREFOX_RAW_SAMESITE_FIRST_SCHEMA = 10;
69const FIREFOX_RAW_SAMESITE_LAST_SCHEMA = 14;
70
71const sameSiteFromColumn = (
72 value: number | null,
73 rawValue: number | null,
74): ImportedCookie["sameSite"] => {
75 // Schema 9 added the column with no default, so older rows carry NULL.
76 if (value === null) return "unspecified";
77 if (value === SAMESITE_LAX && rawValue === SAMESITE_NONE) return "unspecified";
78 if (value === SAMESITE_NONE) return "no_restriction";
79 if (value === SAMESITE_LAX) return "lax";
80 if (value === SAMESITE_STRICT) return "strict";
81 return "unspecified";
82};
83
84const CookieRow = Schema.Struct({
85 host: Schema.String,
86 name: Schema.String,
87 value: Schema.String,
88 path: Schema.String,
89 // UNIX-epoch based, unlike Chromium's 1601-based microseconds — but the
90 // unit depends on the schema version; see `expiryToSeconds`.
91 expiry: Schema.Number,
92 isSecure: Schema.Number,
93 isHttpOnly: Schema.Number,
94 sameSite: Schema.NullOr(Schema.Number),
95 // Present only for schemas 10–14; selected as NULL elsewhere.
96 rawSameSite: Schema.NullOr(Schema.Number),
97});
98const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow));
99
100/**
101 * Firefox schema 16 (Firefox 129) moved `expiry` from seconds to milliseconds
102 * — the migration is `UPDATE moz_cookies SET expiry = expiry * 1000`. Electron
103 * wants seconds, so the unit is decided by `PRAGMA user_version` rather than
104 * assumed: importing a pre-16 profile as milliseconds would expire every cookie
105 * at once, and a post-16 one as seconds would keep them for ~1000× too long.
106 */
107const FIREFOX_EXPIRY_MILLISECONDS_SCHEMA = 16;
108
109const UserVersionRow = Schema.Struct({ user_version: Schema.Number });
110const decodeUserVersion = Schema.decodeUnknownEffect(Schema.Array(UserVersionRow));
111
112const expiryToSeconds = (expiry: number, schemaVersion: number): number | undefined => {
113 if (expiry <= 0) return undefined;
114 return schemaVersion >= FIREFOX_EXPIRY_MILLISECONDS_SCHEMA ? Math.floor(expiry / 1000) : expiry;
115};
116
117export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies")(function* (
118 cookieDatabasePath: string,
119) {
120 const snapshotPath = yield* snapshotCookieDatabase(cookieDatabasePath).pipe(
121 Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })),
122 );
123
124 const { rows, schemaVersion } = yield* Effect.gen(function* () {
125 const sql = yield* SqlClient.SqlClient;
126 const [versionRow] = yield* decodeUserVersion(yield* sql`pragma user_version`);
127 const schemaVersion = versionRow?.user_version ?? 0;
128 const hasRawSameSite =
129 schemaVersion >= FIREFOX_RAW_SAMESITE_FIRST_SCHEMA &&
130 schemaVersion <= FIREFOX_RAW_SAMESITE_LAST_SCHEMA;
131 // Only the default container. Firefox isolates cookies per container and
132 // per private window via `originAttributes` (`^userContextId=2`,
133 // `^privateBrowsingId=1`); Electron has no equivalent, so importing them
134 // all would collapse several identities onto one host/name/path and hand
135 // the profile an arbitrary container's session.
136 const raw = hasRawSameSite
137 ? yield* sql`
138 select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, rawSameSite
139 from moz_cookies
140 where originAttributes = ''
141 `
142 : yield* sql`
143 select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite,
144 null as rawSameSite
145 from moz_cookies
146 where originAttributes = ''
147 `;
148 return { rows: yield* decodeCookieRows(raw), schemaVersion };
149 }).pipe(
150 Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })),
151 Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })),
152 );
153
154 return rows.map((row) => {
155 const secure = row.isSecure === 1;
156 const scope = cookieScope(row.host, row.path, secure);
157 return {
158 url: scope.url,
159 name: row.name,
160 value: row.value,
161 domain: scope.domain,
162 path: row.path,
163 secure,
164 httpOnly: row.isHttpOnly === 1,
165 expirationDate: expiryToSeconds(row.expiry, schemaVersion),
166 sameSite: sameSiteFromColumn(row.sameSite, row.rawSameSite),
167 } satisfies ImportedCookie;
168 });
169});
apps/desktop/src/preview/BrowserImport/Sources.test.ts+392-38
......@@ -2,23 +2,32 @@
22// table with the same native bindings the source reads.
33import * as NodeServices from "@effect/platform-node/NodeServices";
44import { assert, describe, it } from "@effect/vitest";
5import { HostProcessEnvironment, HostProcessHostname } from "@t3tools/shared/hostProcess";
5import {
6 HostProcessEnvironment,
7 HostProcessHostname,
8 HostProcessPlatform,
9} from "@t3tools/shared/hostProcess";
610import * as Effect from "effect/Effect";
711import * as FileSystem from "effect/FileSystem";
812import * as Path from "effect/Path";
913import * as Scope from "effect/Scope";
14import * as Stream from "effect/Stream";
15import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1016import * as NodeSqlite from "node:sqlite";
1117
18import type { BrowserImportPathContext } from "./Sources.ts";
1219import {
1320 BROWSER_IMPORT_SOURCES,
1421 chromiumProcessIsAlive,
1522 chromiumSingletonLockIsHeld,
1623 cookieDatabaseCandidatePaths,
24 firefoxSymlinkLockIsHeld,
1725 resolveCookieDatabase,
1826 isSourceInstalled,
1927 isSourceRunning,
28 posixLockIsHeld,
2029 listSourceProfiles,
21 sourcePaths,
30 sourcePathContext,
2231} from "./Sources.ts";
2332
2433const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;
......@@ -27,15 +36,28 @@ const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;
2736const withSourceHome = Effect.fnUntraced(function* () {
2837 const fileSystem = yield* FileSystem.FileSystem;
2938 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-sources-" });
30 const paths = yield* sourcePaths.pipe(
39 const context = yield* sourcePathContext.pipe(
3140 Effect.provideService(HostProcessEnvironment, { HOME: home }),
41 Effect.provideService(HostProcessPlatform, "darwin"),
3242 );
33 yield* fileSystem.makeDirectory(helium.userDataDirectory(paths), { recursive: true });
34 return paths;
43 yield* fileSystem.makeDirectory(userDataDirectory(context), { recursive: true });
44 return context;
3545});
3646
37const run = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>) =>
38 effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped);
47/** Every case here runs on darwin, where Helium always resolves a directory. */
48const userDataDirectory = (context: BrowserImportPathContext) => {
49 const root = helium.userDataDirectory(context);
50 if (root === undefined) throw new Error("Helium has no macOS user-data directory");
51 return root;
52};
53
54const run = <A, E>(
55 effect: Effect.Effect<
56 A,
57 E,
58 FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner
59 >,
60) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped);
3961
4062/** Writes a Chromium-shaped cookie table with `count` rows. */
4163const writeCookieDatabase = (file: string, count: number) =>
......@@ -47,23 +69,37 @@ const writeCookieDatabase = (file: string, count: number) =>
4769 database.close();
4870 });
4971
72const writeFirefoxCookieDatabase = (
73 file: string,
74 defaultContainerCount: number,
75 containerCount: number,
76) =>
77 Effect.sync(() => {
78 const database = new NodeSqlite.DatabaseSync(file);
79 database.exec("create table moz_cookies (originAttributes text not null)");
80 const insert = database.prepare("insert into moz_cookies (originAttributes) values (?)");
81 for (let index = 0; index < defaultContainerCount; index += 1) insert.run("");
82 for (let index = 0; index < containerCount; index += 1) insert.run("^userContextId=2");
83 database.close();
84 });
85
5086describe("isSourceRunning", () => {
5187 it.effect("reads Chromium's dangling SingletonLock symlink as a running browser", () =>
5288 run(
5389 Effect.gen(function* () {
5490 const fileSystem = yield* FileSystem.FileSystem;
55 const paths = yield* withSourceHome();
56 assert.isFalse(yield* isSourceRunning(helium, paths));
91 const context = yield* withSourceHome();
92 assert.isFalse(yield* isSourceRunning(helium, context));
5793
5894 // Chromium points the lock at `<host>-<pid>`, a target that never
5995 // exists on disk. A check that follows the link reports a running
6096 // browser as closed, letting an import read a live, mid-write database.
6197 yield* fileSystem.symlink(
6298 "host-that-does-not-exist-1234",
63 `${helium.userDataDirectory(paths)}/SingletonLock`,
99 `${userDataDirectory(context)}/SingletonLock`,
64100 );
65101
66 assert.isTrue(yield* isSourceRunning(helium, paths));
102 assert.isTrue(yield* isSourceRunning(helium, context));
67103 }),
68104 ),
69105 );
......@@ -172,28 +208,49 @@ describe("isSourceInstalled", () => {
172208 run(
173209 Effect.gen(function* () {
174210 const fileSystem = yield* FileSystem.FileSystem;
175 const paths = yield* withSourceHome();
176 const root = helium.userDataDirectory(paths);
211 const context = yield* withSourceHome();
212 const root = userDataDirectory(context);
177213
178214 // Installers for native messaging hosts create an empty user-data
179215 // directory for every Chromium fork they know about, so treating the
180216 // directory as evidence lists browsers the user does not have.
181217 yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true });
182 assert.isFalse(yield* isSourceInstalled(helium, paths));
218 assert.isFalse(yield* isSourceInstalled(helium, context));
183219
184220 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
185221 yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db");
186 assert.isTrue(yield* isSourceInstalled(helium, paths));
222 assert.isTrue(yield* isSourceInstalled(helium, context));
187223
188224 // A real install whose cookies live outside `Default` still counts:
189225 // reporting it as absent hides the source from the menu entirely.
190226 yield* fileSystem.remove(`${root}/Default`, { recursive: true });
191227 yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true });
192228 yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db");
193 assert.isTrue(yield* isSourceInstalled(helium, paths));
229 assert.isTrue(yield* isSourceInstalled(helium, context));
194230
195231 yield* fileSystem.remove(root, { recursive: true });
196 assert.isFalse(yield* isSourceInstalled(helium, paths));
232 assert.isFalse(yield* isSourceInstalled(helium, context));
233 }),
234 ),
235 );
236
237 it.effect("follows cookie database symlinks when detecting profiles", () =>
238 run(
239 Effect.gen(function* () {
240 const fileSystem = yield* FileSystem.FileSystem;
241 const context = yield* withSourceHome();
242 const root = userDataDirectory(context);
243 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
244 yield* fileSystem.symlink("missing-cookies", `${root}/Default/Cookies`);
245
246 assert.deepEqual(yield* listSourceProfiles(helium, context), []);
247 assert.isFalse(yield* isSourceInstalled(helium, context));
248
249 yield* fileSystem.writeFileString(`${root}/Default/missing-cookies`, "db");
250 assert.deepEqual(yield* listSourceProfiles(helium, context), [
251 { directory: "Default", name: "Default" },
252 ]);
253 assert.isTrue(yield* isSourceInstalled(helium, context));
197254 }),
198255 ),
199256 );
......@@ -224,15 +281,15 @@ describe("listSourceProfiles", () => {
224281 run(
225282 Effect.gen(function* () {
226283 const fileSystem = yield* FileSystem.FileSystem;
227 const paths = yield* withSourceHome();
228 const root = helium.userDataDirectory(paths);
284 const context = yield* withSourceHome();
285 const root = userDataDirectory(context);
229286 // Assuming `Default` would report a browser whose cookies live in
230287 // `Profile 1` as having nothing to import, and it is then hidden.
231288 yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true });
232289 yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db");
233290 yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true });
234291
235 assert.deepEqual(yield* listSourceProfiles(helium, paths), [
292 assert.deepEqual(yield* listSourceProfiles(helium, context), [
236293 { directory: "Profile 1", name: "Profile 1" },
237294 ]);
238295 }),
......@@ -243,13 +300,13 @@ describe("listSourceProfiles", () => {
243300 run(
244301 Effect.gen(function* () {
245302 const fileSystem = yield* FileSystem.FileSystem;
246 const paths = yield* withSourceHome();
303 const context = yield* withSourceHome();
247304 yield* fileSystem.writeFileString(
248 `${helium.userDataDirectory(paths)}/Local State`,
305 `${userDataDirectory(context)}/Local State`,
249306 `{"profile":{"info_cache":{"Default":{"name":"You"},"Profile 2":{"name":" "}}}}`,
250307 );
251308
252 assert.deepEqual(yield* listSourceProfiles(helium, paths), [
309 assert.deepEqual(yield* listSourceProfiles(helium, context), [
253310 { directory: "Default", name: "You" },
254311 // Blank display name falls back to the directory rather than
255312 // rendering an empty row.
......@@ -263,13 +320,13 @@ describe("listSourceProfiles", () => {
263320 run(
264321 Effect.gen(function* () {
265322 const fileSystem = yield* FileSystem.FileSystem;
266 const paths = yield* withSourceHome();
267 const root = helium.userDataDirectory(paths);
323 const context = yield* withSourceHome();
324 const root = userDataDirectory(context);
268325 yield* fileSystem.writeFileString(`${root}/Local State`, "{not-json");
269326 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
270327 yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db");
271328
272 assert.deepEqual(yield* listSourceProfiles(helium, paths), [
329 assert.deepEqual(yield* listSourceProfiles(helium, context), [
273330 { directory: "Default", name: "Default" },
274331 ]);
275332 }),
......@@ -279,8 +336,8 @@ describe("listSourceProfiles", () => {
279336 it.effect("reports nothing when no directory holds a cookie database", () =>
280337 run(
281338 Effect.gen(function* () {
282 const paths = yield* withSourceHome();
283 assert.deepEqual(yield* listSourceProfiles(helium, paths), []);
339 const context = yield* withSourceHome();
340 assert.deepEqual(yield* listSourceProfiles(helium, context), []);
284341 }),
285342 ),
286343 );
......@@ -305,9 +362,9 @@ describe("cookieDatabaseCandidatePaths", () => {
305362 it.effect("prefers Network/Cookies and falls back to the legacy Cookies", () =>
306363 run(
307364 Effect.gen(function* () {
308 const paths = yield* withSourceHome();
309 const profile = `${paths.home}/Library/Application Support/net.imput.helium/Profile 1`;
310 assert.deepEqual(cookieDatabaseCandidatePaths(helium, paths, "Profile 1"), [
365 const context = yield* withSourceHome();
366 const profile = `${context.home}/Library/Application Support/net.imput.helium/Profile 1`;
367 assert.deepEqual(cookieDatabaseCandidatePaths(helium, context, "Profile 1"), [
311368 `${profile}/Network/Cookies`,
312369 `${profile}/Cookies`,
313370 ]);
......@@ -319,8 +376,8 @@ describe("cookieDatabaseCandidatePaths", () => {
319376 run(
320377 Effect.gen(function* () {
321378 const fileSystem = yield* FileSystem.FileSystem;
322 const paths = yield* withSourceHome();
323 const root = helium.userDataDirectory(paths);
379 const context = yield* withSourceHome();
380 const root = helium.userDataDirectory(context);
324381 // Chromium 96+ keeps sessions in Network/; a root Cookies left behind
325382 // by the move is stale and must not be the one imported.
326383 yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true });
......@@ -328,15 +385,312 @@ describe("cookieDatabaseCandidatePaths", () => {
328385 yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "stale");
329386
330387 assert.equal(
331 yield* resolveCookieDatabase(helium, paths, "Default"),
388 yield* resolveCookieDatabase(helium, context, "Default"),
332389 `${root}/Default/Network/Cookies`,
333390 );
334391 // A fresh install with only the Network/ jar is installed, not hidden.
335392 yield* fileSystem.remove(`${root}/Default/Cookies`);
336 assert.isTrue(yield* isSourceInstalled(helium, paths));
393 assert.isTrue(yield* isSourceInstalled(helium, context));
394 }),
395 ),
396 );
397});
398
399const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!;
400
401describe("Firefox Snap profiles", () => {
402 it.effect("finds Snap profiles with or without profiles.ini and checks their locks", () =>
403 run(
404 Effect.gen(function* () {
405 const fileSystem = yield* FileSystem.FileSystem;
406 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-snap-" });
407 const context = yield* sourcePathContext.pipe(
408 Effect.provideService(HostProcessEnvironment, { HOME: home }),
409 Effect.provideService(HostProcessPlatform, "linux"),
410 );
411 const root = `${home}/snap/firefox/common/.mozilla/firefox`;
412 const directory = `${root}/abcd.default`;
413 yield* fileSystem.makeDirectory(directory, { recursive: true });
414 yield* writeFirefoxCookieDatabase(`${directory}/cookies.sqlite`, 2, 1);
415 yield* fileSystem.writeFileString(
416 `${root}/profiles.ini`,
417 "[Profile0]\nName=Personal\nIsRelative=1\nPath=abcd.default\n",
418 );
419
420 assert.isTrue(yield* isSourceInstalled(firefox, context));
421 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
422 { directory, name: "Personal", cookieCount: 2 },
423 ]);
424 assert.equal(
425 yield* resolveCookieDatabase(firefox, context, directory),
426 `${directory}/cookies.sqlite`,
427 );
428 assert.isFalse(yield* isSourceRunning(firefox, context));
429 yield* fileSystem.symlink("foreign-host:+4242", `${directory}/lock`);
430 assert.isTrue(yield* isSourceRunning(firefox, context));
431 yield* fileSystem.remove(`${directory}/lock`);
432 assert.isFalse(yield* isSourceRunning(firefox, context));
433
434 yield* fileSystem.remove(`${root}/profiles.ini`);
435 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
436 { directory, name: "abcd.default", cookieCount: 2 },
437 ]);
438 }),
439 ),
440 );
441
442 it.effect("keeps matching profile names in native and Snap installs distinct", () =>
443 run(
444 Effect.gen(function* () {
445 const fileSystem = yield* FileSystem.FileSystem;
446 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-snap-" });
447 const context = yield* sourcePathContext.pipe(
448 Effect.provideService(HostProcessEnvironment, { HOME: home }),
449 Effect.provideService(HostProcessPlatform, "linux"),
450 );
451 const native = `${home}/.mozilla/firefox`;
452 const snap = `${home}/snap/firefox/common/.mozilla/firefox`;
453 for (const root of [native, snap]) {
454 yield* fileSystem.makeDirectory(`${root}/abcd.default`, { recursive: true });
455 yield* writeFirefoxCookieDatabase(`${root}/abcd.default/cookies.sqlite`, 1, 0);
456 yield* fileSystem.writeFileString(
457 `${root}/profiles.ini`,
458 "[Profile0]\nName=Personal\nIsRelative=1\nPath=abcd.default\n" +
459 `[Profile1]\nName=Shared\nIsRelative=0\nPath=${snap}/abcd.default\n`,
460 );
461 }
462
463 const profiles = yield* listSourceProfiles(firefox, context);
464 assert.deepEqual(
465 profiles.map((profile) => profile.directory),
466 ["abcd.default", `${snap}/abcd.default`],
467 );
468 const databases = yield* Effect.forEach(profiles, (profile) =>
469 resolveCookieDatabase(firefox, context, profile.directory),
470 );
471 assert.deepEqual(databases, [
472 `${native}/abcd.default/cookies.sqlite`,
473 `${snap}/abcd.default/cookies.sqlite`,
474 ]);
475 }),
476 ),
477 );
478});
479
480describe("listSourceProfiles Firefox fallback", () => {
481 const cases = [
482 { platform: "linux" as const, profileDirectory: "linux.default" },
483 { platform: "darwin" as const, profileDirectory: "Profiles/macos.default" },
484 { platform: "win32" as const, profileDirectory: "Profiles/windows.default" },
485 ];
486
487 for (const { platform, profileDirectory } of cases) {
488 it.effect(`scans the ${platform} profile location and excludes stale entries`, () =>
489 run(
490 Effect.gen(function* () {
491 const fileSystem = yield* FileSystem.FileSystem;
492 const path = yield* Path.Path;
493 const home = yield* fileSystem.makeTempDirectoryScoped({
494 prefix: `t3code-firefox-${platform}-`,
495 });
496 const appData = path.join(home, "AppData", "Roaming");
497 const context = yield* sourcePathContext.pipe(
498 Effect.provideService(HostProcessEnvironment, {
499 HOME: home,
500 APPDATA: appData,
501 }),
502 Effect.provideService(HostProcessPlatform, platform),
503 );
504 const root = firefox.userDataDirectory(context)!;
505 const scanRoot = platform === "linux" ? root : path.join(root, "Profiles");
506 yield* fileSystem.makeDirectory(path.join(root, profileDirectory), { recursive: true });
507 yield* fileSystem.writeFileString(
508 path.join(root, profileDirectory, "cookies.sqlite"),
509 "db",
510 );
511 yield* fileSystem.makeDirectory(path.join(scanRoot, "stale.default"), {
512 recursive: true,
513 });
514 yield* fileSystem.writeFileString(path.join(scanRoot, "stale-file.default"), "not-dir");
515
516 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
517 {
518 directory: profileDirectory,
519 name: path.basename(profileDirectory),
520 },
521 ]);
522 }),
523 ),
524 );
525 }
526
527 it.effect("counts only importable cookies for declared and fallback profiles", () =>
528 run(
529 Effect.gen(function* () {
530 const fileSystem = yield* FileSystem.FileSystem;
531 const path = yield* Path.Path;
532 const home = yield* fileSystem.makeTempDirectoryScoped({
533 prefix: "t3code-firefox-counts-",
534 });
535 const context = yield* sourcePathContext.pipe(
536 Effect.provideService(HostProcessEnvironment, { HOME: home }),
537 Effect.provideService(HostProcessPlatform, "darwin"),
538 );
539 const root = firefox.userDataDirectory(context)!;
540 const declaredDirectory = path.join(root, "Profiles", "declared.default");
541 yield* fileSystem.makeDirectory(declaredDirectory, { recursive: true });
542 yield* writeFirefoxCookieDatabase(path.join(declaredDirectory, "cookies.sqlite"), 2, 3);
543 yield* fileSystem.writeFileString(
544 path.join(root, "profiles.ini"),
545 ["[Profile0]", "Name=Declared", "IsRelative=1", "Path=Profiles/declared.default"].join(
546 "\n",
547 ),
548 );
549
550 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
551 { directory: "Profiles/declared.default", name: "Declared", cookieCount: 2 },
552 ]);
553
554 yield* fileSystem.remove(path.join(root, "profiles.ini"));
555 const fallbackDirectory = path.join(root, "Profiles", "fallback.default");
556 yield* fileSystem.makeDirectory(fallbackDirectory, { recursive: true });
557 yield* writeFirefoxCookieDatabase(path.join(fallbackDirectory, "cookies.sqlite"), 1, 4);
558
559 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
560 { directory: "Profiles/declared.default", name: "declared.default", cookieCount: 2 },
561 { directory: "Profiles/fallback.default", name: "fallback.default", cookieCount: 1 },
562 ]);
563 }),
564 ),
565 );
566});
567
568describe("isSourceRunning for Firefox", () => {
569 it.effect("finds the lock inside the profile, not at the root", () =>
570 run(
571 Effect.gen(function* () {
572 const fileSystem = yield* FileSystem.FileSystem;
573 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" });
574 const context = yield* sourcePathContext.pipe(
575 Effect.provideService(HostProcessEnvironment, { HOME: home }),
576 Effect.provideService(HostProcessPlatform, "darwin"),
577 );
578 const root = firefox.userDataDirectory(context)!;
579 const profile = `${root}/Profiles/abcd.default-release`;
580 yield* fileSystem.makeDirectory(profile, { recursive: true });
581 yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db");
582
583 assert.isFalse(yield* isSourceRunning(firefox, context));
584
585 // Firefox keeps its locks per profile. A root-level lock is not one,
586 // and looking there was why a running Firefox read as importable.
587 yield* fileSystem.writeFileString(`${root}/lock`, "");
588 assert.isFalse(yield* isSourceRunning(firefox, context));
589
590 // `.parentlock` is deliberately left on disk after a clean exit as a
591 // last-used marker, so an unlocked one is not evidence of a running
592 // browser — treating it as one blocked every import after first use.
593 yield* fileSystem.writeFileString(`${profile}/.parentlock`, "");
594 assert.isFalse(yield* isSourceRunning(firefox, context));
595
596 // The `lock` symlink is what Firefox removes on exit; a live pid in
597 // its target means the profile is held.
598 yield* fileSystem.symlink(`127.0.0.1:+${process.pid}`, `${profile}/lock`);
599 assert.isTrue(yield* isSourceRunning(firefox, context));
600 }),
601 ),
602 );
603
604 it.effect("reports not-held when no interpreter can run the fcntl probe", () =>
605 run(
606 Effect.gen(function* () {
607 const fileSystem = yield* FileSystem.FileSystem;
608 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-lock-" });
609 const lock = `${directory}/.parentlock`;
610 yield* fileSystem.writeFileString(lock, "");
611 // A Mac without the developer tools has only Apple's shim, which
612 // refuses to run the script; a machine with no python at all has
613 // nothing. Either way the probe is unavailable, not the lock held —
614 // treating it as held would block Firefox import on that machine for
615 // good.
616 assert.isFalse(yield* posixLockIsHeld(lock, ["/nonexistent/python3"]));
617 // And a fake "interpreter" that exits non-zero without a verdict, as
618 // the shim does, is the same case.
619 assert.isFalse(yield* posixLockIsHeld(lock, ["/usr/bin/false"]));
620 }),
621 ),
622 );
623
624 it.effect("detects a live fcntl lock on .parentlock, as macOS Firefox leaves it", () =>
625 run(
626 Effect.gen(function* () {
627 const fileSystem = yield* FileSystem.FileSystem;
628 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
629 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" });
630 const context = yield* sourcePathContext.pipe(
631 Effect.provideService(HostProcessEnvironment, { HOME: home }),
632 Effect.provideService(HostProcessPlatform, "darwin"),
633 );
634 const root = firefox.userDataDirectory(context)!;
635 const profile = `${root}/Profiles/abcd.default-release`;
636 yield* fileSystem.makeDirectory(profile, { recursive: true });
637 yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db");
638 const parentLock = `${profile}/.parentlock`;
639 yield* fileSystem.writeFileString(parentLock, "");
640
641 // Hold the lock from a child the way Firefox does (F_SETLK, write),
642 // and keep it until the scope closes.
643 const holder = yield* spawner.spawn(
644 ChildProcess.make(
645 "python3",
646 [
647 "-c",
648 "import fcntl,os,sys,time\n" +
649 "fd=os.open(sys.argv[1],os.O_WRONLY)\n" +
650 "fcntl.lockf(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n" +
651 "print('locked',flush=True)\n" +
652 "time.sleep(30)",
653 parentLock,
654 ],
655 { stdin: "ignore" },
656 ),
657 );
658 // Wait for the child to confirm it holds the lock before probing.
659 yield* holder.stdout.pipe(
660 Stream.decodeText(),
661 Stream.splitLines,
662 Stream.filter((line) => line.trim() === "locked"),
663 Stream.take(1),
664 Stream.runDrain,
665 );
666
667 assert.isTrue(yield* isSourceRunning(firefox, context));
668 yield* holder.kill();
337669 }),
338670 ),
339671 );
672
673 it.effect("reads a Firefox lock symlink's pid to tell live from crashed", () =>
674 Effect.gen(function* () {
675 const alive = (pid: number) => Effect.succeed(pid === 4242);
676 // The resolver may hand Firefox any of the machine's addresses, not
677 // just 127.0.0.1 — 127.0.1.1 on Debian-style hosts, a LAN address
678 // elsewhere — so every local address counts as ours.
679 const local = new Set(["127.0.0.1", "127.0.1.1", "192.168.1.20"]);
680 // Both the plain and the fcntl-marked (`+`) forms carry the pid.
681 assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.0.1:4242", local, alive));
682 assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.1.1:+4242", local, alive));
683 assert.isTrue(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+4242", local, alive));
684 // A crash leaves the symlink behind with a dead pid, on any local address.
685 assert.isFalse(yield* firefoxSymlinkLockIsHeld("127.0.0.1:+9999", local, alive));
686 assert.isFalse(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+9999", local, alive));
687 // Anything unparseable stays conservative.
688 assert.isTrue(yield* firefoxSymlinkLockIsHeld("garbage", local, alive));
689 // A foreign owner (a shared profile locked from another machine) names
690 // a pid we cannot probe, so it is held regardless of local liveness.
691 assert.isTrue(yield* firefoxSymlinkLockIsHeld("10.0.0.7:+9999", local, alive));
692 }),
693 );
340694});
341695
342696describe("listSourceProfiles hardening", () => {
......@@ -344,16 +698,16 @@ describe("listSourceProfiles hardening", () => {
344698 run(
345699 Effect.gen(function* () {
346700 const fileSystem = yield* FileSystem.FileSystem;
347 const paths = yield* withSourceHome();
701 const context = yield* withSourceHome();
348702 // `Local State` is writable by anything running as the user, so a
349703 // crafted key must not reach `cookieDatabasePath` and read a database
350704 // outside the browser's user-data directory.
351705 yield* fileSystem.writeFileString(
352 `${helium.userDataDirectory(paths)}/Local State`,
706 `${userDataDirectory(context)}/Local State`,
353707 `{"profile":{"info_cache":{"Default":{"name":"You"},"../../../../secrets":{"name":"Escape"},"a/b":{"name":"Nested"},"..":{"name":"Parent"}}}}`,
354708 );
355709
356 const profiles = yield* listSourceProfiles(helium, paths);
710 const profiles = yield* listSourceProfiles(helium, context);
357711
358712 assert.deepEqual(
359713 profiles.map((profile) => profile.directory),
apps/desktop/src/preview/BrowserImport/Sources.ts+517-77
......@@ -1,56 +1,171 @@
11/**
22 * Importable browser sources.
33 *
4 * Each entry pins its own on-disk and keychain coordinates rather than
5 * deriving them: Chromium forks do not agree on the convention. Helium, for
6 * instance, uses the keychain service "Helium Storage Key" / account "Helium"
7 * where Chrome and its closer relatives use "<Name> Safe Storage" / "<Name>".
4 * Two engines are modelled. Chromium-family browsers keep cookies in an
5 * encrypted SQLite database whose key lives in an OS credential store; Firefox
6 * keeps them in plain SQLite with no key at all, so it needs no keychain and
7 * works the same on every platform.
8 *
9 * Each entry pins its own paths and keychain coordinates rather than deriving
10 * them, because the forks do not agree. Helium uses the keychain service
11 * "Helium Storage Key" / account "Helium" where Chrome and its closer
12 * relatives use "<Name> Safe Storage" / "<Name>", and the user-data directory
13 * differs per fork and per platform.
814 *
915 * @module BrowserImportSources
1016 */
1117import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";
1218import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
13import { HostProcessEnvironment, HostProcessHostname } from "@t3tools/shared/hostProcess";
19import {
20 HostProcessEnvironment,
21 HostProcessAddresses,
22 HostProcessHostname,
23 HostProcessPlatform,
24} from "@t3tools/shared/hostProcess";
1425import * as Effect from "effect/Effect";
1526import * as FileSystem from "effect/FileSystem";
1627import * as Path from "effect/Path";
28import * as PlatformError from "effect/PlatformError";
1729import * as Schema from "effect/Schema";
30import * as Stream from "effect/Stream";
31import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
1832import * as SqlClient from "effect/unstable/sql/SqlClient";
1933
34export type BrowserImportEngine = "chromium" | "firefox";
35
2036/**
21 * Where a source's files live, resolved once per call rather than read from
22 * the ambient process so the registry stays testable.
37 * Directory roots a definition builds its paths from. Passed in rather than
38 * read from `process`, so source resolution stays testable for platforms the
39 * host is not currently running.
2340 */
24export interface SourcePaths {
41export interface BrowserImportPathContext {
2542 readonly path: Path.Path;
43 readonly platform: NodeJS.Platform;
2644 readonly home: string;
45 /** `%APPDATA%` on Windows; unused elsewhere. */
46 readonly appData: string | undefined;
47 /** `%LOCALAPPDATA%` on Windows; unused elsewhere. */
48 readonly localAppData: string | undefined;
2749}
2850
29export const sourcePaths = Effect.gen(function* () {
30 const path = yield* Path.Path;
31 const environment = yield* HostProcessEnvironment;
32 return { path, home: environment.HOME ?? environment.USERPROFILE ?? "" } satisfies SourcePaths;
33});
34
3551export interface BrowserImportSourceDefinition {
3652 readonly id: BrowserImportSourceId;
3753 readonly name: string;
38 /** Platforms the definition's paths are valid for. */
54 readonly engine: BrowserImportEngine;
55 /** Platforms the definition has paths for. */
3956 readonly platforms: ReadonlyArray<NodeJS.Platform>;
40 readonly userDataDirectory: (paths: SourcePaths) => string;
57 readonly userDataDirectory: (context: BrowserImportPathContext) => string | undefined;
58 /** Chromium on macOS only: where the OSCrypt key lives in the keychain. */
59 readonly keychainService?: string;
60 readonly keychainAccount?: string;
61}
62
63const macApplicationSupport = (
64 context: BrowserImportPathContext,
65 ...segments: ReadonlyArray<string>
66) => context.path.join(context.home, "Library", "Application Support", ...segments);
67
68/**
69 * One Chromium fork on macOS and Linux. The leaves differ per fork; omitting a
70 * platform's segments marks the fork as unavailable there. Windows is left out
71 * on purpose: since Chrome 127 those cookies are encrypted to the browser's own
72 * identity (App-Bound Encryption), so no other process can read them.
73 */
74const chromiumSource = (input: {
75 readonly id: BrowserImportSourceId;
76 readonly name: string;
4177 readonly keychainService: string;
4278 readonly keychainAccount: string;
43}
79 readonly macSegments: ReadonlyArray<string>;
80 readonly linuxSegments?: ReadonlyArray<string>;
81}): BrowserImportSourceDefinition => ({
82 id: input.id,
83 name: input.name,
84 engine: "chromium",
85 platforms: [
86 "darwin" as NodeJS.Platform,
87 ...(input.linuxSegments ? ["linux" as NodeJS.Platform] : []),
88 ],
89 keychainService: input.keychainService,
90 keychainAccount: input.keychainAccount,
91 userDataDirectory: (context) => {
92 if (context.platform === "darwin") return macApplicationSupport(context, ...input.macSegments);
93 return input.linuxSegments
94 ? context.path.join(context.home, ".config", ...input.linuxSegments)
95 : undefined;
96 },
97});
4498
4599export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [
46 {
100 chromiumSource({
101 id: "chrome",
102 name: "Chrome",
103 keychainService: "Chrome Safe Storage",
104 keychainAccount: "Chrome",
105 macSegments: ["Google", "Chrome"],
106 linuxSegments: ["google-chrome"],
107 }),
108 chromiumSource({
109 id: "edge",
110 name: "Microsoft Edge",
111 keychainService: "Microsoft Edge Safe Storage",
112 keychainAccount: "Microsoft Edge",
113 macSegments: ["Microsoft Edge"],
114 linuxSegments: ["microsoft-edge"],
115 }),
116 chromiumSource({
117 id: "brave",
118 name: "Brave",
119 keychainService: "Brave Safe Storage",
120 keychainAccount: "Brave",
121 macSegments: ["BraveSoftware", "Brave-Browser"],
122 linuxSegments: ["BraveSoftware", "Brave-Browser"],
123 }),
124 chromiumSource({
125 id: "vivaldi",
126 name: "Vivaldi",
127 keychainService: "Vivaldi Safe Storage",
128 keychainAccount: "Vivaldi",
129 macSegments: ["Vivaldi"],
130 linuxSegments: ["vivaldi"],
131 }),
132 chromiumSource({
133 id: "opera",
134 name: "Opera",
135 keychainService: "Opera Safe Storage",
136 keychainAccount: "Opera",
137 macSegments: ["com.operasoftware.Opera"],
138 linuxSegments: ["opera"],
139 }),
140 // Arc and Helium ship macOS-only builds.
141 chromiumSource({
142 id: "arc",
143 name: "Arc",
144 keychainService: "Arc Safe Storage",
145 keychainAccount: "Arc",
146 macSegments: ["Arc", "User Data"],
147 }),
148 chromiumSource({
47149 id: "helium",
48150 name: "Helium",
49 platforms: ["darwin"],
50 userDataDirectory: ({ path, home }) =>
51 path.join(home, "Library", "Application Support", "net.imput.helium"),
52151 keychainService: "Helium Storage Key",
53152 keychainAccount: "Helium",
153 macSegments: ["net.imput.helium"],
154 }),
155 {
156 id: "firefox",
157 name: "Firefox",
158 engine: "firefox",
159 platforms: ["darwin", "win32", "linux"],
160 userDataDirectory: (context) => {
161 if (context.platform === "darwin") return macApplicationSupport(context, "Firefox");
162 if (context.platform === "win32") {
163 return context.appData
164 ? context.path.join(context.appData, "Mozilla", "Firefox")
165 : undefined;
166 }
167 return context.path.join(context.home, ".mozilla", "firefox");
168 },
54169 },
55170];
56171
......@@ -60,28 +175,117 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition
60175 * pre-96 install or a leftover from before the move. Importing the leftover
61176 * while sessions live in `Network/` would snapshot a stale or empty database,
62177 * and a fresh install with only `Network/Cookies` would read as not installed.
178 * Firefox uses `cookies.sqlite`, and its profile paths from `profiles.ini`
179 * may already be absolute.
63180 */
64181export const cookieDatabaseCandidatePaths = (
65182 definition: BrowserImportSourceDefinition,
66 paths: SourcePaths,
183 context: BrowserImportPathContext,
67184 profileDirectory: string,
68185): ReadonlyArray<string> => {
69 const profile = paths.path.join(definition.userDataDirectory(paths), profileDirectory);
70 return [paths.path.join(profile, "Network", "Cookies"), paths.path.join(profile, "Cookies")];
186 const root = definition.userDataDirectory(context);
187 if (root === undefined) return [];
188 const profile = context.path.isAbsolute(profileDirectory)
189 ? profileDirectory
190 : context.path.join(root, profileDirectory);
191 if (definition.engine === "firefox") return [context.path.join(profile, "cookies.sqlite")];
192 return [context.path.join(profile, "Network", "Cookies"), context.path.join(profile, "Cookies")];
71193};
72194
73195/** The first candidate that is a regular file, or undefined when none is. */
74196export const resolveCookieDatabase = Effect.fnUntraced(function* (
75197 definition: BrowserImportSourceDefinition,
76 paths: SourcePaths,
198 context: BrowserImportPathContext,
77199 profileDirectory: string,
78200) {
79 for (const candidate of cookieDatabaseCandidatePaths(definition, paths, profileDirectory)) {
201 for (const candidate of cookieDatabaseCandidatePaths(definition, context, profileDirectory)) {
80202 if (yield* databaseFileExists(candidate)) return candidate;
81203 }
82204 return undefined;
83205});
84206
207/**
208 * Firefox records its profiles in `profiles.ini`. `Install*` sections point at
209 * a default profile but do not describe one, so only `[ProfileN]` blocks
210 * count.
211 */
212export function parseFirefoxProfiles(
213 ini: string,
214 path: Path.Path,
215 root: string,
216): ReadonlyArray<BrowserImportSourceProfile> {
217 const profiles: BrowserImportSourceProfile[] = [];
218 let current: { name?: string; path?: string; isRelative?: string } | null = null;
219
220 const flush = () => {
221 if (current?.path) {
222 const candidate = current.path;
223 const isRelative = current.isRelative === undefined || current.isRelative === "1";
224 const validIsRelative = current.isRelative === undefined || /^[01]$/.test(current.isRelative);
225 if (!validIsRelative || candidate.includes("\u0000")) {
226 current = null;
227 return;
228 }
229
230 let directory: string | undefined;
231 if (isRelative) {
232 if (!path.isAbsolute(candidate)) {
233 const resolved = path.resolve(root, candidate);
234 const relative = path.relative(root, resolved);
235 const escapesRoot =
236 relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative);
237 if (!escapesRoot) directory = path.normalize(candidate);
238 }
239 } else if (path.isAbsolute(candidate)) {
240 // Firefox supports profiles on arbitrary custom roots when
241 // IsRelative=0. Do not constrain them to the standard Firefox root.
242 directory = path.normalize(candidate);
243 }
244
245 if (directory !== undefined) {
246 profiles.push({ directory, name: current.name?.trim() || directory });
247 }
248 }
249 current = null;
250 };
251
252 for (const rawLine of ini.split(/\r?\n/)) {
253 const line = rawLine.trim();
254 if (line.startsWith("[")) {
255 flush();
256 current = /^\[Profile\d+\]$/i.test(line) ? {} : null;
257 continue;
258 }
259 if (!current) continue;
260 const separator = line.indexOf("=");
261 if (separator === -1) continue;
262 const key = line.slice(0, separator).trim().toLowerCase();
263 const value = line.slice(separator + 1).trim();
264 if (key === "name") current.name = value;
265 if (key === "path") current.path = value;
266 if (key === "isrelative") current.isRelative = value;
267 }
268 flush();
269 return profiles;
270}
271
272/**
273 * Resolves the roots the registry builds its paths from, from the ambient
274 * process. Tests build a context directly instead.
275 */
276export const sourcePathContext = Effect.gen(function* () {
277 const path = yield* Path.Path;
278 const platform = yield* HostProcessPlatform;
279 const environment = yield* HostProcessEnvironment;
280 return {
281 path,
282 platform,
283 home: environment.HOME ?? environment.USERPROFILE ?? "",
284 appData: environment.APPDATA,
285 localAppData: environment.LOCALAPPDATA,
286 } satisfies BrowserImportPathContext;
287});
288
85289/** Shape of the slice of Chromium's `Local State` that names its profiles. */
86290const LocalState = Schema.Struct({
87291 profile: Schema.optional(
......@@ -106,21 +310,25 @@ const CookieCountRow = Schema.Struct({ count: Schema.Number });
106310const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow));
107311
108312/**
109 * How many cookies a profile holds, counted without decrypting anything — a
110 * bare `COUNT(*)` needs no key. Best effort: a locked, missing or non-Chromium
111 * database (Firefox's table is named differently, Safari's is not SQL) yields
112 * `undefined` rather than failing the listing.
313 * How many importable cookies a profile holds, counted without decrypting
314 * anything. Firefox containers use identities Electron cannot represent, so
315 * its count uses the same default-container predicate as the reader. Best
316 * effort: a locked, missing or unexpected database yields `undefined` rather
317 * than failing the listing.
113318 */
114319const countProfileCookies = Effect.fnUntraced(function* (
115320 definition: BrowserImportSourceDefinition,
116 paths: SourcePaths,
321 context: BrowserImportPathContext,
117322 directory: string,
118323): Effect.fn.Return<number | undefined, never, FileSystem.FileSystem> {
119 const database = yield* resolveCookieDatabase(definition, paths, directory);
324 const database = yield* resolveCookieDatabase(definition, context, directory);
120325 if (database === undefined) return undefined;
121326 return yield* Effect.gen(function* () {
122327 const sql = yield* SqlClient.SqlClient;
123 const rows = yield* sql`select count(*) as count from cookies`;
328 const rows =
329 definition.engine === "firefox"
330 ? yield* sql`select count(*) as count from moz_cookies where originAttributes = ''`
331 : yield* sql`select count(*) as count from cookies`;
124332 const [row] = yield* decodeCookieCount(rows);
125333 return row?.count;
126334 }).pipe(
......@@ -131,11 +339,11 @@ const countProfileCookies = Effect.fnUntraced(function* (
131339
132340const withCookieCounts = (
133341 definition: BrowserImportSourceDefinition,
134 paths: SourcePaths,
342 context: BrowserImportPathContext,
135343 profiles: ReadonlyArray<BrowserImportSourceProfile>,
136344) =>
137345 Effect.forEach(profiles, (profile) =>
138 countProfileCookies(definition, paths, profile.directory).pipe(
346 countProfileCookies(definition, context, profile.directory).pipe(
139347 Effect.map((cookieCount) =>
140348 cookieCount === undefined ? profile : { ...profile, cookieCount },
141349 ),
......@@ -143,26 +351,54 @@ const withCookieCounts = (
143351 );
144352
145353/**
146 * Profiles the source browser knows about, read from its `Local State`.
354 * Profiles the source browser knows about.
147355 *
148 * When that file is missing, unreadable or malformed, the user-data directory
149 * is scanned for directories that hold a cookie database. Assuming `Default`
150 * instead would report a browser whose cookies live in `Profile 1` as having
356 * Firefox declares them in `profiles.ini`; Chromium in `Local State`. When
357 * that metadata is missing, unreadable or malformed, the directories that
358 * actually hold a cookie database are scanned instead. Assuming a single
359 * `Default` would report a browser whose cookies live in `Profile 1` as having
151360 * nothing to import — and it is then left out of the menu entirely.
152361 */
153export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* (
362const listSourceProfilesInDirectory = Effect.fnUntraced(function* (
154363 definition: BrowserImportSourceDefinition,
155 paths: SourcePaths,
156) {
364 context: BrowserImportPathContext,
365): Effect.fn.Return<ReadonlyArray<BrowserImportSourceProfile>, never, FileSystem.FileSystem> {
157366 const fileSystem = yield* FileSystem.FileSystem;
158 const localStatePath = paths.path.join(definition.userDataDirectory(paths), "Local State");
367 const root = definition.userDataDirectory(context);
368 if (root === undefined) return [];
369
370 if (definition.engine === "firefox") {
371 const declared = yield* fileSystem.readFileString(context.path.join(root, "profiles.ini")).pipe(
372 Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)),
373 Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
374 );
375 if (declared.length > 0) return yield* withCookieCounts(definition, context, declared);
376
377 // Linux keeps profile directories directly under the Firefox root. macOS
378 // and Windows put them in `Profiles/`.
379 const fallbackDirectory =
380 context.platform === "linux" ? root : context.path.join(root, "Profiles");
381 const entries = yield* fileSystem
382 .readDirectory(fallbackDirectory)
383 .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>));
384 const found = yield* Effect.forEach(entries, (entry) => {
385 const directory = context.platform === "linux" ? entry : context.path.join("Profiles", entry);
386 return resolveCookieDatabase(definition, context, directory).pipe(
387 Effect.map((database) => (database === undefined ? undefined : { directory, name: entry })),
388 );
389 });
390 return yield* withCookieCounts(
391 definition,
392 context,
393 found.filter((profile) => profile !== undefined),
394 );
395 }
159396
160 const root = definition.userDataDirectory(paths);
161 const declared = yield* fileSystem.readFileString(localStatePath).pipe(
397 const declared = yield* fileSystem.readFileString(context.path.join(root, "Local State")).pipe(
162398 Effect.flatMap(decodeLocalState),
163399 Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})),
164400 // The keys are directory names from the browser's own metadata file, which
165 // is writable by anything running as the user. Anything but a single plain
401 // anything running as the user can write. Anything but a single plain
166402 // segment is dropped: `..` or a path separator would otherwise be handed
167403 // to `cookieDatabasePath` and read a database outside the user-data
168404 // directory.
......@@ -172,18 +408,15 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf
172408 ),
173409 Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
174410 );
175 if (declared.length > 0) return yield* withCookieCounts(definition, paths, declared);
411 if (declared.length > 0) return yield* withCookieCounts(definition, context, declared);
176412
177 // `Local State` is missing, unreadable or malformed. Scanning for
178 // directories that hold a cookie database finds the profiles anyway;
179 // assuming `Default` would report a browser whose cookies live in
180 // `Profile 1` as having nothing to import, and it is then hidden entirely.
413 // `Local State` is missing, unreadable or malformed. Scanning for directories
414 // that hold a cookie database finds the profiles anyway.
181415 const entries = yield* fileSystem
182416 .readDirectory(root)
183417 .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>));
184 const candidates = entries.filter(isSafeProfileDirectory);
185 const found = yield* Effect.forEach(candidates, (directory) =>
186 resolveCookieDatabase(definition, paths, directory).pipe(
418 const found = yield* Effect.forEach(entries.filter(isSafeProfileDirectory), (directory) =>
419 resolveCookieDatabase(definition, context, directory).pipe(
187420 Effect.map((database) =>
188421 database === undefined ? undefined : { directory, name: directory },
189422 ),
......@@ -191,11 +424,46 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf
191424 );
192425 return yield* withCookieCounts(
193426 definition,
194 paths,
427 context,
195428 found.filter((profile) => profile !== undefined),
196429 );
197430});
198431
432/**
433 * Include Firefox's Snap home alongside its native home. Snap profiles use
434 * absolute directories so cookie reads and lock checks keep pointing at the
435 * installation they came from, even when both installs use the same name.
436 */
437export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* (
438 definition: BrowserImportSourceDefinition,
439 context: BrowserImportPathContext,
440): Effect.fn.Return<ReadonlyArray<BrowserImportSourceProfile>, never, FileSystem.FileSystem> {
441 if (definition.engine !== "firefox" || context.platform !== "linux") {
442 return yield* listSourceProfilesInDirectory(definition, context);
443 }
444
445 const root = definition.userDataDirectory(context);
446 if (root === undefined) return [];
447 const roots = [
448 root,
449 context.path.join(context.home, "snap", "firefox", "common", ".mozilla", "firefox"),
450 ];
451 const profiles = new Map<string, BrowserImportSourceProfile>();
452 for (const directory of roots) {
453 const found = yield* listSourceProfilesInDirectory(
454 { ...definition, userDataDirectory: () => directory },
455 context,
456 );
457 for (const profile of found) {
458 const absolute = context.path.resolve(directory, profile.directory);
459 if (!profiles.has(absolute)) {
460 profiles.set(absolute, directory === root ? profile : { ...profile, directory: absolute });
461 }
462 }
463 }
464 return [...profiles.values()];
465});
466
199467/**
200468 * Whether a cookie database candidate is a regular file. Presence alone is
201469 * not enough: a directory at the path would list as an importable profile and
......@@ -254,28 +522,200 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* (
254522 return yield* isProcessAlive(pid);
255523});
256524
525/** Windows sharing and lock violations are translated by libuv to `Busy`. */
526export const isWindowsLockHeldError = (error: PlatformError.PlatformError): boolean =>
527 error.reason._tag === "Busy";
528
529/**
530 * Whether a Windows `parent.lock` is actually held by a running process. It
531 * is opened with no sharing, so it persists on disk after the process exits
532 * and `stat` always succeeds; only trying to open it for write reveals an
533 * active holder, which surfaces as `Busy`.
534 */
535const windowsLockIsHeld = Effect.fnUntraced(function* (lockPath: string) {
536 // Permission failures are distinct: they do not prove a browser owns the
537 // lock, so they must not hide the source as running.
538 const fileSystem = yield* FileSystem.FileSystem;
539 return yield* fileSystem.open(lockPath, { flag: "r+" }).pipe(
540 Effect.as(false),
541 Effect.catchIf(isWindowsLockHeldError, () => Effect.succeed(true)),
542 Effect.orElseSucceed(() => false),
543 Effect.scoped,
544 );
545});
546
547/**
548 * Whether a Firefox `lock` symlink's `<ip>:[+]<pid>` target still names a
549 * live owner. Firefox writes this symlink beside the profile while it runs and
550 * unlinks it on a clean exit, so a dangling one is either live or a crash.
551 */
552export const firefoxSymlinkLockIsHeld = Effect.fnUntraced(function* (
553 target: string,
554 localAddresses: ReadonlySet<string>,
555 isProcessAlive: ProcessLivenessProbe,
556) {
557 const separator = target.lastIndexOf(":");
558 if (separator < 0) return true;
559 // The owner half is whatever Firefox's resolver returned for the machine's
560 // hostname — 127.0.0.1 when the lookup fails, but often 127.0.1.1 or a LAN
561 // address — so a pid is only meaningful when that address is one of ours.
562 // A shared (NFS) profile locked from another machine names a foreign
563 // address whose pid cannot be probed here, nor could a reused local pid
564 // vouch for it, so it stays conservatively held.
565 const owner = target.slice(0, separator);
566 if (!localAddresses.has(owner)) return true;
567 // A `+` marks an fcntl-holding owner; the pid follows either way.
568 const pidText = target.slice(separator + 1).replace(/^\+/, "");
569 if (!/^\d+$/.test(pidText)) return true;
570 const pid = Number(pidText);
571 if (!Number.isSafeInteger(pid) || pid <= 0) return true;
572 return yield* isProcessAlive(pid);
573});
574
575/**
576 * Interpreters that can run the fcntl probe, tried in order. `/usr/bin/python3`
577 * is named absolutely first so a Dock-launched app with launchd's bare `PATH`
578 * still finds it without depending on the login-shell PATH merge; Linux
579 * distributions carry python3 on the default path.
580 */
581const FCNTL_PROBE_INTERPRETERS = ["/usr/bin/python3", "python3"] as const;
582
583/**
584 * The probe prints exactly one of these. Anything else means the script never
585 * ran — most importantly Apple's `/usr/bin/python3` shim, which on a Mac
586 * without the Command Line Tools exits non-zero after printing an install
587 * prompt, without ever reaching our code.
588 */
589const FCNTL_PROBE_SCRIPT =
590 "import fcntl,os,sys\n" +
591 "fd=os.open(sys.argv[1],os.O_WRONLY)\n" +
592 "try:\n" +
593 " fcntl.lockf(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n" +
594 "except BlockingIOError:\n" +
595 " print('held')\n" +
596 "else:\n" +
597 " print('free')";
598
599/**
600 * Whether another process holds an fcntl write lock on `path`.
601 *
602 * Firefox's `.parentlock` is an empty file whose only signal is the kernel
603 * lock, and Node exposes no fcntl, so a throwaway interpreter tries a
604 * non-blocking `F_SETLK` and reports `EWOULDBLOCK`. The lock is never
605 * acquired for real: on success the child exits and the kernel drops it.
606 *
607 * The answer is trusted only when the script itself spoke. A verdict of
608 * `held` or `free` on stdout is the probe's own, and stands. Anything else —
609 * no interpreter on any candidate path, or one that refused to run the script
610 * (Apple's shim without the developer tools) — is the probe being unavailable,
611 * not evidence about the lock. That case falls back to "not held" rather than
612 * "held": reporting every profile as locked forever would block Firefox import
613 * outright on such machines, and the SQLite snapshot already copes with a
614 * live database's WAL, as it does for every other engine.
615 */
616export const posixLockIsHeld = Effect.fnUntraced(function* (
617 path: string,
618 interpreters: ReadonlyArray<string> = FCNTL_PROBE_INTERPRETERS,
619) {
620 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
621 const environment = yield* HostProcessEnvironment;
622 for (const interpreter of interpreters) {
623 const verdict = yield* Effect.scoped(
624 Effect.gen(function* () {
625 const handle = yield* spawner.spawn(
626 ChildProcess.make(interpreter, ["-c", FCNTL_PROBE_SCRIPT, path], {
627 stdin: "ignore",
628 env: environment,
629 }),
630 );
631 const [stdout] = yield* Effect.all(
632 [handle.stdout.pipe(Stream.decodeText(), Stream.mkString), handle.exitCode],
633 { concurrency: "unbounded" },
634 );
635 return stdout.trim();
636 }),
637 ).pipe(Effect.orElseSucceed(() => ""));
638 if (verdict === "held") return true;
639 if (verdict === "free") return false;
640 }
641 return false;
642});
643
644/**
645 * Whether Firefox holds a profile.
646 *
647 * Firefox leaves two kinds of lock behind, and they mean different things.
648 * On Linux the `lock` symlink (target `<ip>:+<pid>`) is removed on a clean
649 * exit, so its presence is evidence — provided the pid it names is alive. But
650 * `.parentlock` (macOS/Linux) and `parent.lock` (Windows) are regular files
651 * held with fcntl or a Windows handle and are *deliberately left on disk*
652 * after exit, as a last-used marker; treating them as proof of a running
653 * browser blocks every import after Firefox has been used once. On POSIX the
654 * fcntl lock itself is the truth, and macOS in particular writes nothing else
655 * (no symlink, no pid), so `.parentlock` is probed for the kernel lock. On
656 * Windows the held handle denies our open, which `windowsLockIsHeld` reads as `Busy`.
657 */
658const firefoxProfileIsHeld = Effect.fnUntraced(function* (
659 directory: string,
660 context: BrowserImportPathContext,
661 // Resolved once by the caller: it involves a DNS lookup of the hostname and
662 // is the same for every profile.
663 localAddresses: ReadonlySet<string>,
664) {
665 const fileSystem = yield* FileSystem.FileSystem;
666 if (context.platform === "win32") {
667 return yield* windowsLockIsHeld(context.path.join(directory, "parent.lock"));
668 }
669 // Linux additionally writes the `lock` symlink; a live pid there settles it
670 // without spawning anything.
671 const symlinkHeld = yield* fileSystem.readLink(context.path.join(directory, "lock")).pipe(
672 Effect.flatMap((target) => firefoxSymlinkLockIsHeld(target, localAddresses, processIsAlive)),
673 Effect.orElseSucceed(() => false),
674 );
675 if (symlinkHeld) return true;
676 const parentLock = context.path.join(directory, ".parentlock");
677 const present = yield* fileSystem.stat(parentLock).pipe(
678 Effect.map((info) => info.type === "File"),
679 Effect.orElseSucceed(() => false),
680 );
681 if (!present) return false;
682 return yield* posixLockIsHeld(parentLock);
683});
684
257685/** Whether the browser is running, which leaves its cookie DB mid-write. */
258686export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")(function* (
259687 definition: BrowserImportSourceDefinition,
260 paths: SourcePaths,
261) {
688 context: BrowserImportPathContext,
689): Effect.fn.Return<
690 boolean,
691 never,
692 FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
693> {
262694 const fileSystem = yield* FileSystem.FileSystem;
263 const currentHost = yield* HostProcessHostname;
264 const lock = paths.path.join(definition.userDataDirectory(paths), "SingletonLock");
265 // Chromium writes a `SingletonLock` symlink for as long as an instance holds
266 // the profile. Its presence is a far cheaper and more targeted signal than
267 // scanning the process table for a name.
268 //
269 // The link points at `<host>-<pid>`, a target that never exists, and both
270 // `stat` and `exists` follow links — so they report every running browser as
271 // closed, which would let an import read a live, mid-write database.
272 // `readLink` is the one probe that answers for the entry itself. Chromium
273 // can leave this link behind after a crash, so a positively dead local PID
274 // is stale. Every ambiguous target or liveness result stays conservative.
275 return yield* fileSystem.readLink(lock).pipe(
276 Effect.flatMap((target) => chromiumSingletonLockIsHeld(target, currentHost, processIsAlive)),
277 Effect.catch((error) => Effect.succeed(error.reason._tag !== "NotFound")),
278 );
695 const root = definition.userDataDirectory(context);
696 if (root === undefined) return false;
697 // Both engines leave a lock file for as long as an instance holds a profile,
698 // which is far cheaper and more targeted than scanning the process table.
699 if (definition.engine !== "firefox") {
700 const currentHost = yield* HostProcessHostname;
701 const lock = context.path.join(root, "SingletonLock");
702 return yield* fileSystem.readLink(lock).pipe(
703 Effect.flatMap((target) => chromiumSingletonLockIsHeld(target, currentHost, processIsAlive)),
704 Effect.catch((error) => Effect.succeed(error.reason._tag !== "NotFound")),
705 );
706 }
707
708 const profiles = yield* listSourceProfiles(definition, context);
709 // Only the Linux `lock` symlink names an address, so Windows skips the lookup.
710 const localAddresses: ReadonlySet<string> =
711 context.platform === "win32" ? new Set() : yield* yield* HostProcessAddresses;
712 const found = yield* Effect.forEach(profiles, (profile) => {
713 const directory = context.path.isAbsolute(profile.directory)
714 ? profile.directory
715 : context.path.join(root, profile.directory);
716 return firefoxProfileIsHeld(directory, context, localAddresses);
717 });
718 return found.some(Boolean);
279719});
280720
281721/**
......@@ -295,11 +735,11 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")
295735 */
296736export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstalled")(function* (
297737 definition: BrowserImportSourceDefinition,
298 paths: SourcePaths,
299) {
300 const profiles = yield* listSourceProfiles(definition, paths);
738 context: BrowserImportPathContext,
739): Effect.fn.Return<boolean, never, FileSystem.FileSystem> {
740 const profiles = yield* listSourceProfiles(definition, context);
301741 const found = yield* Effect.forEach(profiles, (profile) =>
302 resolveCookieDatabase(definition, paths, profile.directory).pipe(
742 resolveCookieDatabase(definition, context, profile.directory).pipe(
303743 Effect.map((database) => database !== undefined),
304744 ),
305745 );
docs/user/browser-import.md created+10
......@@ -0,0 +1,10 @@
1# Import browser logins
2
3In the desktop app, open **Settings → Integrations → Browser profiles → Add profile**
4and choose a browser under **Import from**. The import copies cookies into a T3 Code browser
5profile so you can use existing logins in the preview browser. Changes made afterward stay
6separate from the source browser.
7
8On Linux, Firefox profiles are discovered from both native and Snap installations. A browser
9appears once it has a profile with a cookie database. Close the source browser before importing;
10the import wizard will prompt you if it is still running.
packages/contracts/src/browserImport.ts+10-1
......@@ -16,7 +16,16 @@ import * as Schema from "effect/Schema";
1616import { TrimmedNonEmptyString } from "./baseSchemas.ts";
1717import { BrowserProfileId } from "./browserProfile.ts";
1818
19export const BROWSER_IMPORT_SOURCE_IDS = ["helium"] as const;
19export const BROWSER_IMPORT_SOURCE_IDS = [
20 "chrome",
21 "edge",
22 "brave",
23 "vivaldi",
24 "opera",
25 "arc",
26 "helium",
27 "firefox",
28] as const;
2029
2130export const BrowserImportSourceId = Schema.Literals(BROWSER_IMPORT_SOURCE_IDS);
2231export type BrowserImportSourceId = typeof BrowserImportSourceId.Type;
packages/shared/src/hostProcess.ts+30
......@@ -1,5 +1,6 @@
11import * as Context from "effect/Context";
22import * as Effect from "effect/Effect";
3import * as NodeDns from "node:dns";
34import * as NodeOS from "node:os";
45
56export const HostProcessPlatform = Context.Reference<NodeJS.Platform>(
......@@ -51,6 +52,35 @@ export const HostProcessArguments = Context.Reference<ReadonlyArray<string>>(
5152 },
5253);
5354
55/**
56 * Every IP address this machine answers to: the interface addresses, plus
57 * whatever the resolver returns for the machine's own hostname. The latter
58 * matters because a hostname can map to an address no interface carries —
59 * Debian-style hosts put `127.0.1.1` in `/etc/hosts` — and a program that
60 * records "its" address by resolving its hostname (Firefox's profile lock
61 * does) will write that one. "Is this address ours" has to accept both.
62 *
63 * Best effort: a failed lookup just leaves the interface set.
64 */
65export const HostProcessAddresses = Context.Reference<Effect.Effect<ReadonlySet<string>>>(
66 "@t3tools/shared/hostProcess/HostProcessAddresses",
67 {
68 defaultValue: () =>
69 Effect.gen(function* () {
70 const interfaces = Object.values(NodeOS.networkInterfaces())
71 .flat()
72 .flatMap((entry) => (entry ? [entry.address] : []));
73 const resolved = yield* Effect.tryPromise(() =>
74 NodeDns.promises.lookup(NodeOS.hostname(), { all: true }),
75 ).pipe(
76 Effect.map((entries) => entries.map((entry) => entry.address)),
77 Effect.orElseSucceed(() => [] as ReadonlyArray<string>),
78 );
79 return new Set([...interfaces, ...resolved]);
80 }),
81 },
82);
83
5484/** Undefined on platforms without POSIX uids (Windows). */
5585export const HostProcessUserId = Context.Reference<number | undefined>(
5686 "@t3tools/shared/hostProcess/HostProcessUserId",