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