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 it.effect.each([
390 { platform: "Linux", root: "/home/user/.mozilla/firefox" },
391 { platform: "macOS", root: "/Users/user/Library/Application Support/Firefox" },
392 ])("validates relative and absolute $platform profile paths", ({ root }) =>
393 Effect.gen(function* () {
394 const parsed = yield* parsePosixFirefoxProfiles(
395 [
396 "[Profile0]",
397 "Name=Relative",
398 "IsRelative=1",
399 "Path=Profiles/relative.default",
400 "[Profile1]",
401 "Name=Custom",
402 "IsRelative=0",
403 "Path=/mnt/custom/firefox-profile",
404 "[Profile2]",
405 "IsRelative=1",
406 "Path=../../escape",
407 "[Profile3]",
408 "IsRelative=1",
409 "Path=/absolute-marked-relative",
410 "[Profile4]",
411 "IsRelative=0",
412 "Path=relative-marked-absolute",
413 "[Profile5]",
414 "IsRelative=1",
415 "Path=Profiles/nul\u0000escape",
416 ].join("\n"),
417 root,
418 );
419
420 expect(parsed).toEqual([
421 { directory: "Profiles/relative.default", name: "Relative" },
422 { directory: "/mnt/custom/firefox-profile", name: "Custom" },
423 ]);
424 }),
425 );
426
427 it.effect("uses Windows path rules for relative and absolute profiles", () =>
428 Effect.gen(function* () {
429 const parsed = yield* parseWindowsFirefoxProfiles(
430 [
431 "[Profile0]",
432 "Name=Relative",
433 "IsRelative=1",
434 "Path=Profiles\\relative.default",
435 "[Profile1]",
436 "Name=Custom",
437 "IsRelative=0",
438 "Path=D:\\Firefox Profiles\\Work",
439 "[Profile2]",
440 "IsRelative=1",
441 "Path=..\\..\\escape",
442 "[Profile3]",
443 "IsRelative=1",
444 "Path=D:\\absolute-marked-relative",
445 "[Profile4]",
446 "IsRelative=0",
447 "Path=relative-marked-absolute",
448 ].join("\n"),
449 );
450
451 expect(parsed).toEqual([
452 { directory: "Profiles\\relative.default", name: "Relative" },
453 { directory: "D:\\Firefox Profiles\\Work", name: "Custom" },
454 ]);
455 }),
456 );
457});