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/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.TaggedError<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")(
118 function* (cookieDatabasePath: string) {
119 const snapshotPath = yield* snapshotCookieDatabase(cookieDatabasePath);
120
121 const { rows, schemaVersion } = yield* Effect.gen(function* () {
122 const sql = yield* SqlClient.SqlClient;
123 const [versionRow] = yield* decodeUserVersion(yield* sql`pragma user_version`);
124 const schemaVersion = versionRow?.user_version ?? 0;
125 const hasRawSameSite =
126 schemaVersion >= FIREFOX_RAW_SAMESITE_FIRST_SCHEMA &&
127 schemaVersion <= FIREFOX_RAW_SAMESITE_LAST_SCHEMA;
128 // Only the default container. Firefox isolates cookies per container and
129 // per private window via `originAttributes` (`^userContextId=2`,
130 // `^privateBrowsingId=1`); Electron has no equivalent, so importing them
131 // all would collapse several identities onto one host/name/path and hand
132 // the profile an arbitrary container's session.
133 const raw = hasRawSameSite
134 ? yield* sql`
135 select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, rawSameSite
136 from moz_cookies
137 where originAttributes = ''
138 `
139 : yield* sql`
140 select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite,
141 null as rawSameSite
142 from moz_cookies
143 where originAttributes = ''
144 `;
145 return { rows: yield* decodeCookieRows(raw), schemaVersion };
146 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })));
147
148 return rows.map((row) => {
149 const secure = row.isSecure === 1;
150 const scope = cookieScope(row.host, row.path, secure);
151 return {
152 url: scope.url,
153 name: row.name,
154 value: row.value,
155 domain: scope.domain,
156 path: row.path,
157 secure,
158 httpOnly: row.isHttpOnly === 1,
159 expirationDate: expiryToSeconds(row.expiry, schemaVersion),
160 sameSite: sameSiteFromColumn(row.sameSite, row.rawSameSite),
161 } satisfies ImportedCookie;
162 });
163 },
164 (effect, cookieDatabasePath) =>
165 effect.pipe(
166 Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })),
167 ),
168);