459 lines · 14.8 KB · TypeScript
RawDownload
// @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 = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem | Path.Path | Scope.Scope>) =>  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" },      ]);    }),  );});