Open History to see the last commit
1// @effect-diagnostics nodeBuiltinImport:off - Builds a Chromium-shaped cookie
2// table with the same native bindings the source reads.
3import * as NodePath from "node:path";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { assert, describe, it } from "@effect/vitest";
6import * as HostProcess from "@t3tools/shared/HostProcess";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Path from "effect/Path";
10import * as Scope from "effect/Scope";
11import { ChildProcessSpawner } from "effect/process";
12import * as NodeSqlite from "node:sqlite";
13
14import type { BrowserImportPathContext } from "./Sources.ts";
15import {
16 BROWSER_IMPORT_SOURCES,
17 chromiumProcessIsAlive,
18 chromiumSingletonLockIsHeld,
19 firefoxSymlinkLockIsHeld,
20 resolveCookieDatabase,
21 isSourceInstalled,
22 isSourceRunning,
23 listSourceProfiles,
24 sourcePathContext,
25 windowsChromiumCookiesAreHeld,
26} from "./Sources.ts";
27import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
28
29const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;
30
31const withSourceHome = Effect.fnUntraced(function* () {
32 const fileSystem = yield* FileSystem.FileSystem;
33 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-sources-" });
34 const context = yield* sourcePathContext.pipe(
35 Effect.provideService(HostProcess.Environment, { HOME: home }),
36 Effect.provideService(HostProcess.Platform, "darwin"),
37 );
38 yield* fileSystem.makeDirectory(userDataDirectory(context), { recursive: true });
39 return context;
40});
41
42const userDataDirectory = (context: BrowserImportPathContext) => {
43 const root = helium.userDataDirectory(context);
44 if (root === undefined) throw new Error("Helium has no macOS user-data directory");
45 return root;
46};
47
48const run = <A, E>(
49 effect: Effect.Effect<
50 A,
51 E,
52 FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner
53 >,
54) => effect.pipe(Effect.provide(NodeServices.layer));
55
56const writeCookieDatabase = (file: string, count: number) =>
57 Effect.sync(() => {
58 const database = new NodeSqlite.DatabaseSync(file);
59 database.exec("create table cookies (host_key text, name text)");
60 const insert = database.prepare("insert into cookies (host_key, name) values (?, ?)");
61 for (let index = 0; index < count; index += 1) insert.run("example.test", `c${index}`);
62 database.close();
63 });
64
65const writeFirefoxCookieDatabase = (
66 file: string,
67 defaultContainerCount: number,
68 containerCount: number,
69) =>
70 Effect.sync(() => {
71 const database = new NodeSqlite.DatabaseSync(file);
72 database.exec("create table moz_cookies (originAttributes text not null)");
73 const insert = database.prepare("insert into moz_cookies (originAttributes) values (?)");
74 for (let index = 0; index < defaultContainerCount; index += 1) insert.run("");
75 for (let index = 0; index < containerCount; index += 1) insert.run("^userContextId=2");
76 database.close();
77 });
78
79describe("isSourceRunning", () => {
80 it.effect("uses the held cookie database as Chromium's Windows running signal", () =>
81 run(
82 Effect.gen(function* () {
83 const fileSystem = yield* FileSystem.FileSystem;
84 const home = yield* fileSystem.makeTempDirectoryScoped({
85 prefix: "t3code-helium-windows-lock-",
86 });
87 const context = yield* sourcePathContext.pipe(
88 Effect.provideService(HostProcess.Environment, {
89 HOME: home,
90 LOCALAPPDATA: home,
91 }),
92 Effect.provideService(HostProcess.Platform, "win32"),
93 );
94 const profile = context.path.join(helium.userDataDirectory(context)!, "Default");
95 const database = context.path.join(profile, "Network", "Cookies");
96 yield* fileSystem.makeDirectory(context.path.join(profile, "Network"), { recursive: true });
97 yield* writeCookieDatabase(database, 1);
98
99 const probed: string[] = [];
100 assert.isTrue(
101 yield* windowsChromiumCookiesAreHeld(helium, context, (path) =>
102 Effect.sync(() => {
103 probed.push(path);
104 return true;
105 }),
106 ),
107 );
108 assert.deepEqual(probed, [database]);
109 }),
110 ),
111 );
112
113 it.effect.skipIf(!symlinksSupported)(
114 "reads Chromium's dangling SingletonLock symlink as a running browser",
115 () =>
116 run(
117 Effect.gen(function* () {
118 const fileSystem = yield* FileSystem.FileSystem;
119 const context = yield* withSourceHome();
120 assert.isFalse(yield* isSourceRunning(helium, context));
121
122 yield* fileSystem.symlink(
123 "host-that-does-not-exist-1234",
124 `${userDataDirectory(context)}/SingletonLock`,
125 );
126
127 assert.isTrue(yield* isSourceRunning(helium, context));
128 }),
129 ),
130 );
131});
132
133describe("chromiumSingletonLockIsHeld", () => {
134 it.effect("ignores a positively dead PID on the current host", () =>
135 Effect.gen(function* () {
136 const checked: number[] = [];
137 const held = yield* chromiumSingletonLockIsHeld("current-host-4321", "current-host", (pid) =>
138 Effect.sync(() => {
139 checked.push(pid);
140 return false;
141 }),
142 );
143 assert.isFalse(held);
144 assert.deepEqual(checked, [4321]);
145 }),
146 );
147
148 it.effect("keeps foreign-host and malformed targets without probing a PID", () =>
149 Effect.gen(function* () {
150 let probes = 0;
151 const probe = (_pid: number) =>
152 Effect.sync(() => {
153 probes += 1;
154 return false;
155 });
156 assert.isTrue(yield* chromiumSingletonLockIsHeld("another-host-4321", "current-host", probe));
157 assert.isTrue(
158 yield* chromiumSingletonLockIsHeld("current-host-no-pid", "current-host", probe),
159 );
160 assert.isTrue(yield* chromiumSingletonLockIsHeld("current-host-0", "current-host", probe));
161 assert.strictEqual(probes, 0);
162 }),
163 );
164});
165
166describe("chromiumProcessIsAlive", () => {
167 it.effect("returns false only when signal 0 reports a missing process", () =>
168 Effect.gen(function* () {
169 const missing = Object.assign(new Error("missing"), { code: "ESRCH" });
170 const denied = Object.assign(new Error("denied"), { code: "EPERM" });
171 assert.isFalse(
172 yield* chromiumProcessIsAlive(4321, () => {
173 throw missing;
174 }),
175 );
176 assert.isTrue(
177 yield* chromiumProcessIsAlive(4321, () => {
178 throw denied;
179 }),
180 );
181 assert.isTrue(
182 yield* chromiumProcessIsAlive(4321, () => {
183 throw undefined;
184 }),
185 );
186 assert.isTrue(
187 yield* chromiumProcessIsAlive(4321, () => {
188 throw "unknown failure";
189 }),
190 );
191 assert.isTrue(
192 yield* chromiumProcessIsAlive(4321, () => {
193 throw null;
194 }),
195 );
196 assert.isTrue(yield* chromiumProcessIsAlive(4321, () => true));
197 }),
198 );
199});
200
201describe("isSourceInstalled", () => {
202 it.effect.each([undefined, "", "relative-config", "relocated", "chrome-override"])(
203 "discovers Linux Chromium profiles with XDG_CONFIG_HOME=%s",
204 (configHome) =>
205 run(
206 Effect.gen(function* () {
207 const fileSystem = yield* FileSystem.FileSystem;
208 const path = yield* Path.Path;
209 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-sources-xdg-" });
210 const xdgConfigHome =
211 configHome === "relocated" || configHome === "chrome-override"
212 ? path.join(home, "config")
213 : configHome;
214 const chromeConfigHome =
215 configHome === "chrome-override" ? path.join(home, ".config") : undefined;
216 const context = yield* sourcePathContext.pipe(
217 Effect.provideService(HostProcess.Environment, {
218 HOME: home,
219 XDG_CONFIG_HOME: xdgConfigHome,
220 CHROME_CONFIG_HOME: chromeConfigHome,
221 }),
222 Effect.provideService(HostProcess.Platform, "linux"),
223 );
224 const configRoot =
225 chromeConfigHome ??
226 (configHome === "relocated" ? path.join(home, "config") : path.join(home, ".config"));
227 const source = BROWSER_IMPORT_SOURCES.find((source) => source.id === "chrome")!;
228 const root = path.join(configRoot, "google-chrome");
229 const database = path.join(root, "Default", "Network", "Cookies");
230 assert.isFalse(yield* isSourceInstalled(source, context));
231 yield* fileSystem.makeDirectory(path.dirname(database), { recursive: true });
232 yield* writeCookieDatabase(database, 2);
233 yield* fileSystem.writeFileString(
234 path.join(root, "Local State"),
235 '{"profile":{"info_cache":{"Default":{"name":"Fixture"}}}}',
236 );
237
238 assert.isTrue(yield* isSourceInstalled(source, context));
239 assert.deepEqual(yield* listSourceProfiles(source, context), [
240 { directory: "Default", name: "Fixture", cookieCount: 2 },
241 ]);
242 assert.equal(yield* resolveCookieDatabase(source, context, "Default"), database);
243 }),
244 ),
245 );
246
247 it.effect("ignores a user-data directory that holds no cookie database", () =>
248 run(
249 Effect.gen(function* () {
250 const fileSystem = yield* FileSystem.FileSystem;
251 const context = yield* withSourceHome();
252 const root = userDataDirectory(context);
253
254 yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true });
255 assert.isFalse(yield* isSourceInstalled(helium, context));
256
257 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
258 yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db");
259 assert.isTrue(yield* isSourceInstalled(helium, context));
260
261 yield* fileSystem.remove(`${root}/Default`, { recursive: true });
262 yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true });
263 yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db");
264 assert.isTrue(yield* isSourceInstalled(helium, context));
265
266 yield* fileSystem.remove(root, { recursive: true });
267 assert.isFalse(yield* isSourceInstalled(helium, context));
268 }),
269 ),
270 );
271});
272
273describe("listSourceProfiles", () => {
274 it.effect("reads the profile names the browser shows", () =>
275 run(
276 Effect.gen(function* () {
277 const fileSystem = yield* FileSystem.FileSystem;
278 const context = yield* withSourceHome();
279 yield* fileSystem.writeFileString(
280 `${userDataDirectory(context)}/Local State`,
281 `{"profile":{"info_cache":{"Default":{"name":"You"},"Profile 2":{"name":" "}}}}`,
282 );
283
284 assert.deepEqual(yield* listSourceProfiles(helium, context), [
285 { directory: "Default", name: "You" },
286 { directory: "Profile 2", name: "Profile 2" },
287 ]);
288 }),
289 ),
290 );
291
292 it.effect("scans for profiles when Local State is malformed", () =>
293 run(
294 Effect.gen(function* () {
295 const fileSystem = yield* FileSystem.FileSystem;
296 const context = yield* withSourceHome();
297 const root = userDataDirectory(context);
298 yield* fileSystem.writeFileString(`${root}/Local State`, "{not-json");
299 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
300 yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db");
301
302 assert.deepEqual(yield* listSourceProfiles(helium, context), [
303 { directory: "Default", name: "Default" },
304 ]);
305 }),
306 ),
307 );
308
309 it.effect("discovers profiles with cookies under Network/ (Chromium 127+)", () =>
310 run(
311 Effect.gen(function* () {
312 const fileSystem = yield* FileSystem.FileSystem;
313 const context = yield* withSourceHome();
314 const root = userDataDirectory(context);
315 yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true });
316 yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "db");
317
318 assert.deepEqual(yield* listSourceProfiles(helium, context), [
319 { directory: "Default", name: "Default" },
320 ]);
321 }),
322 ),
323 );
324
325 it.effect("falls through to the legacy database when Network/Cookies is a directory", () =>
326 run(
327 Effect.gen(function* () {
328 const fileSystem = yield* FileSystem.FileSystem;
329 const paths = yield* withSourceHome();
330 const root = helium.userDataDirectory(paths);
331 yield* fileSystem.makeDirectory(`${root}/Default/Network/Cookies`, { recursive: true });
332 yield* writeCookieDatabase(`${root}/Default/Cookies`, 2);
333
334 const [profile] = yield* listSourceProfiles(helium, paths);
335 assert.equal(profile?.directory, "Default");
336 assert.equal(profile?.cookieCount, 2);
337 }),
338 ),
339 );
340});
341
342describe("cookieDatabaseCandidatePaths", () => {
343 it.effect("resolves the live Network/ jar over a leftover root Cookies", () =>
344 run(
345 Effect.gen(function* () {
346 const fileSystem = yield* FileSystem.FileSystem;
347 const context = yield* withSourceHome();
348 const root = userDataDirectory(context);
349 yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true });
350 yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "live");
351 yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "stale");
352
353 assert.equal(
354 yield* resolveCookieDatabase(helium, context, "Default"),
355 context.path.join(root, "Default", "Network", "Cookies"),
356 );
357 yield* fileSystem.remove(`${root}/Default/Cookies`);
358 assert.isTrue(yield* isSourceInstalled(helium, context));
359 }),
360 ),
361 );
362});
363
364const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!;
365
366const zen = BROWSER_IMPORT_SOURCES.find((source) => source.id === "zen")!;
367
368describe("Zen", () => {
369 it.effect("resolves Zen's native user-data roots on every supported platform", () =>
370 run(
371 Effect.gen(function* () {
372 const darwinContext = yield* sourcePathContext.pipe(
373 Effect.provideService(HostProcess.Environment, {
374 HOME: "/Users/zen-user",
375 XDG_CONFIG_HOME: "/custom-config",
376 }),
377 Effect.provideService(HostProcess.Platform, "darwin"),
378 );
379 const windowsContext = yield* sourcePathContext.pipe(
380 Effect.provideService(HostProcess.Environment, {
381 HOME: "C:\\Users\\zen-user",
382 APPDATA: "C:\\Users\\zen-user\\AppData\\Roaming",
383 LOCALAPPDATA: "C:\\Users\\zen-user\\AppData\\Local",
384 XDG_CONFIG_HOME: "/custom-config",
385 }),
386 Effect.provideService(HostProcess.Platform, "win32"),
387 );
388 const linuxContext = yield* sourcePathContext.pipe(
389 Effect.provideService(HostProcess.Environment, { HOME: "/home/zen-user" }),
390 Effect.provideService(HostProcess.Platform, "linux"),
391 );
392
393 assert.equal(zen.name, "Zen");
394 assert.equal(zen.engine, "firefox");
395 assert.deepEqual([...zen.platforms], ["darwin", "win32", "linux"]);
396 assert.equal(
397 zen.userDataDirectory(darwinContext),
398 darwinContext.path.join("/Users/zen-user", "Library", "Application Support", "zen"),
399 );
400 assert.equal(
401 zen.userDataDirectory(windowsContext),
402 windowsContext.path.join("C:\\Users\\zen-user\\AppData\\Roaming", "zen"),
403 );
404 assert.equal(
405 zen.userDataDirectory(linuxContext),
406 linuxContext.path.join("/home/zen-user", ".config", "zen"),
407 );
408 assert.deepEqual(zen.alternateLinuxRoots?.(linuxContext), [
409 linuxContext.path.join("/home/zen-user", ".zen"),
410 linuxContext.path.join("/home/zen-user", ".var", "app", "app.zen_browser.zen", "zen"),
411 ]);
412 assert.equal(
413 helium.userDataDirectory(darwinContext),
414 darwinContext.path.join(
415 "/Users/zen-user",
416 "Library",
417 "Application Support",
418 "net.imput.helium",
419 ),
420 );
421 assert.equal(
422 helium.userDataDirectory(windowsContext),
423 windowsContext.path.join(
424 "C:\\Users\\zen-user\\AppData\\Local",
425 "imput",
426 "Helium",
427 "User Data",
428 ),
429 );
430 }),
431 ),
432 );
433
434 it.effect("discovers Zen profiles through its Firefox-compatible metadata", () =>
435 run(
436 Effect.gen(function* () {
437 const fileSystem = yield* FileSystem.FileSystem;
438 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-" });
439 const context = yield* sourcePathContext.pipe(
440 Effect.provideService(HostProcess.Environment, { HOME: home }),
441 Effect.provideService(HostProcess.Platform, "darwin"),
442 );
443 const root = zen.userDataDirectory(context)!;
444 yield* fileSystem.makeDirectory(root, { recursive: true });
445 const profile = context.path.join(root, "Profiles", "zen.default");
446 yield* fileSystem.makeDirectory(profile, { recursive: true });
447 yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 2, 1);
448 yield* fileSystem.makeDirectory(context.path.join(root, "Profiles", "empty.default"), {
449 recursive: true,
450 });
451 yield* fileSystem.writeFileString(
452 context.path.join(root, "profiles.ini"),
453 [
454 "[Profile0]",
455 "Name=Zen",
456 "IsRelative=1",
457 "Path=Profiles/zen.default",
458 "ZenAvatarPath=chrome://browser/content/zen-avatars/avatar-55.svg",
459 "Default=1",
460 "",
461 "[Profile1]",
462 "Name=Empty",
463 "IsRelative=1",
464 "Path=Profiles/empty.default",
465 "",
466 ].join("\n"),
467 );
468
469 assert.deepEqual(yield* listSourceProfiles(zen, context), [
470 {
471 directory: context.path.join("Profiles", "zen.default"),
472 name: "Zen",
473 cookieCount: 2,
474 },
475 ]);
476 assert.isTrue(yield* isSourceInstalled(zen, context));
477 assert.isFalse(yield* isSourceRunning(zen, context));
478 assert.equal(
479 yield* resolveCookieDatabase(zen, context, context.path.join("Profiles", "zen.default")),
480 context.path.join(profile, "cookies.sqlite"),
481 );
482 }),
483 ),
484 );
485
486 it.effect("discovers Zen profiles in relocated and legacy Linux roots", () =>
487 run(
488 Effect.gen(function* () {
489 const fileSystem = yield* FileSystem.FileSystem;
490 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-linux-" });
491 const context = yield* sourcePathContext.pipe(
492 Effect.provideService(HostProcess.Environment, {
493 HOME: home,
494 XDG_CONFIG_HOME: NodePath.join(home, "config"),
495 }),
496 Effect.provideService(HostProcess.Platform, "linux"),
497 );
498 const currentRoot = context.path.join(home, "config", "zen");
499 const legacyRoot = context.path.join(home, ".zen");
500 const currentProfile = context.path.join(currentRoot, "current.default");
501 const legacyProfile = context.path.join(legacyRoot, "legacy.default");
502
503 for (const [root, profile, name] of [
504 [currentRoot, currentProfile, "Current"],
505 [legacyRoot, legacyProfile, "Legacy"],
506 ] as const) {
507 yield* fileSystem.makeDirectory(profile, { recursive: true });
508 yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 1, 0);
509 yield* fileSystem.writeFileString(
510 context.path.join(root, "profiles.ini"),
511 [
512 "[Profile0]",
513 `Name=${name}`,
514 "IsRelative=1",
515 `Path=${context.path.basename(profile)}`,
516 ].join("\n"),
517 );
518 }
519
520 assert.deepEqual(yield* listSourceProfiles(zen, context), [
521 { directory: "current.default", name: "Current", cookieCount: 1 },
522 { directory: legacyProfile, name: "Legacy", cookieCount: 1 },
523 ]);
524 assert.isTrue(yield* isSourceInstalled(zen, context));
525 assert.equal(
526 yield* resolveCookieDatabase(zen, context, legacyProfile),
527 context.path.join(legacyProfile, "cookies.sqlite"),
528 );
529
530 yield* fileSystem.writeFileString(
531 context.path.join(currentRoot, "profiles.ini"),
532 `[Profile0]\nName=Shared\nIsRelative=0\nPath=${legacyProfile}\n`,
533 );
534 assert.deepEqual(yield* listSourceProfiles(zen, context), [
535 { directory: legacyProfile, name: "Shared", cookieCount: 1 },
536 ]);
537
538 yield* fileSystem.remove(currentRoot, { recursive: true });
539 assert.isTrue(yield* isSourceInstalled(zen, context));
540 assert.deepEqual(yield* listSourceProfiles(zen, context), [
541 { directory: legacyProfile, name: "Legacy", cookieCount: 1 },
542 ]);
543 }),
544 ),
545 );
546
547 it.effect("discovers a Flatpak-only Zen installation and resolves its cookies", () =>
548 run(
549 Effect.gen(function* () {
550 const fileSystem = yield* FileSystem.FileSystem;
551 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-flatpak-" });
552 const context = yield* sourcePathContext.pipe(
553 Effect.provideService(HostProcess.Environment, { HOME: home }),
554 Effect.provideService(HostProcess.Platform, "linux"),
555 );
556 const root = context.path.join(home, ".var", "app", "app.zen_browser.zen", "zen");
557 const profile = context.path.join(root, "flatpak.default");
558 yield* fileSystem.makeDirectory(profile, { recursive: true });
559 yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 2, 1);
560 yield* fileSystem.writeFileString(
561 context.path.join(root, "profiles.ini"),
562 "[Profile0]\nName=Flatpak\nIsRelative=1\nPath=flatpak.default\n",
563 );
564
565 assert.deepEqual(yield* listSourceProfiles(zen, context), [
566 { directory: profile, name: "Flatpak", cookieCount: 2 },
567 ]);
568 assert.isTrue(yield* isSourceInstalled(zen, context));
569 assert.equal(
570 yield* resolveCookieDatabase(zen, context, profile),
571 context.path.join(profile, "cookies.sqlite"),
572 );
573 }),
574 ),
575 );
576
577 it.effect("does not mistake Firefox Snap installs for Zen profiles", () =>
578 run(
579 Effect.gen(function* () {
580 const fileSystem = yield* FileSystem.FileSystem;
581 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-zen-snap-" });
582 const context = yield* sourcePathContext.pipe(
583 Effect.provideService(HostProcess.Environment, { HOME: home }),
584 Effect.provideService(HostProcess.Platform, "linux"),
585 );
586 const zenRoot = context.path.join(home, ".config", "zen");
587 const snapRoot = context.path.join(
588 home,
589 "snap",
590 "firefox",
591 "common",
592 ".mozilla",
593 "firefox",
594 );
595 const zenProfile = context.path.join(zenRoot, "zen.default");
596 const snapProfile = context.path.join(snapRoot, "snap.default");
597 for (const root of [zenRoot, snapRoot]) {
598 const profile = root === zenRoot ? zenProfile : snapProfile;
599 yield* fileSystem.makeDirectory(profile, { recursive: true });
600 yield* writeFirefoxCookieDatabase(context.path.join(profile, "cookies.sqlite"), 1, 0);
601 yield* fileSystem.writeFileString(
602 context.path.join(root, "profiles.ini"),
603 [
604 "[Profile0]",
605 "Name=Personal",
606 "IsRelative=1",
607 `Path=${profile === zenProfile ? "zen.default" : "snap.default"}`,
608 ].join("\n"),
609 );
610 }
611
612 const profiles = yield* listSourceProfiles(zen, context);
613 assert.deepEqual(
614 profiles.map((profile) => profile.directory),
615 ["zen.default"],
616 );
617 assert.isTrue(yield* isSourceInstalled(zen, context));
618 }),
619 ),
620 );
621});
622
623describe("Firefox Snap profiles", () => {
624 it.effect.skipIf(!symlinksSupported)(
625 "finds Snap profiles with or without profiles.ini and checks their locks",
626 () =>
627 run(
628 Effect.gen(function* () {
629 const fileSystem = yield* FileSystem.FileSystem;
630 const home = yield* fileSystem.makeTempDirectoryScoped({
631 prefix: "t3code-firefox-snap-",
632 });
633 const context = yield* sourcePathContext.pipe(
634 Effect.provideService(HostProcess.Environment, { HOME: home }),
635 Effect.provideService(HostProcess.Platform, "linux"),
636 );
637 const root = context.path.join(home, "snap", "firefox", "common", ".mozilla", "firefox");
638 const directory = context.path.join(root, "abcd.default");
639 yield* fileSystem.makeDirectory(directory, { recursive: true });
640 yield* writeFirefoxCookieDatabase(`${directory}/cookies.sqlite`, 2, 1);
641 yield* fileSystem.writeFileString(
642 `${root}/profiles.ini`,
643 "[Profile0]\nName=Personal\nIsRelative=1\nPath=abcd.default\n",
644 );
645
646 assert.isTrue(yield* isSourceInstalled(firefox, context));
647 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
648 { directory, name: "Personal", cookieCount: 2 },
649 ]);
650 assert.equal(
651 yield* resolveCookieDatabase(firefox, context, directory),
652 context.path.join(directory, "cookies.sqlite"),
653 );
654 assert.isFalse(yield* isSourceRunning(firefox, context));
655 yield* fileSystem.symlink("foreign-host:+4242", `${directory}/lock`);
656 assert.isTrue(yield* isSourceRunning(firefox, context));
657 yield* fileSystem.remove(`${directory}/lock`);
658 assert.isFalse(yield* isSourceRunning(firefox, context));
659
660 yield* fileSystem.remove(`${root}/profiles.ini`);
661 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
662 { directory, name: "abcd.default", cookieCount: 2 },
663 ]);
664 }),
665 ),
666 );
667
668 it.effect("keeps matching profile names in native and Snap installs distinct", () =>
669 run(
670 Effect.gen(function* () {
671 const fileSystem = yield* FileSystem.FileSystem;
672 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-snap-" });
673 const context = yield* sourcePathContext.pipe(
674 Effect.provideService(HostProcess.Environment, { HOME: home }),
675 Effect.provideService(HostProcess.Platform, "linux"),
676 );
677 const native = context.path.join(home, ".mozilla", "firefox");
678 const snap = context.path.join(home, "snap", "firefox", "common", ".mozilla", "firefox");
679 for (const root of [native, snap]) {
680 yield* fileSystem.makeDirectory(`${root}/abcd.default`, { recursive: true });
681 yield* writeFirefoxCookieDatabase(`${root}/abcd.default/cookies.sqlite`, 1, 0);
682 yield* fileSystem.writeFileString(
683 `${root}/profiles.ini`,
684 "[Profile0]\nName=Personal\nIsRelative=1\nPath=abcd.default\n" +
685 `[Profile1]\nName=Shared\nIsRelative=0\nPath=${snap}/abcd.default\n`,
686 );
687 }
688
689 const profiles = yield* listSourceProfiles(firefox, context);
690 assert.deepEqual(
691 profiles.map((profile) => profile.directory),
692 ["abcd.default", context.path.join(snap, "abcd.default")],
693 );
694 const databases = yield* Effect.forEach(profiles, (profile) =>
695 resolveCookieDatabase(firefox, context, profile.directory),
696 );
697 assert.deepEqual(databases, [
698 context.path.join(native, "abcd.default", "cookies.sqlite"),
699 context.path.join(snap, "abcd.default", "cookies.sqlite"),
700 ]);
701 }),
702 ),
703 );
704});
705
706describe("listSourceProfiles Firefox fallback", () => {
707 const cases = [
708 { platform: "linux" as const, profileDirectory: "linux.default" },
709 { platform: "darwin" as const, profileDirectory: NodePath.join("Profiles", "macos.default") },
710 { platform: "win32" as const, profileDirectory: NodePath.join("Profiles", "windows.default") },
711 ];
712
713 it.effect.each(cases)(
714 "scans the $platform profile location and excludes stale entries",
715 ({ platform, profileDirectory }) =>
716 run(
717 Effect.gen(function* () {
718 const fileSystem = yield* FileSystem.FileSystem;
719 const path = yield* Path.Path;
720 const home = yield* fileSystem.makeTempDirectoryScoped({
721 prefix: `t3code-firefox-${platform}-`,
722 });
723 const appData = path.join(home, "AppData", "Roaming");
724 const context = yield* sourcePathContext.pipe(
725 Effect.provideService(HostProcess.Environment, {
726 HOME: home,
727 APPDATA: appData,
728 }),
729 Effect.provideService(HostProcess.Platform, platform),
730 );
731 const root = firefox.userDataDirectory(context)!;
732 const scanRoot = platform === "linux" ? root : path.join(root, "Profiles");
733 yield* fileSystem.makeDirectory(path.join(root, profileDirectory), { recursive: true });
734 yield* fileSystem.writeFileString(
735 path.join(root, profileDirectory, "cookies.sqlite"),
736 "db",
737 );
738 yield* fileSystem.makeDirectory(path.join(scanRoot, "stale.default"), {
739 recursive: true,
740 });
741 yield* fileSystem.writeFileString(path.join(scanRoot, "stale-file.default"), "not-dir");
742
743 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
744 {
745 directory: profileDirectory,
746 name: path.basename(profileDirectory),
747 },
748 ]);
749 }),
750 ),
751 );
752
753 it.effect("scans for profiles when profiles.ini declares only ones without cookies", () =>
754 run(
755 Effect.gen(function* () {
756 const fileSystem = yield* FileSystem.FileSystem;
757 const path = yield* Path.Path;
758 const home = yield* fileSystem.makeTempDirectoryScoped({
759 prefix: "t3code-firefox-stale-ini-",
760 });
761 const context = yield* sourcePathContext.pipe(
762 Effect.provideService(HostProcess.Environment, { HOME: home }),
763 Effect.provideService(HostProcess.Platform, "darwin"),
764 );
765 const root = firefox.userDataDirectory(context)!;
766 yield* fileSystem.makeDirectory(path.join(root, "Profiles", "stale.default"), {
767 recursive: true,
768 });
769 const realDirectory = path.join(root, "Profiles", "real.default");
770 yield* fileSystem.makeDirectory(realDirectory, { recursive: true });
771 yield* writeFirefoxCookieDatabase(path.join(realDirectory, "cookies.sqlite"), 3, 0);
772 yield* fileSystem.writeFileString(
773 path.join(root, "profiles.ini"),
774 ["[Profile0]", "Name=Stale", "IsRelative=1", "Path=Profiles/stale.default"].join("\n"),
775 );
776
777 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
778 {
779 directory: path.join("Profiles", "real.default"),
780 name: "real.default",
781 cookieCount: 3,
782 },
783 ]);
784 assert.isTrue(yield* isSourceInstalled(firefox, context));
785 }),
786 ),
787 );
788
789 it.effect("counts only importable cookies for declared and fallback profiles", () =>
790 run(
791 Effect.gen(function* () {
792 const fileSystem = yield* FileSystem.FileSystem;
793 const path = yield* Path.Path;
794 const home = yield* fileSystem.makeTempDirectoryScoped({
795 prefix: "t3code-firefox-counts-",
796 });
797 const context = yield* sourcePathContext.pipe(
798 Effect.provideService(HostProcess.Environment, { HOME: home }),
799 Effect.provideService(HostProcess.Platform, "darwin"),
800 );
801 const root = firefox.userDataDirectory(context)!;
802 const declaredDirectory = path.join(root, "Profiles", "declared.default");
803 yield* fileSystem.makeDirectory(declaredDirectory, { recursive: true });
804 yield* writeFirefoxCookieDatabase(path.join(declaredDirectory, "cookies.sqlite"), 2, 3);
805 yield* fileSystem.writeFileString(
806 path.join(root, "profiles.ini"),
807 ["[Profile0]", "Name=Declared", "IsRelative=1", "Path=Profiles/declared.default"].join(
808 "\n",
809 ),
810 );
811
812 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
813 {
814 directory: path.join("Profiles", "declared.default"),
815 name: "Declared",
816 cookieCount: 2,
817 },
818 ]);
819
820 yield* fileSystem.remove(path.join(root, "profiles.ini"));
821 const fallbackDirectory = path.join(root, "Profiles", "fallback.default");
822 yield* fileSystem.makeDirectory(fallbackDirectory, { recursive: true });
823 yield* writeFirefoxCookieDatabase(path.join(fallbackDirectory, "cookies.sqlite"), 1, 4);
824
825 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
826 {
827 directory: path.join("Profiles", "declared.default"),
828 name: "declared.default",
829 cookieCount: 2,
830 },
831 {
832 directory: path.join("Profiles", "fallback.default"),
833 name: "fallback.default",
834 cookieCount: 1,
835 },
836 ]);
837 }),
838 ),
839 );
840});
841
842describe("isSourceRunning for Firefox", () => {
843 it.effect.skipIf(!symlinksSupported)("finds the lock inside the profile, not at the root", () =>
844 run(
845 Effect.gen(function* () {
846 const fileSystem = yield* FileSystem.FileSystem;
847 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" });
848 const context = yield* sourcePathContext.pipe(
849 Effect.provideService(HostProcess.Environment, { HOME: home }),
850 Effect.provideService(HostProcess.Platform, "darwin"),
851 );
852 const root = firefox.userDataDirectory(context)!;
853 const profile = `${root}/Profiles/abcd.default-release`;
854 yield* fileSystem.makeDirectory(profile, { recursive: true });
855 yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db");
856
857 assert.isFalse(yield* isSourceRunning(firefox, context));
858
859 yield* fileSystem.writeFileString(`${root}/lock`, "");
860 assert.isFalse(yield* isSourceRunning(firefox, context));
861
862 yield* fileSystem.writeFileString(`${profile}/.parentlock`, "");
863 assert.isFalse(yield* isSourceRunning(firefox, context));
864
865 yield* fileSystem.symlink(`127.0.0.1:+${process.pid}`, `${profile}/lock`);
866 assert.isTrue(yield* isSourceRunning(firefox, context));
867 }),
868 ),
869 );
870
871 it.effect("reads a Firefox lock symlink's pid to tell live from crashed", () =>
872 Effect.gen(function* () {
873 const alive = (pid: number) => Effect.succeed(pid === 4242);
874 const local = new Set(["127.0.0.1", "127.0.1.1", "192.168.1.20"]);
875 assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.0.1:4242", local, alive));
876 assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.1.1:+4242", local, alive));
877 assert.isTrue(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+4242", local, alive));
878 assert.isFalse(yield* firefoxSymlinkLockIsHeld("127.0.0.1:+9999", local, alive));
879 assert.isFalse(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+9999", local, alive));
880 assert.isTrue(yield* firefoxSymlinkLockIsHeld("garbage", local, alive));
881 assert.isTrue(yield* firefoxSymlinkLockIsHeld("10.0.0.7:+9999", local, alive));
882 }),
883 );
884
885 it.effect("does not treat a stale parent.lock file as a running browser", () =>
886 run(
887 Effect.gen(function* () {
888 const fileSystem = yield* FileSystem.FileSystem;
889 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" });
890 const context = yield* sourcePathContext.pipe(
891 // Without APPDATA, the Windows Firefox fixture would escape into the repo.
892 Effect.provideService(HostProcess.Environment, {
893 HOME: home,
894 APPDATA: `${home}/AppData/Roaming`,
895 }),
896 Effect.provideService(HostProcess.Platform, "win32"),
897 );
898 const root = firefox.userDataDirectory(context)!;
899 const profile = `${root}/Profiles/gx7x7fqx.default-release`;
900 yield* fileSystem.makeDirectory(profile, { recursive: true });
901 yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db");
902
903 yield* fileSystem.writeFileString(`${profile}/parent.lock`, "");
904 assert.isFalse(yield* isSourceRunning(firefox, context));
905 }),
906 ),
907 );
908});
909
910describe("listSourceProfiles hardening", () => {
911 it.effect("drops profile directories that are not a single plain segment", () =>
912 run(
913 Effect.gen(function* () {
914 const fileSystem = yield* FileSystem.FileSystem;
915 const context = yield* withSourceHome();
916 yield* fileSystem.writeFileString(
917 `${userDataDirectory(context)}/Local State`,
918 `{"profile":{"info_cache":{"Default":{"name":"You"},"../../../../secrets":{"name":"Escape"},"a/b":{"name":"Nested"},"..":{"name":"Parent"}}}}`,
919 );
920
921 const profiles = yield* listSourceProfiles(helium, context);
922
923 assert.deepEqual(
924 profiles.map((profile) => profile.directory),
925 ["Default"],
926 );
927 }),
928 ),
929 );
930});
931
932describe("Safari profiles", () => {
933 const safari = BROWSER_IMPORT_SOURCES.find((source) => source.id === "safari")!;
934 const workUuid = "C561D071-67AD-4537-866F-54F65FB8E8DD";
935 const otherUuid = "2875EB19-B938-4E38-BE92-5AE97C256BDD";
936
937 const fixture = Effect.fnUntraced(function* () {
938 const context = yield* withSourceHome();
939 const fileSystem = yield* FileSystem.FileSystem;
940 const root = safari.userDataDirectory(context)!;
941 const library = context.path.dirname(root);
942 yield* fileSystem.makeDirectory(root, { recursive: true });
943 yield* fileSystem.writeFileString(context.path.join(root, "Cookies.binarycookies"), "default");
944 const store = (uuid: string) =>
945 context.path.join(library, "WebKit", "WebsiteDataStore", uuid.toLowerCase(), "Cookies");
946 for (const uuid of [workUuid, otherUuid]) {
947 yield* fileSystem.makeDirectory(store(uuid), { recursive: true });
948 yield* fileSystem.writeFileString(
949 context.path.join(store(uuid), "Cookies.binarycookies"),
950 uuid,
951 );
952 }
953 yield* fileSystem.makeDirectory(context.path.join(library, "Safari"), { recursive: true });
954 const metadata = context.path.join(library, "Safari", "SafariTabs.db");
955 return { context, root, store, metadata };
956 });
957
958 it.effect("discovers named profiles and resolves only the selected profile's cookies", () =>
959 run(
960 Effect.gen(function* () {
961 const { context, root, store, metadata } = yield* fixture();
962 yield* Effect.sync(() => {
963 const database = new NodeSqlite.DatabaseSync(metadata);
964 try {
965 database.exec(`CREATE TABLE bookmarks (
966 title TEXT, external_uuid TEXT, parent INTEGER DEFAULT 0,
967 type INTEGER DEFAULT 1, subtype INTEGER DEFAULT 2,
968 deleted INTEGER DEFAULT 0, order_index INTEGER DEFAULT 0
969 )`);
970 const insert = database.prepare(
971 "INSERT INTO bookmarks (title, external_uuid, deleted) VALUES (?, ?, ?)",
972 );
973 insert.run("", "DefaultProfile", 0);
974 insert.run("Ping", workUuid, 0);
975 insert.run("Deleted", otherUuid, 1);
976 insert.run("Unsafe", "../../outside", 0);
977 database.exec(
978 "INSERT INTO bookmarks (title, external_uuid, subtype) VALUES ('Tab group', 'group', 1)",
979 );
980 } finally {
981 database.close();
982 }
983 });
984 const profiles = yield* listSourceProfiles(safari, context);
985 assert.deepEqual(profiles, [
986 { directory: ".", name: "Personal" },
987 { directory: store(workUuid), name: "Ping" },
988 ]);
989 assert.strictEqual(
990 yield* resolveCookieDatabase(safari, context, "."),
991 context.path.join(root, "Cookies.binarycookies"),
992 );
993 const selected = yield* resolveCookieDatabase(safari, context, profiles[1]!.directory);
994 assert.strictEqual(selected, context.path.join(store(workUuid), "Cookies.binarycookies"));
995 const fileSystem = yield* FileSystem.FileSystem;
996 assert.strictEqual(yield* fileSystem.readFileString(selected!), workUuid);
997 yield* fileSystem.remove(selected!);
998 assert.isUndefined(yield* resolveCookieDatabase(safari, context, profiles[1]!.directory));
999 assert.deepEqual(yield* listSourceProfiles(safari, context), profiles);
1000 }),
1001 ),
1002 );
1003
1004 it.effect.each(["missing", "corrupt"] as const)(
1005 "recovers separate cookie stores when metadata is %s",
1006 (metadataState) =>
1007 run(
1008 Effect.gen(function* () {
1009 const { context, store, metadata } = yield* fixture();
1010 const fileSystem = yield* FileSystem.FileSystem;
1011 if (metadataState === "corrupt") yield* fileSystem.writeFileString(metadata, "invalid");
1012 yield* fileSystem.remove(context.path.join(store(otherUuid), "Cookies.binarycookies"));
1013 assert.deepEqual(yield* listSourceProfiles(safari, context), [
1014 { directory: ".", name: "Safari" },
1015 { directory: store(workUuid), name: workUuid.toLowerCase() },
1016 ]);
1017 assert.isTrue(yield* isSourceInstalled(safari, context));
1018 }),
1019 ),
1020 );
1021});