// @effect-diagnostics nodeBuiltinImport:off - Builds a Firefox-shaped // `cookies.sqlite` fixture with the same native bindings Firefox itself uses. import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Scope from "effect/Scope"; import * as NodeSqlite from "node:sqlite"; import { readFirefoxCookies } from "./FirefoxCookies.ts"; import { parseFirefoxProfiles } from "./Sources.ts"; const parsePosixFirefoxProfiles = (ini: string, root = "/home/user/.mozilla/firefox") => Effect.gen(function* () { const path = yield* Path.Path; return parseFirefoxProfiles(ini, path, root); }).pipe(Effect.provide(NodePath.layerPosix)); const parseWindowsFirefoxProfiles = (ini: string, root = "C:\\Users\\user\\Firefox") => Effect.gen(function* () { const path = yield* Path.Path; return parseFirefoxProfiles(ini, path, root); }).pipe(Effect.provide(NodePath.layerWin32)); /** Builds a `cookies.sqlite` with Firefox's real `moz_cookies` shape. */ const writeFirefoxCookieDatabase = Effect.fnUntraced(function* ( rows: ReadonlyArray<{ host: string; name: string; value: string; path: string; expiry: number; isSecure: number; isHttpOnly: number; sameSite: number | null; rawSameSite?: number; originAttributes?: string; }>, // Firefox stamps `PRAGMA user_version`; schema 16+ stores `expiry` in // milliseconds, earlier ones in seconds. schemaVersion = 15, ) { const fileSystem = yield* FileSystem.FileSystem; const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-test-" }); const file = `${directory}/cookies.sqlite`; const database = new NodeSqlite.DatabaseSync(file); database.exec(`pragma user_version = ${schemaVersion}`); // Only schemas 10–14 have `rawSameSite`; the schema-15 migration dropped it. const hasRawSameSite = schemaVersion >= 10 && schemaVersion <= 14; database.exec( `create table moz_cookies ( id integer primary key, host text, name text, value text, path text, expiry integer, isSecure integer, isHttpOnly integer, sameSite integer, ${hasRawSameSite ? "rawSameSite integer," : ""} originAttributes text not null default '' )`, ); const insert = database.prepare( `insert into moz_cookies (host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, ${hasRawSameSite ? "rawSameSite," : ""} originAttributes) values (?, ?, ?, ?, ?, ?, ?, ?, ${hasRawSameSite ? "?," : ""} ?)`, ); for (const row of rows) { insert.run( row.host, row.name, row.value, row.path, row.expiry, row.isSecure, row.isHttpOnly, row.sameSite, ...(hasRawSameSite ? [row.rawSameSite ?? row.sameSite] : []), row.originAttributes ?? "", ); } database.close(); return file; }); const run = (effect: Effect.Effect) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped); describe("readFirefoxCookies", () => { it.effect("converts millisecond expiries from schema 16 and newer", () => run( Effect.gen(function* () { // Firefox 129 (schema 16) migrated `expiry` to milliseconds; older // profiles still hold seconds. Both must land as seconds for Electron. const row = { host: "example.test", name: "c", value: "v", path: "/", expiry: 1_800_000_000_000, isSecure: 0, isHttpOnly: 0, sameSite: 0, }; const modern = yield* readFirefoxCookies(yield* writeFirefoxCookieDatabase([row], 16)); expect(modern[0]?.expirationDate).toBe(1_800_000_000); const legacy = yield* readFirefoxCookies( yield* writeFirefoxCookieDatabase([{ ...row, expiry: 1_800_000_000 }], 15), ); expect(legacy[0]?.expirationDate).toBe(1_800_000_000); }), ), ); it.effect("maps moz_cookies onto the shape Electron accepts", () => run( Effect.gen(function* () { const file = yield* writeFirefoxCookieDatabase([ { host: ".github.com", name: "session", value: "abc", path: "/", expiry: 1_800_000_000, isSecure: 1, isHttpOnly: 1, sameSite: 1, }, { host: "example.test", name: "plain", value: "v", path: "/app", // Firefox writes 0 for a session cookie. expiry: 0, isSecure: 0, isHttpOnly: 0, sameSite: 0, }, ]); const cookies = yield* readFirefoxCookies(file); expect(cookies).toEqual([ { // The leading dot stays on the domain but not in the URL, which is // what Electron matches against. url: "https://github.com/", name: "session", value: "abc", domain: ".github.com", path: "/", secure: true, httpOnly: true, expirationDate: 1_800_000_000, sameSite: "lax", }, { url: "http://example.test/app", name: "plain", value: "v", // Host-only in Firefox, so no `domain`: supplying one would make // Electron widen it to every subdomain of example.test. domain: undefined, path: "/app", secure: false, httpOnly: false, // Session cookies carry no expiry rather than one at the epoch. expirationDate: undefined, sameSite: "no_restriction", }, ]); }), ), ); it.effect("keeps an unset SameSite unspecified instead of widening it to none", () => run( Effect.gen(function* () { // nsICookie::SAMESITE_UNSET is 256, a cookie that carried no SameSite // attribute. It is not SAMESITE_NONE (0), which is an explicit opt-in // to cross-site use; importing it as "none" would widen its scope. const row = { host: "example.test", name: "c", value: "v", path: "/", expiry: 0, isSecure: 0, isHttpOnly: 0, }; const cookies = yield* readFirefoxCookies( yield* writeFirefoxCookieDatabase([ { ...row, name: "unset", sameSite: 256 }, { ...row, name: "none", sameSite: 0 }, ]), ); expect(cookies.map(({ name, sameSite }) => ({ name, sameSite }))).toEqual([ { name: "unset", sameSite: "unspecified" }, { name: "none", sameSite: "no_restriction" }, ]); }), ), ); it.effect("imports rows whose SameSite was never written", () => run( Effect.gen(function* () { // Schema 9 added `sameSite` without a default, so rows from before the // upgrade hold NULL. One such row must not fail the whole import. const row = { host: "example.test", name: "c", value: "v", path: "/", expiry: 0, isSecure: 0, isHttpOnly: 0, }; const cookies = yield* readFirefoxCookies( yield* writeFirefoxCookieDatabase( [ { ...row, name: "legacy", sameSite: null }, { ...row, name: "strict", sameSite: 2 }, ], 9, ), ); expect(cookies.map(({ name, sameSite }) => ({ name, sameSite }))).toEqual([ { name: "legacy", sameSite: "unspecified" }, { name: "strict", sameSite: "strict" }, ]); }), ), ); it.effect("applies the schema-15 rawSameSite rule to older databases", () => run( Effect.gen(function* () { // Schemas 10–14 defaulted `sameSite` to Lax and kept the declared value // in `rawSameSite`. Firefox's own migration to 15 turns "Lax by // default, None declared" into Unset; an unmigrated database has to be // read the same way or an undeclared cookie becomes an explicit Lax. const row = { host: "example.test", name: "c", value: "v", path: "/", expiry: 0, isSecure: 0, isHttpOnly: 0, }; const cookies = yield* readFirefoxCookies( yield* writeFirefoxCookieDatabase( [ { ...row, name: "defaulted", sameSite: 1, rawSameSite: 0 }, { ...row, name: "declared", sameSite: 1, rawSameSite: 1 }, { ...row, name: "none", sameSite: 0, rawSameSite: 0 }, ], 14, ), ); expect(cookies.map(({ name, sameSite }) => ({ name, sameSite }))).toEqual([ { name: "defaulted", sameSite: "unspecified" }, { name: "declared", sameSite: "lax" }, { name: "none", sameSite: "no_restriction" }, ]); }), ), ); it.effect("imports only the default container", () => run( Effect.gen(function* () { const file = yield* writeFirefoxCookieDatabase([ { host: "mail.test", name: "session", value: "default-container", path: "/", expiry: 1_800_000_000, isSecure: 1, isHttpOnly: 0, sameSite: 1, }, { // Same host, name and path as above: Firefox keeps these apart by // container, Electron cannot, so importing both would hand the // profile whichever one happened to be written last. host: "mail.test", name: "session", value: "work-container", path: "/", expiry: 1_800_000_000, isSecure: 1, isHttpOnly: 0, sameSite: 1, originAttributes: "^userContextId=2", }, { host: "mail.test", name: "private", value: "private-window", path: "/", expiry: 1_800_000_000, isSecure: 1, isHttpOnly: 0, sameSite: 1, originAttributes: "^privateBrowsingId=1", }, ]); const cookies = yield* readFirefoxCookies(file); expect(cookies.map((cookie) => cookie.value)).toEqual(["default-container"]); }), ), ); it.effect("reads without mutating the source database", () => run( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const file = yield* writeFirefoxCookieDatabase([ { host: "a.test", name: "n", value: "v", path: "/", expiry: 1_800_000_000, isSecure: 1, isHttpOnly: 0, sameSite: 2, }, ]); const before = yield* fileSystem.stat(file); yield* readFirefoxCookies(file); // The browser's own file is snapshotted, never opened for writing. const after = yield* fileSystem.stat(file); expect(after.mtime).toEqual(before.mtime); expect(after.size).toBe(before.size); }), ), ); }); describe("parseFirefoxProfiles", () => { it.effect("reads named profiles and ignores Install sections", () => Effect.gen(function* () { // `Install*` sections name a default profile but do not describe one, so // counting them would invent a profile whose directory does not exist. const parsed = yield* parsePosixFirefoxProfiles( [ "[Install4F96D1932A9F858E]", "Default=Profiles/abcd1234.default-release", "Locked=1", "", "[Profile0]", "Name=default-release", "IsRelative=1", "Path=Profiles/abcd1234.default-release", "", "[Profile1]", "Name=Work", "IsRelative=0", "Path=/Volumes/External/firefox-work", "", "[General]", "StartWithLastProfile=1", ].join("\n"), ); expect(parsed).toEqual([ { directory: "Profiles/abcd1234.default-release", name: "default-release" }, { directory: "/Volumes/External/firefox-work", name: "Work" }, ]); }), ); it.effect("falls back to the path when a profile has no name", () => Effect.gen(function* () { expect( yield* parsePosixFirefoxProfiles(["[Profile0]", "Path=Profiles/x.default"].join("\n")), ).toEqual([{ directory: "Profiles/x.default", name: "Profiles/x.default" }]); }), ); for (const [platform, root] of [ ["Linux", "/home/user/.mozilla/firefox"], ["macOS", "/Users/user/Library/Application Support/Firefox"], ] as const) { it.effect(`validates relative and absolute ${platform} profile paths`, () => Effect.gen(function* () { const parsed = yield* parsePosixFirefoxProfiles( [ "[Profile0]", "Name=Relative", "IsRelative=1", "Path=Profiles/relative.default", "[Profile1]", "Name=Custom", "IsRelative=0", "Path=/mnt/custom/firefox-profile", "[Profile2]", "IsRelative=1", "Path=../../escape", "[Profile3]", "IsRelative=1", "Path=/absolute-marked-relative", "[Profile4]", "IsRelative=0", "Path=relative-marked-absolute", "[Profile5]", "IsRelative=1", "Path=Profiles/nul\u0000escape", ].join("\n"), root, ); expect(parsed).toEqual([ { directory: "Profiles/relative.default", name: "Relative" }, { directory: "/mnt/custom/firefox-profile", name: "Custom" }, ]); }), ); } it.effect("uses Windows path rules for relative and absolute profiles", () => Effect.gen(function* () { const parsed = yield* parseWindowsFirefoxProfiles( [ "[Profile0]", "Name=Relative", "IsRelative=1", "Path=Profiles\\relative.default", "[Profile1]", "Name=Custom", "IsRelative=0", "Path=D:\\Firefox Profiles\\Work", "[Profile2]", "IsRelative=1", "Path=..\\..\\escape", "[Profile3]", "IsRelative=1", "Path=D:\\absolute-marked-relative", "[Profile4]", "IsRelative=0", "Path=relative-marked-absolute", ].join("\n"), ); expect(parsed).toEqual([ { directory: "Profiles\\relative.default", name: "Relative" }, { directory: "D:\\Firefox Profiles\\Work", name: "Custom" }, ]); }), ); });