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";...@@ -13,7 +13,7 @@ import * as Ref from "effect/Ref";
1313
14import * as BrowserSession from "../BrowserSession.ts";14import * as BrowserSession from "../BrowserSession.ts";
15import * as BrowserImport from "./BrowserImport.ts";15import * as BrowserImport from "./BrowserImport.ts";
16import { BROWSER_IMPORT_SOURCES, sourcePaths } from "./Sources.ts";16import { BROWSER_IMPORT_SOURCES, sourcePathContext } from "./Sources.ts";
1717
18const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;18const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;
1919
...@@ -52,15 +52,16 @@ const withImporter = Effect.fnUntraced(function* () {...@@ -52,15 +52,16 @@ const withImporter = Effect.fnUntraced(function* () {
52 const fileSystem = yield* FileSystem.FileSystem;52 const fileSystem = yield* FileSystem.FileSystem;
53 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-" });53 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-" });
54 const environment = Layer.succeed(HostProcessEnvironment, { HOME: home });54 const environment = Layer.succeed(HostProcessEnvironment, { HOME: home });
55 const paths = yield* sourcePaths.pipe(55 const context = yield* sourcePathContext.pipe(
56 Effect.provideService(HostProcessEnvironment, { HOME: home }),56 Effect.provideService(HostProcessEnvironment, { HOME: home }),
57 Effect.provideService(HostProcessPlatform, "darwin"),
57 );58 );
58 yield* fileSystem.makeDirectory(`${helium.userDataDirectory(paths)}/Default`, {59 const root = helium.userDataDirectory(context);
59 recursive: true,60 if (root === undefined) throw new Error("Helium has no macOS user-data directory");
60 });61 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
61 // The cookie database is what marks a source as installed, so a fixture62 // The cookie database is what marks a source as installed, so a fixture
62 // without one is reported as absent before any other check runs.63 // 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
65 const importer = yield* BrowserImport.BrowserImport.pipe(66 const importer = yield* BrowserImport.BrowserImport.pipe(
66 Effect.provide(67 Effect.provide(
...@@ -73,7 +74,7 @@ const withImporter = Effect.fnUntraced(function* () {...@@ -73,7 +74,7 @@ const withImporter = Effect.fnUntraced(function* () {
73 ),74 ),
74 ),75 ),
75 );76 );
76 return { importer, home, paths };77 return { importer, home, root };
77});78});
7879
79describe("BrowserImport.importCookies", () => {80describe("BrowserImport.importCookies", () => {
...@@ -107,13 +108,10 @@ describe("BrowserImport.importCookies", () => {...@@ -107,13 +108,10 @@ describe("BrowserImport.importCookies", () => {
107 it.effect("refuses to import while the source browser holds its profile", () =>108 it.effect("refuses to import while the source browser holds its profile", () =>
108 Effect.gen(function* () {109 Effect.gen(function* () {
109 const fileSystem = yield* FileSystem.FileSystem;110 const fileSystem = yield* FileSystem.FileSystem;
110 const { importer, paths } = yield* withImporter();111 const { importer, root } = yield* withImporter();
111 // The lock Chromium leaves while it is running, dangling target and112 // The lock Chromium leaves while it is running, dangling target and
112 // all. This must stop the import before it ever asks the keychain.113 // all. This must stop the import before it ever asks the keychain.
113 yield* fileSystem.symlink(114 yield* fileSystem.symlink("host-that-does-not-exist-1234", `${root}/SingletonLock`);
114 "host-that-does-not-exist-1234",
115 `${helium.userDataDirectory(paths)}/SingletonLock`,
116 );
117115
118 const error = yield* importer116 const error = yield* importer
119 .importCookies({117 .importCookies({
apps/desktop/src/preview/BrowserImport/BrowserImport.ts+81-32
...@@ -18,20 +18,24 @@ import * as FileSystem from "effect/FileSystem";...@@ -18,20 +18,24 @@ import * as FileSystem from "effect/FileSystem";
18import * as Layer from "effect/Layer";18import * as Layer from "effect/Layer";
19import * as Path from "effect/Path";19import * as Path from "effect/Path";
20import * as Schema from "effect/Schema";20import * as Schema from "effect/Schema";
21import * as Scope from "effect/Scope";
22import { ChildProcessSpawner } from "effect/unstable/process";
2123
22import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";24import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";
2325
24import * as BrowserSession from "../BrowserSession.ts";26import * 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";
26import {30import {
27 BROWSER_IMPORT_SOURCES,31 BROWSER_IMPORT_SOURCES,
28 resolveCookieDatabase,32 resolveCookieDatabase,
29 isSourceInstalled,33 isSourceInstalled,
30 isSourceRunning,34 isSourceRunning,
31 listSourceProfiles,35 listSourceProfiles,
32 sourcePaths,36 sourcePathContext,
37 type BrowserImportPathContext,
33 type BrowserImportSourceDefinition,38 type BrowserImportSourceDefinition,
34 type SourcePaths,
35} from "./Sources.ts";39} from "./Sources.ts";
3640
37export class BrowserImportFailedError extends Schema.TaggedErrorClass<BrowserImportFailedError>()(41export class BrowserImportFailedError extends Schema.TaggedErrorClass<BrowserImportFailedError>()(
...@@ -79,12 +83,20 @@ export class BrowserImport extends Context.Service<...@@ -79,12 +83,20 @@ export class BrowserImport extends Context.Service<
7983
80const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* (84const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* (
81 definition: BrowserImportSourceDefinition,85 definition: BrowserImportSourceDefinition,
82 platform: NodeJS.Platform,86 context: BrowserImportPathContext,
83 paths: SourcePaths,87): Effect.fn.Return<
84): Effect.fn.Return<BrowserImportUnavailableReason | undefined, never, FileSystem.FileSystem> {88 BrowserImportUnavailableReason | undefined,
85 if (!definition.platforms.includes(platform)) return "unsupportedPlatform";89 never,
86 if (!(yield* isSourceInstalled(definition, paths))) return "notInstalled";90 FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
87 if (yield* isSourceRunning(definition, paths)) return "browserRunning";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";
88 return undefined;100 return undefined;
89});101});
90102
...@@ -155,19 +167,22 @@ export const make = Effect.gen(function* BrowserImportMake() {...@@ -155,19 +167,22 @@ export const make = Effect.gen(function* BrowserImportMake() {
155 const executablePath = yield* HostProcessExecutablePath;167 const executablePath = yield* HostProcessExecutablePath;
156 // Captured here so the service's methods stay free of a requirements168 // Captured here so the service's methods stay free of a requirements
157 // channel: the layer is built where NodeServices is already in scope.169 // channel: the layer is built where NodeServices is already in scope.
158 const platformServices = yield* Effect.context<FileSystem.FileSystem | Path.Path>();170 const platformServices = yield* Effect.context<
159 const paths = yield* sourcePaths;171 FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
172 >();
173 const pathContext = yield* sourcePathContext;
160174
161 const listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>> = Effect.forEach(175 const listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>> = Effect.forEach(
162 BROWSER_IMPORT_SOURCES,176 BROWSER_IMPORT_SOURCES,
163 Effect.fnUntraced(function* (definition) {177 Effect.fnUntraced(function* (definition) {
164 const unavailable = yield* unavailableReason(definition, platform, paths);178 const unavailable = yield* unavailableReason(definition, pathContext);
165 return {179 return {
166 id: definition.id,180 id: definition.id,
167 name: definition.name,181 name: definition.name,
168 // Listing profiles touches the source's own files, so skip it when the182 // Listing profiles touches the source's own files, so skip it when the
169 // source is unusable anyway.183 // source is unusable anyway.
170 profiles: unavailable === undefined ? yield* listSourceProfiles(definition, paths) : [],184 profiles:
185 unavailable === undefined ? yield* listSourceProfiles(definition, pathContext) : [],
171 ...(unavailable === undefined ? {} : { unavailable }),186 ...(unavailable === undefined ? {} : { unavailable }),
172 } satisfies BrowserImportSource;187 } satisfies BrowserImportSource;
173 }),188 }),
...@@ -189,7 +204,7 @@ export const make = Effect.gen(function* BrowserImportMake() {...@@ -189,7 +204,7 @@ export const make = Effect.gen(function* BrowserImportMake() {
189 });204 });
190 }205 }
191206
192 const blocked = yield* unavailableReason(definition, platform, paths).pipe(207 const blocked = yield* unavailableReason(definition, pathContext).pipe(
193 Effect.provide(platformServices),208 Effect.provide(platformServices),
194 );209 );
195 if (blocked !== undefined) {210 if (blocked !== undefined) {
...@@ -199,16 +214,21 @@ export const make = Effect.gen(function* BrowserImportMake() {...@@ -199,16 +214,21 @@ export const make = Effect.gen(function* BrowserImportMake() {
199 // macOS attributes the Keychain prompt and the resulting ACL grant to the214 // macOS attributes the Keychain prompt and the resulting ACL grant to the
200 // executable that asks, so record which one that was — in a packaged build215 // executable that asks, so record which one that was — in a packaged build
201 // it is the signed app, in dev whatever binary hosts the main process.216 // it is the signed app, in dev whatever binary hosts the main process.
202 yield* Effect.logInfo("Reading browser cookie key from the keychain", {217 // Only Chromium reads a key; a Firefox import touches no keychain, and
203 sourceId: definition.id,218 // logging that it did would put a false security-sensitive event in the
204 executablePath,219 // audit trail.
205 });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
207 // The profile directory arrives over IPC, so it is only honoured when the227 // The profile directory arrives over IPC, so it is only honoured when the
208 // source itself reported it. Forwarding it unchecked would let `..`228 // source itself reported it. Forwarding it unchecked would let `..`
209 // segments walk out of the browser's user-data directory and read any229 // segments walk out of the browser's user-data directory and read any
210 // cookie database reachable on disk.230 // cookie database reachable on disk.
211 const sourceProfiles = yield* listSourceProfiles(definition, paths).pipe(231 const sourceProfiles = yield* listSourceProfiles(definition, pathContext).pipe(
212 Effect.provide(platformServices),232 Effect.provide(platformServices),
213 );233 );
214 const requestedProfile = sourceProfiles.find(234 const requestedProfile = sourceProfiles.find(
...@@ -222,28 +242,57 @@ export const make = Effect.gen(function* BrowserImportMake() {...@@ -222,28 +242,57 @@ export const make = Effect.gen(function* BrowserImportMake() {
222 }242 }
223243
224 // The profile was listed against a database moments ago; resolve it again244 // 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/`.
226 const databasePath = yield* resolveCookieDatabase(246 const databasePath = yield* resolveCookieDatabase(
227 definition,247 definition,
228 paths,248 pathContext,
229 requestedProfile.directory,249 requestedProfile.directory,
230 ).pipe(Effect.provide(platformServices));250 ).pipe(Effect.provide(platformServices));
231 if (databasePath === undefined) {251 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.
232 return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed" });255 return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed" });
233 }256 }
234257
235 const read = yield* readChromiumCookies({258 // Both branches fail with a tagged error, so the union stays structurally
236 cookieDatabasePath: databasePath,259 // identifiable and each tag is handled on its own below. The success side
237 keychainService: definition.keychainService,260 // is normalized to one shape too, so the skipped tally survives either
238 keychainAccount: definition.keychainAccount,261 // engine — Firefox stores plaintext, so nothing there is ever unreadable.
239 platform,262 const read: Effect.Effect<
240 }).pipe(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(
241 Effect.scoped,281 Effect.scoped,
242 Effect.provide(platformServices),282 Effect.provide(platformServices),
243 Effect.mapError(283 Effect.catchTags({
244 (cause) =>284 ChromiumCookieReadError: (cause) =>
245 new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }),285 Effect.fail(
246 ),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 }),
247 );296 );
248297
249 const session = yield* browserSession298 const session = yield* browserSession
...@@ -261,7 +310,7 @@ export const make = Effect.gen(function* BrowserImportMake() {...@@ -261,7 +310,7 @@ export const make = Effect.gen(function* BrowserImportMake() {
261310
262 // Written one at a time rather than in parallel: Chromium's cookie store311 // Written one at a time rather than in parallel: Chromium's cookie store
263 // serialises writes anyway, and a rejected cookie should only cost itself.312 // serialises writes anyway, and a rejected cookie should only cost itself.
264 return yield* writeCookies(session, read);313 return yield* writeCookies(session, result);
265 });314 });
266315
267 return BrowserImport.of({ listSources, importCookies });316 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";...@@ -6,103 +6,16 @@ import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
6import * as NodeCrypto from "node:crypto";6import * as NodeCrypto from "node:crypto";
7import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as Scope from "effect/Scope";
11import * as SqlClient from "effect/unstable/sql/SqlClient";9import * as SqlClient from "effect/unstable/sql/SqlClient";
1210
13import {11import { readChromiumCookieDatabase } from "./ChromiumCookies.ts";
14 cookieScope,12import { cookieScope } from "./CookieDatabase.ts";
15 readChromiumCookieDatabase,
16 snapshotCookieDatabase,
17} from "./ChromiumCookies.ts";
1813
19const encryptV10 = (value: string | Buffer, key: Buffer): Uint8Array => {14const encryptV10 = (value: string | Buffer, key: Buffer): Uint8Array => {
20 const cipher = NodeCrypto.createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20));15 const cipher = NodeCrypto.createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20));
21 return Buffer.concat([Buffer.from("v10"), cipher.update(value), cipher.final()]);16 return Buffer.concat([Buffer.from("v10"), cipher.update(value), cipher.final()]);
22};17};
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
106describe("cookieScope", () => {19describe("cookieScope", () => {
107 it("keeps a host-only cookie host-only", () => {20 it("keeps a host-only cookie host-only", () => {
108 // Chromium stores a host-only cookie without a leading dot. Passing any21 // 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";...@@ -19,11 +19,17 @@ import * as NodeCrypto from "node:crypto";
1919
20import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";20import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
21import * as Effect from "effect/Effect";21import * as Effect from "effect/Effect";
22import * as FileSystem from "effect/FileSystem";
23import * as Path from "effect/Path";
24import * as Schema from "effect/Schema";22import * as Schema from "effect/Schema";
25import * as SqlClient from "effect/unstable/sql/SqlClient";23import * 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
27/** macOS OSCrypt parameters. Chromium has used these since the feature landed. */33/** macOS OSCrypt parameters. Chromium has used these since the feature landed. */
28const MAC_KEY_ITERATIONS = 1003;34const MAC_KEY_ITERATIONS = 1003;
29const MAC_KEY_SALT = "saltysalt";35const MAC_KEY_SALT = "saltysalt";
...@@ -32,25 +38,6 @@ const MAC_KEY_LENGTH = 16;...@@ -32,25 +38,6 @@ const MAC_KEY_LENGTH = 16;
32const AES_IV = Buffer.alloc(16, 0x20);38const AES_IV = Buffer.alloc(16, 0x20);
33const V10_PREFIX = "v10";39const 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
54export const ChromiumCookieReadReason = Schema.Literals([41export const ChromiumCookieReadReason = Schema.Literals([
55 "needsKeychainApproval",42 "needsKeychainApproval",
56 "keychainItemMissing",43 "keychainItemMissing",
...@@ -104,14 +91,17 @@ const decodeSchemaVersion = Schema.decodeUnknownEffect(...@@ -104,14 +91,17 @@ const decodeSchemaVersion = Schema.decodeUnknownEffect(
104);91);
10592
106/**93/**
107 * Chromium stores `SameSite` as an int; unspecified (-1) behaves as Lax in94 * Chromium stores `SameSite` as an int: -1 = unspecified, 0 = none, 1 = lax,
108 * modern Chromium, so it maps there rather than to `no_restriction`, which95 * 2 = strict. Unspecified is imported as Electron's own `unspecified` rather
109 * would widen the cookie's scope on import.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.
110 */99 */
111const sameSiteFromColumn = (value: number): ChromiumCookie["sameSite"] => {100const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => {
112 if (value === 0) return "no_restriction";101 if (value === 0) return "no_restriction";
102 if (value === 1) return "lax";
113 if (value === 2) return "strict";103 if (value === 2) return "strict";
114 return "lax";104 return "unspecified";
115};105};
116106
117/**107/**
...@@ -171,60 +161,6 @@ const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPasswo...@@ -171,60 +161,6 @@ const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPasswo
171 return password;161 return password;
172});162});
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
228const decryptValue = (164const decryptValue = (
229 encrypted: Uint8Array,165 encrypted: Uint8Array,
230 key: Buffer,166 key: Buffer,
...@@ -294,7 +230,7 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu...@@ -294,7 +230,7 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu
294 return { rows: yield* decodeCookieRows(raw), schemaVersion };230 return { rows: yield* decodeCookieRows(raw), schemaVersion };
295 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })));231 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })));
296232
297 const cookies: ChromiumCookie[] = [];233 const cookies: ImportedCookie[] = [];
298 let undecryptable = 0;234 let undecryptable = 0;
299 const undecryptableHosts = new Set<string>();235 const undecryptableHosts = new Set<string>();
300 for (const row of rows.rows) {236 for (const row of rows.rows) {
...@@ -340,18 +276,6 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu...@@ -340,18 +276,6 @@ export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiu
340 },276 },
341);277);
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
355export interface ChromiumCookieSource {279export interface ChromiumCookieSource {
356 readonly cookieDatabasePath: string;280 readonly cookieDatabasePath: string;
357 readonly keychainService: string;281 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 @@...@@ -2,23 +2,32 @@
2// table with the same native bindings the source reads.2// table with the same native bindings the source reads.
3import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { assert, describe, it } from "@effect/vitest";4import { 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";
6import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";11import * as FileSystem from "effect/FileSystem";
8import * as Path from "effect/Path";12import * as Path from "effect/Path";
9import * as Scope from "effect/Scope";13import * as Scope from "effect/Scope";
14import * as Stream from "effect/Stream";
15import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
10import * as NodeSqlite from "node:sqlite";16import * as NodeSqlite from "node:sqlite";
1117
18import type { BrowserImportPathContext } from "./Sources.ts";
12import {19import {
13 BROWSER_IMPORT_SOURCES,20 BROWSER_IMPORT_SOURCES,
14 chromiumProcessIsAlive,21 chromiumProcessIsAlive,
15 chromiumSingletonLockIsHeld,22 chromiumSingletonLockIsHeld,
16 cookieDatabaseCandidatePaths,23 cookieDatabaseCandidatePaths,
24 firefoxSymlinkLockIsHeld,
17 resolveCookieDatabase,25 resolveCookieDatabase,
18 isSourceInstalled,26 isSourceInstalled,
19 isSourceRunning,27 isSourceRunning,
28 posixLockIsHeld,
20 listSourceProfiles,29 listSourceProfiles,
21 sourcePaths,30 sourcePathContext,
22} from "./Sources.ts";31} from "./Sources.ts";
2332
24const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;33const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;
...@@ -27,15 +36,28 @@ const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;...@@ -27,15 +36,28 @@ const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;
27const withSourceHome = Effect.fnUntraced(function* () {36const withSourceHome = Effect.fnUntraced(function* () {
28 const fileSystem = yield* FileSystem.FileSystem;37 const fileSystem = yield* FileSystem.FileSystem;
29 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-sources-" });38 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-sources-" });
30 const paths = yield* sourcePaths.pipe(39 const context = yield* sourcePathContext.pipe(
31 Effect.provideService(HostProcessEnvironment, { HOME: home }),40 Effect.provideService(HostProcessEnvironment, { HOME: home }),
41 Effect.provideService(HostProcessPlatform, "darwin"),
32 );42 );
33 yield* fileSystem.makeDirectory(helium.userDataDirectory(paths), { recursive: true });43 yield* fileSystem.makeDirectory(userDataDirectory(context), { recursive: true });
34 return paths;44 return context;
35});45});
3646
37const run = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>) =>47/** Every case here runs on darwin, where Helium always resolves a directory. */
38 effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped);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
40/** Writes a Chromium-shaped cookie table with `count` rows. */62/** Writes a Chromium-shaped cookie table with `count` rows. */
41const writeCookieDatabase = (file: string, count: number) =>63const writeCookieDatabase = (file: string, count: number) =>
...@@ -47,23 +69,37 @@ const writeCookieDatabase = (file: string, count: number) =>...@@ -47,23 +69,37 @@ const writeCookieDatabase = (file: string, count: number) =>
47 database.close();69 database.close();
48 });70 });
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
50describe("isSourceRunning", () => {86describe("isSourceRunning", () => {
51 it.effect("reads Chromium's dangling SingletonLock symlink as a running browser", () =>87 it.effect("reads Chromium's dangling SingletonLock symlink as a running browser", () =>
52 run(88 run(
53 Effect.gen(function* () {89 Effect.gen(function* () {
54 const fileSystem = yield* FileSystem.FileSystem;90 const fileSystem = yield* FileSystem.FileSystem;
55 const paths = yield* withSourceHome();91 const context = yield* withSourceHome();
56 assert.isFalse(yield* isSourceRunning(helium, paths));92 assert.isFalse(yield* isSourceRunning(helium, context));
5793
58 // Chromium points the lock at `<host>-<pid>`, a target that never94 // Chromium points the lock at `<host>-<pid>`, a target that never
59 // exists on disk. A check that follows the link reports a running95 // exists on disk. A check that follows the link reports a running
60 // browser as closed, letting an import read a live, mid-write database.96 // browser as closed, letting an import read a live, mid-write database.
61 yield* fileSystem.symlink(97 yield* fileSystem.symlink(
62 "host-that-does-not-exist-1234",98 "host-that-does-not-exist-1234",
63 `${helium.userDataDirectory(paths)}/SingletonLock`,99 `${userDataDirectory(context)}/SingletonLock`,
64 );100 );
65101
66 assert.isTrue(yield* isSourceRunning(helium, paths));102 assert.isTrue(yield* isSourceRunning(helium, context));
67 }),103 }),
68 ),104 ),
69 );105 );
...@@ -172,28 +208,49 @@ describe("isSourceInstalled", () => {...@@ -172,28 +208,49 @@ describe("isSourceInstalled", () => {
172 run(208 run(
173 Effect.gen(function* () {209 Effect.gen(function* () {
174 const fileSystem = yield* FileSystem.FileSystem;210 const fileSystem = yield* FileSystem.FileSystem;
175 const paths = yield* withSourceHome();211 const context = yield* withSourceHome();
176 const root = helium.userDataDirectory(paths);212 const root = userDataDirectory(context);
177213
178 // Installers for native messaging hosts create an empty user-data214 // Installers for native messaging hosts create an empty user-data
179 // directory for every Chromium fork they know about, so treating the215 // directory for every Chromium fork they know about, so treating the
180 // directory as evidence lists browsers the user does not have.216 // directory as evidence lists browsers the user does not have.
181 yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true });217 yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true });
182 assert.isFalse(yield* isSourceInstalled(helium, paths));218 assert.isFalse(yield* isSourceInstalled(helium, context));
183219
184 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });220 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
185 yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db");221 yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db");
186 assert.isTrue(yield* isSourceInstalled(helium, paths));222 assert.isTrue(yield* isSourceInstalled(helium, context));
187223
188 // A real install whose cookies live outside `Default` still counts:224 // A real install whose cookies live outside `Default` still counts:
189 // reporting it as absent hides the source from the menu entirely.225 // reporting it as absent hides the source from the menu entirely.
190 yield* fileSystem.remove(`${root}/Default`, { recursive: true });226 yield* fileSystem.remove(`${root}/Default`, { recursive: true });
191 yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true });227 yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true });
192 yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db");228 yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db");
193 assert.isTrue(yield* isSourceInstalled(helium, paths));229 assert.isTrue(yield* isSourceInstalled(helium, context));
194230
195 yield* fileSystem.remove(root, { recursive: true });231 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));
197 }),254 }),
198 ),255 ),
199 );256 );
...@@ -224,15 +281,15 @@ describe("listSourceProfiles", () => {...@@ -224,15 +281,15 @@ describe("listSourceProfiles", () => {
224 run(281 run(
225 Effect.gen(function* () {282 Effect.gen(function* () {
226 const fileSystem = yield* FileSystem.FileSystem;283 const fileSystem = yield* FileSystem.FileSystem;
227 const paths = yield* withSourceHome();284 const context = yield* withSourceHome();
228 const root = helium.userDataDirectory(paths);285 const root = userDataDirectory(context);
229 // Assuming `Default` would report a browser whose cookies live in286 // Assuming `Default` would report a browser whose cookies live in
230 // `Profile 1` as having nothing to import, and it is then hidden.287 // `Profile 1` as having nothing to import, and it is then hidden.
231 yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true });288 yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true });
232 yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db");289 yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db");
233 yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true });290 yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true });
234291
235 assert.deepEqual(yield* listSourceProfiles(helium, paths), [292 assert.deepEqual(yield* listSourceProfiles(helium, context), [
236 { directory: "Profile 1", name: "Profile 1" },293 { directory: "Profile 1", name: "Profile 1" },
237 ]);294 ]);
238 }),295 }),
...@@ -243,13 +300,13 @@ describe("listSourceProfiles", () => {...@@ -243,13 +300,13 @@ describe("listSourceProfiles", () => {
243 run(300 run(
244 Effect.gen(function* () {301 Effect.gen(function* () {
245 const fileSystem = yield* FileSystem.FileSystem;302 const fileSystem = yield* FileSystem.FileSystem;
246 const paths = yield* withSourceHome();303 const context = yield* withSourceHome();
247 yield* fileSystem.writeFileString(304 yield* fileSystem.writeFileString(
248 `${helium.userDataDirectory(paths)}/Local State`,305 `${userDataDirectory(context)}/Local State`,
249 `{"profile":{"info_cache":{"Default":{"name":"You"},"Profile 2":{"name":" "}}}}`,306 `{"profile":{"info_cache":{"Default":{"name":"You"},"Profile 2":{"name":" "}}}}`,
250 );307 );
251308
252 assert.deepEqual(yield* listSourceProfiles(helium, paths), [309 assert.deepEqual(yield* listSourceProfiles(helium, context), [
253 { directory: "Default", name: "You" },310 { directory: "Default", name: "You" },
254 // Blank display name falls back to the directory rather than311 // Blank display name falls back to the directory rather than
255 // rendering an empty row.312 // rendering an empty row.
...@@ -263,13 +320,13 @@ describe("listSourceProfiles", () => {...@@ -263,13 +320,13 @@ describe("listSourceProfiles", () => {
263 run(320 run(
264 Effect.gen(function* () {321 Effect.gen(function* () {
265 const fileSystem = yield* FileSystem.FileSystem;322 const fileSystem = yield* FileSystem.FileSystem;
266 const paths = yield* withSourceHome();323 const context = yield* withSourceHome();
267 const root = helium.userDataDirectory(paths);324 const root = userDataDirectory(context);
268 yield* fileSystem.writeFileString(`${root}/Local State`, "{not-json");325 yield* fileSystem.writeFileString(`${root}/Local State`, "{not-json");
269 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });326 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
270 yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db");327 yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db");
271328
272 assert.deepEqual(yield* listSourceProfiles(helium, paths), [329 assert.deepEqual(yield* listSourceProfiles(helium, context), [
273 { directory: "Default", name: "Default" },330 { directory: "Default", name: "Default" },
274 ]);331 ]);
275 }),332 }),
...@@ -279,8 +336,8 @@ describe("listSourceProfiles", () => {...@@ -279,8 +336,8 @@ describe("listSourceProfiles", () => {
279 it.effect("reports nothing when no directory holds a cookie database", () =>336 it.effect("reports nothing when no directory holds a cookie database", () =>
280 run(337 run(
281 Effect.gen(function* () {338 Effect.gen(function* () {
282 const paths = yield* withSourceHome();339 const context = yield* withSourceHome();
283 assert.deepEqual(yield* listSourceProfiles(helium, paths), []);340 assert.deepEqual(yield* listSourceProfiles(helium, context), []);
284 }),341 }),
285 ),342 ),
286 );343 );
...@@ -305,9 +362,9 @@ describe("cookieDatabaseCandidatePaths", () => {...@@ -305,9 +362,9 @@ describe("cookieDatabaseCandidatePaths", () => {
305 it.effect("prefers Network/Cookies and falls back to the legacy Cookies", () =>362 it.effect("prefers Network/Cookies and falls back to the legacy Cookies", () =>
306 run(363 run(
307 Effect.gen(function* () {364 Effect.gen(function* () {
308 const paths = yield* withSourceHome();365 const context = yield* withSourceHome();
309 const profile = `${paths.home}/Library/Application Support/net.imput.helium/Profile 1`;366 const profile = `${context.home}/Library/Application Support/net.imput.helium/Profile 1`;
310 assert.deepEqual(cookieDatabaseCandidatePaths(helium, paths, "Profile 1"), [367 assert.deepEqual(cookieDatabaseCandidatePaths(helium, context, "Profile 1"), [
311 `${profile}/Network/Cookies`,368 `${profile}/Network/Cookies`,
312 `${profile}/Cookies`,369 `${profile}/Cookies`,
313 ]);370 ]);
...@@ -319,8 +376,8 @@ describe("cookieDatabaseCandidatePaths", () => {...@@ -319,8 +376,8 @@ describe("cookieDatabaseCandidatePaths", () => {
319 run(376 run(
320 Effect.gen(function* () {377 Effect.gen(function* () {
321 const fileSystem = yield* FileSystem.FileSystem;378 const fileSystem = yield* FileSystem.FileSystem;
322 const paths = yield* withSourceHome();379 const context = yield* withSourceHome();
323 const root = helium.userDataDirectory(paths);380 const root = helium.userDataDirectory(context);
324 // Chromium 96+ keeps sessions in Network/; a root Cookies left behind381 // Chromium 96+ keeps sessions in Network/; a root Cookies left behind
325 // by the move is stale and must not be the one imported.382 // by the move is stale and must not be the one imported.
326 yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true });383 yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true });
...@@ -328,15 +385,312 @@ describe("cookieDatabaseCandidatePaths", () => {...@@ -328,15 +385,312 @@ describe("cookieDatabaseCandidatePaths", () => {
328 yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "stale");385 yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "stale");
329386
330 assert.equal(387 assert.equal(
331 yield* resolveCookieDatabase(helium, paths, "Default"),388 yield* resolveCookieDatabase(helium, context, "Default"),
332 `${root}/Default/Network/Cookies`,389 `${root}/Default/Network/Cookies`,
333 );390 );
334 // A fresh install with only the Network/ jar is installed, not hidden.391 // A fresh install with only the Network/ jar is installed, not hidden.
335 yield* fileSystem.remove(`${root}/Default/Cookies`);392 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();
337 }),669 }),
338 ),670 ),
339 );671 );
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 );
340});694});
341695
342describe("listSourceProfiles hardening", () => {696describe("listSourceProfiles hardening", () => {
...@@ -344,16 +698,16 @@ describe("listSourceProfiles hardening", () => {...@@ -344,16 +698,16 @@ describe("listSourceProfiles hardening", () => {
344 run(698 run(
345 Effect.gen(function* () {699 Effect.gen(function* () {
346 const fileSystem = yield* FileSystem.FileSystem;700 const fileSystem = yield* FileSystem.FileSystem;
347 const paths = yield* withSourceHome();701 const context = yield* withSourceHome();
348 // `Local State` is writable by anything running as the user, so a702 // `Local State` is writable by anything running as the user, so a
349 // crafted key must not reach `cookieDatabasePath` and read a database703 // crafted key must not reach `cookieDatabasePath` and read a database
350 // outside the browser's user-data directory.704 // outside the browser's user-data directory.
351 yield* fileSystem.writeFileString(705 yield* fileSystem.writeFileString(
352 `${helium.userDataDirectory(paths)}/Local State`,706 `${userDataDirectory(context)}/Local State`,
353 `{"profile":{"info_cache":{"Default":{"name":"You"},"../../../../secrets":{"name":"Escape"},"a/b":{"name":"Nested"},"..":{"name":"Parent"}}}}`,707 `{"profile":{"info_cache":{"Default":{"name":"You"},"../../../../secrets":{"name":"Escape"},"a/b":{"name":"Nested"},"..":{"name":"Parent"}}}}`,
354 );708 );
355709
356 const profiles = yield* listSourceProfiles(helium, paths);710 const profiles = yield* listSourceProfiles(helium, context);
357711
358 assert.deepEqual(712 assert.deepEqual(
359 profiles.map((profile) => profile.directory),713 profiles.map((profile) => profile.directory),
apps/desktop/src/preview/BrowserImport/Sources.ts+517-77
...@@ -1,56 +1,171 @@...@@ -1,56 +1,171 @@
1/**1/**
2 * Importable browser sources.2 * Importable browser sources.
3 *3 *
4 * Each entry pins its own on-disk and keychain coordinates rather than4 * Two engines are modelled. Chromium-family browsers keep cookies in an
5 * deriving them: Chromium forks do not agree on the convention. Helium, for5 * encrypted SQLite database whose key lives in an OS credential store; Firefox
6 * instance, uses the keychain service "Helium Storage Key" / account "Helium"6 * keeps them in plain SQLite with no key at all, so it needs no keychain and
7 * where Chrome and its closer relatives use "<Name> Safe Storage" / "<Name>".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.
8 *14 *
9 * @module BrowserImportSources15 * @module BrowserImportSources
10 */16 */
11import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";17import type { BrowserImportSourceId, BrowserImportSourceProfile } from "@t3tools/contracts";
12import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";18import * 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";
14import * as Effect from "effect/Effect";25import * as Effect from "effect/Effect";
15import * as FileSystem from "effect/FileSystem";26import * as FileSystem from "effect/FileSystem";
16import * as Path from "effect/Path";27import * as Path from "effect/Path";
28import * as PlatformError from "effect/PlatformError";
17import * as Schema from "effect/Schema";29import * as Schema from "effect/Schema";
30import * as Stream from "effect/Stream";
31import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
18import * as SqlClient from "effect/unstable/sql/SqlClient";32import * as SqlClient from "effect/unstable/sql/SqlClient";
1933
34export type BrowserImportEngine = "chromium" | "firefox";
35
20/**36/**
21 * Where a source's files live, resolved once per call rather than read from37 * Directory roots a definition builds its paths from. Passed in rather than
22 * the ambient process so the registry stays testable.38 * read from `process`, so source resolution stays testable for platforms the
39 * host is not currently running.
23 */40 */
24export interface SourcePaths {41export interface BrowserImportPathContext {
25 readonly path: Path.Path;42 readonly path: Path.Path;
43 readonly platform: NodeJS.Platform;
26 readonly home: string;44 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;
27}49}
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
35export interface BrowserImportSourceDefinition {51export interface BrowserImportSourceDefinition {
36 readonly id: BrowserImportSourceId;52 readonly id: BrowserImportSourceId;
37 readonly name: string;53 readonly name: string;
38 /** Platforms the definition's paths are valid for. */54 readonly engine: BrowserImportEngine;
55 /** Platforms the definition has paths for. */
39 readonly platforms: ReadonlyArray<NodeJS.Platform>;56 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;
41 readonly keychainService: string;77 readonly keychainService: string;
42 readonly keychainAccount: string;78 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
45export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [99export 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({
47 id: "helium",149 id: "helium",
48 name: "Helium",150 name: "Helium",
49 platforms: ["darwin"],
50 userDataDirectory: ({ path, home }) =>
51 path.join(home, "Library", "Application Support", "net.imput.helium"),
52 keychainService: "Helium Storage Key",151 keychainService: "Helium Storage Key",
53 keychainAccount: "Helium",152 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 },
54 },169 },
55];170];
56171
...@@ -60,28 +175,117 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition...@@ -60,28 +175,117 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition
60 * pre-96 install or a leftover from before the move. Importing the leftover175 * pre-96 install or a leftover from before the move. Importing the leftover
61 * while sessions live in `Network/` would snapshot a stale or empty database,176 * while sessions live in `Network/` would snapshot a stale or empty database,
62 * and a fresh install with only `Network/Cookies` would read as not installed.177 * 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.
63 */180 */
64export const cookieDatabaseCandidatePaths = (181export const cookieDatabaseCandidatePaths = (
65 definition: BrowserImportSourceDefinition,182 definition: BrowserImportSourceDefinition,
66 paths: SourcePaths,183 context: BrowserImportPathContext,
67 profileDirectory: string,184 profileDirectory: string,
68): ReadonlyArray<string> => {185): ReadonlyArray<string> => {
69 const profile = paths.path.join(definition.userDataDirectory(paths), profileDirectory);186 const root = definition.userDataDirectory(context);
70 return [paths.path.join(profile, "Network", "Cookies"), paths.path.join(profile, "Cookies")];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")];
71};193};
72194
73/** The first candidate that is a regular file, or undefined when none is. */195/** The first candidate that is a regular file, or undefined when none is. */
74export const resolveCookieDatabase = Effect.fnUntraced(function* (196export const resolveCookieDatabase = Effect.fnUntraced(function* (
75 definition: BrowserImportSourceDefinition,197 definition: BrowserImportSourceDefinition,
76 paths: SourcePaths,198 context: BrowserImportPathContext,
77 profileDirectory: string,199 profileDirectory: string,
78) {200) {
79 for (const candidate of cookieDatabaseCandidatePaths(definition, paths, profileDirectory)) {201 for (const candidate of cookieDatabaseCandidatePaths(definition, context, profileDirectory)) {
80 if (yield* databaseFileExists(candidate)) return candidate;202 if (yield* databaseFileExists(candidate)) return candidate;
81 }203 }
82 return undefined;204 return undefined;
83});205});
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
85/** Shape of the slice of Chromium's `Local State` that names its profiles. */289/** Shape of the slice of Chromium's `Local State` that names its profiles. */
86const LocalState = Schema.Struct({290const LocalState = Schema.Struct({
87 profile: Schema.optional(291 profile: Schema.optional(
...@@ -106,21 +310,25 @@ const CookieCountRow = Schema.Struct({ count: Schema.Number });...@@ -106,21 +310,25 @@ const CookieCountRow = Schema.Struct({ count: Schema.Number });
106const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow));310const decodeCookieCount = Schema.decodeUnknownEffect(Schema.Array(CookieCountRow));
107311
108/**312/**
109 * How many cookies a profile holds, counted without decrypting anything — a313 * How many importable cookies a profile holds, counted without decrypting
110 * bare `COUNT(*)` needs no key. Best effort: a locked, missing or non-Chromium314 * anything. Firefox containers use identities Electron cannot represent, so
111 * database (Firefox's table is named differently, Safari's is not SQL) yields315 * its count uses the same default-container predicate as the reader. Best
112 * `undefined` rather than failing the listing.316 * effort: a locked, missing or unexpected database yields `undefined` rather
317 * than failing the listing.
113 */318 */
114const countProfileCookies = Effect.fnUntraced(function* (319const countProfileCookies = Effect.fnUntraced(function* (
115 definition: BrowserImportSourceDefinition,320 definition: BrowserImportSourceDefinition,
116 paths: SourcePaths,321 context: BrowserImportPathContext,
117 directory: string,322 directory: string,
118): Effect.fn.Return<number | undefined, never, FileSystem.FileSystem> {323): Effect.fn.Return<number | undefined, never, FileSystem.FileSystem> {
119 const database = yield* resolveCookieDatabase(definition, paths, directory);324 const database = yield* resolveCookieDatabase(definition, context, directory);
120 if (database === undefined) return undefined;325 if (database === undefined) return undefined;
121 return yield* Effect.gen(function* () {326 return yield* Effect.gen(function* () {
122 const sql = yield* SqlClient.SqlClient;327 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`;
124 const [row] = yield* decodeCookieCount(rows);332 const [row] = yield* decodeCookieCount(rows);
125 return row?.count;333 return row?.count;
126 }).pipe(334 }).pipe(
...@@ -131,11 +339,11 @@ const countProfileCookies = Effect.fnUntraced(function* (...@@ -131,11 +339,11 @@ const countProfileCookies = Effect.fnUntraced(function* (
131339
132const withCookieCounts = (340const withCookieCounts = (
133 definition: BrowserImportSourceDefinition,341 definition: BrowserImportSourceDefinition,
134 paths: SourcePaths,342 context: BrowserImportPathContext,
135 profiles: ReadonlyArray<BrowserImportSourceProfile>,343 profiles: ReadonlyArray<BrowserImportSourceProfile>,
136) =>344) =>
137 Effect.forEach(profiles, (profile) =>345 Effect.forEach(profiles, (profile) =>
138 countProfileCookies(definition, paths, profile.directory).pipe(346 countProfileCookies(definition, context, profile.directory).pipe(
139 Effect.map((cookieCount) =>347 Effect.map((cookieCount) =>
140 cookieCount === undefined ? profile : { ...profile, cookieCount },348 cookieCount === undefined ? profile : { ...profile, cookieCount },
141 ),349 ),
...@@ -143,26 +351,54 @@ const withCookieCounts = (...@@ -143,26 +351,54 @@ const withCookieCounts = (
143 );351 );
144352
145/**353/**
146 * Profiles the source browser knows about, read from its `Local State`.354 * Profiles the source browser knows about.
147 *355 *
148 * When that file is missing, unreadable or malformed, the user-data directory356 * Firefox declares them in `profiles.ini`; Chromium in `Local State`. When
149 * is scanned for directories that hold a cookie database. Assuming `Default`357 * that metadata is missing, unreadable or malformed, the directories that
150 * instead would report a browser whose cookies live in `Profile 1` as having358 * 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
151 * nothing to import — and it is then left out of the menu entirely.360 * nothing to import — and it is then left out of the menu entirely.
152 */361 */
153export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProfiles")(function* (362const listSourceProfilesInDirectory = Effect.fnUntraced(function* (
154 definition: BrowserImportSourceDefinition,363 definition: BrowserImportSourceDefinition,
155 paths: SourcePaths,364 context: BrowserImportPathContext,
156) {365): Effect.fn.Return<ReadonlyArray<BrowserImportSourceProfile>, never, FileSystem.FileSystem> {
157 const fileSystem = yield* FileSystem.FileSystem;366 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);397 const declared = yield* fileSystem.readFileString(context.path.join(root, "Local State")).pipe(
161 const declared = yield* fileSystem.readFileString(localStatePath).pipe(
162 Effect.flatMap(decodeLocalState),398 Effect.flatMap(decodeLocalState),
163 Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})),399 Effect.map((state) => Object.entries(state.profile?.info_cache ?? {})),
164 // The keys are directory names from the browser's own metadata file, which400 // 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 plain401 // anything running as the user can write. Anything but a single plain
166 // segment is dropped: `..` or a path separator would otherwise be handed402 // segment is dropped: `..` or a path separator would otherwise be handed
167 // to `cookieDatabasePath` and read a database outside the user-data403 // to `cookieDatabasePath` and read a database outside the user-data
168 // directory.404 // directory.
...@@ -172,18 +408,15 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf...@@ -172,18 +408,15 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf
172 ),408 ),
173 Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),409 Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>),
174 );410 );
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 for413 // `Local State` is missing, unreadable or malformed. Scanning for directories
178 // directories that hold a cookie database finds the profiles anyway;414 // 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.
181 const entries = yield* fileSystem415 const entries = yield* fileSystem
182 .readDirectory(root)416 .readDirectory(root)
183 .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>));417 .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>));
184 const candidates = entries.filter(isSafeProfileDirectory);418 const found = yield* Effect.forEach(entries.filter(isSafeProfileDirectory), (directory) =>
185 const found = yield* Effect.forEach(candidates, (directory) =>419 resolveCookieDatabase(definition, context, directory).pipe(
186 resolveCookieDatabase(definition, paths, directory).pipe(
187 Effect.map((database) =>420 Effect.map((database) =>
188 database === undefined ? undefined : { directory, name: directory },421 database === undefined ? undefined : { directory, name: directory },
189 ),422 ),
...@@ -191,11 +424,46 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf...@@ -191,11 +424,46 @@ export const listSourceProfiles = Effect.fn("BrowserImportSources.listSourceProf
191 );424 );
192 return yield* withCookieCounts(425 return yield* withCookieCounts(
193 definition,426 definition,
194 paths,427 context,
195 found.filter((profile) => profile !== undefined),428 found.filter((profile) => profile !== undefined),
196 );429 );
197});430});
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
199/**467/**
200 * Whether a cookie database candidate is a regular file. Presence alone is468 * Whether a cookie database candidate is a regular file. Presence alone is
201 * not enough: a directory at the path would list as an importable profile and469 * not enough: a directory at the path would list as an importable profile and
...@@ -254,28 +522,200 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* (...@@ -254,28 +522,200 @@ export const chromiumSingletonLockIsHeld = Effect.fnUntraced(function* (
254 return yield* isProcessAlive(pid);522 return yield* isProcessAlive(pid);
255});523});
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
257/** Whether the browser is running, which leaves its cookie DB mid-write. */685/** Whether the browser is running, which leaves its cookie DB mid-write. */
258export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")(function* (686export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")(function* (
259 definition: BrowserImportSourceDefinition,687 definition: BrowserImportSourceDefinition,
260 paths: SourcePaths,688 context: BrowserImportPathContext,
261) {689): Effect.fn.Return<
690 boolean,
691 never,
692 FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
693> {
262 const fileSystem = yield* FileSystem.FileSystem;694 const fileSystem = yield* FileSystem.FileSystem;
263 const currentHost = yield* HostProcessHostname;695 const root = definition.userDataDirectory(context);
264 const lock = paths.path.join(definition.userDataDirectory(paths), "SingletonLock");696 if (root === undefined) return false;
265 // Chromium writes a `SingletonLock` symlink for as long as an instance holds697 // Both engines leave a lock file for as long as an instance holds a profile,
266 // the profile. Its presence is a far cheaper and more targeted signal than698 // which is far cheaper and more targeted than scanning the process table.
267 // scanning the process table for a name.699 if (definition.engine !== "firefox") {
268 //700 const currentHost = yield* HostProcessHostname;
269 // The link points at `<host>-<pid>`, a target that never exists, and both701 const lock = context.path.join(root, "SingletonLock");
270 // `stat` and `exists` follow links — so they report every running browser as702 return yield* fileSystem.readLink(lock).pipe(
271 // closed, which would let an import read a live, mid-write database.703 Effect.flatMap((target) => chromiumSingletonLockIsHeld(target, currentHost, processIsAlive)),
272 // `readLink` is the one probe that answers for the entry itself. Chromium704 Effect.catch((error) => Effect.succeed(error.reason._tag !== "NotFound")),
273 // can leave this link behind after a crash, so a positively dead local PID705 );
274 // is stale. Every ambiguous target or liveness result stays conservative.706 }
275 return yield* fileSystem.readLink(lock).pipe(707
276 Effect.flatMap((target) => chromiumSingletonLockIsHeld(target, currentHost, processIsAlive)),708 const profiles = yield* listSourceProfiles(definition, context);
277 Effect.catch((error) => Effect.succeed(error.reason._tag !== "NotFound")),709 // Only the Linux `lock` symlink names an address, so Windows skips the lookup.
278 );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);
279});719});
280720
281/**721/**
...@@ -295,11 +735,11 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")...@@ -295,11 +735,11 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning")
295 */735 */
296export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstalled")(function* (736export const isSourceInstalled = Effect.fn("BrowserImportSources.isSourceInstalled")(function* (
297 definition: BrowserImportSourceDefinition,737 definition: BrowserImportSourceDefinition,
298 paths: SourcePaths,738 context: BrowserImportPathContext,
299) {739): Effect.fn.Return<boolean, never, FileSystem.FileSystem> {
300 const profiles = yield* listSourceProfiles(definition, paths);740 const profiles = yield* listSourceProfiles(definition, context);
301 const found = yield* Effect.forEach(profiles, (profile) =>741 const found = yield* Effect.forEach(profiles, (profile) =>
302 resolveCookieDatabase(definition, paths, profile.directory).pipe(742 resolveCookieDatabase(definition, context, profile.directory).pipe(
303 Effect.map((database) => database !== undefined),743 Effect.map((database) => database !== undefined),
304 ),744 ),
305 );745 );
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";...@@ -16,7 +16,16 @@ import * as Schema from "effect/Schema";
16import { TrimmedNonEmptyString } from "./baseSchemas.ts";16import { TrimmedNonEmptyString } from "./baseSchemas.ts";
17import { BrowserProfileId } from "./browserProfile.ts";17import { 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
21export const BrowserImportSourceId = Schema.Literals(BROWSER_IMPORT_SOURCE_IDS);30export const BrowserImportSourceId = Schema.Literals(BROWSER_IMPORT_SOURCE_IDS);
22export type BrowserImportSourceId = typeof BrowserImportSourceId.Type;31export type BrowserImportSourceId = typeof BrowserImportSourceId.Type;
packages/shared/src/hostProcess.ts+30
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import * as Context from "effect/Context";1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as NodeDns from "node:dns";
3import * as NodeOS from "node:os";4import * as NodeOS from "node:os";
45
5export const HostProcessPlatform = Context.Reference<NodeJS.Platform>(6export const HostProcessPlatform = Context.Reference<NodeJS.Platform>(
...@@ -51,6 +52,35 @@ export const HostProcessArguments = Context.Reference<ReadonlyArray<string>>(...@@ -51,6 +52,35 @@ export const HostProcessArguments = Context.Reference<ReadonlyArray<string>>(
51 },52 },
52);53);
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
54/** Undefined on platforms without POSIX uids (Windows). */84/** Undefined on platforms without POSIX uids (Windows). */
55export const HostProcessUserId = Context.Reference<number | undefined>(85export const HostProcessUserId = Context.Reference<number | undefined>(
56 "@t3tools/shared/hostProcess/HostProcessUserId",86 "@t3tools/shared/hostProcess/HostProcessUserId",