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 PlatformError from "effect/PlatformError";
11import * as Scope from "effect/Scope";
12import * as Stream from "effect/Stream";
13import { ChildProcess, ChildProcessSpawner } from "effect/process";
14import * as NodeSqlite from "node:sqlite";
15
16import type { BrowserImportPathContext } from "./Sources.ts";
17import {
18 BROWSER_IMPORT_SOURCES,
19 chromiumProcessIsAlive,
20 chromiumSingletonLockIsHeld,
21 cookieDatabaseCandidatePaths,
22 firefoxSymlinkLockIsHeld,
23 resolveCookieDatabase,
24 isSourceInstalled,
25 isSourceRunning,
26 isWindowsLockHeldError,
27 posixLockIsHeld,
28 listSourceProfiles,
29 sourcePathContext,
30 windowsChromiumCookiesAreHeld,
31} from "./Sources.ts";
32import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
33
34const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;
35
36describe("Linux Chromium secret applications", () => {
37 it("pins the libsecret application attribute for each supported fork", () => {
38 assert.deepEqual(
39 Object.fromEntries(
40 BROWSER_IMPORT_SOURCES.filter((source) => source.platforms.includes("linux")).map(
41 (source) => [source.id, source.linuxSecretApplication],
42 ),
43 ),
44 {
45 chrome: "chrome",
46 edge: "msedge",
47 brave: "brave",
48 vivaldi: "vivaldi",
49 opera: "opera",
50 helium: "chromium",
51 firefox: undefined,
52 },
53 );
54 });
55});
56
57const platformError = (reasonTag: string): PlatformError.PlatformError =>
58 ({ _tag: "PlatformError", reason: { _tag: reasonTag } }) as never;
59
60describe("Windows browser lock errors", () => {
61 it("treats sharing and lock violations reported as Busy as held", () => {
62 assert.isTrue(isWindowsLockHeldError(platformError("Busy")));
63 });
64
65 it("does not treat access denied as proof of an active lock", () => {
66 assert.isFalse(isWindowsLockHeldError(platformError("PermissionDenied")));
67 });
68
69 it("does not treat a missing lock file as held", () => {
70 assert.isFalse(isWindowsLockHeldError(platformError("NotFound")));
71 });
72});
73
74/** A scratch home with the source's user-data directory already created. */
75const withSourceHome = Effect.fnUntraced(function* () {
76 const fileSystem = yield* FileSystem.FileSystem;
77 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-sources-" });
78 const context = yield* sourcePathContext.pipe(
79 Effect.provideService(HostProcess.Environment, { HOME: home }),
80 Effect.provideService(HostProcess.Platform, "darwin"),
81 );
82 yield* fileSystem.makeDirectory(userDataDirectory(context), { recursive: true });
83 return context;
84});
85
86/** Every case here runs on darwin, where Helium always resolves a directory. */
87const userDataDirectory = (context: BrowserImportPathContext) => {
88 const root = helium.userDataDirectory(context);
89 if (root === undefined) throw new Error("Helium has no macOS user-data directory");
90 return root;
91};
92
93const run = <A, E>(
94 effect: Effect.Effect<
95 A,
96 E,
97 FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner
98 >,
99) => effect.pipe(Effect.provide(NodeServices.layer), Effect.scoped);
100
101/** Writes a Chromium-shaped cookie table with `count` rows. */
102const writeCookieDatabase = (file: string, count: number) =>
103 Effect.sync(() => {
104 const database = new NodeSqlite.DatabaseSync(file);
105 database.exec("create table cookies (host_key text, name text)");
106 const insert = database.prepare("insert into cookies (host_key, name) values (?, ?)");
107 for (let index = 0; index < count; index += 1) insert.run("example.test", `c${index}`);
108 database.close();
109 });
110
111const writeFirefoxCookieDatabase = (
112 file: string,
113 defaultContainerCount: number,
114 containerCount: number,
115) =>
116 Effect.sync(() => {
117 const database = new NodeSqlite.DatabaseSync(file);
118 database.exec("create table moz_cookies (originAttributes text not null)");
119 const insert = database.prepare("insert into moz_cookies (originAttributes) values (?)");
120 for (let index = 0; index < defaultContainerCount; index += 1) insert.run("");
121 for (let index = 0; index < containerCount; index += 1) insert.run("^userContextId=2");
122 database.close();
123 });
124
125describe("Helium on Linux", () => {
126 it.effect.skipIf(!symlinksSupported)("discovers its profiles and checks the user-data lock", () =>
127 run(
128 Effect.gen(function* () {
129 const fileSystem = yield* FileSystem.FileSystem;
130 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-helium-linux-" });
131 const context = yield* sourcePathContext.pipe(
132 Effect.provideService(HostProcess.Environment, { HOME: home }),
133 Effect.provideService(HostProcess.Platform, "linux"),
134 );
135 const root = `${home}/.config/net.imput.helium`;
136 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
137 yield* writeCookieDatabase(`${root}/Default/Cookies`, 3);
138 yield* fileSystem.writeFileString(
139 `${root}/Local State`,
140 '{"profile":{"info_cache":{"Default":{"name":"Personal"}}}}',
141 );
142
143 assert.include(helium.platforms, "linux");
144 assert.isTrue(yield* isSourceInstalled(helium, context));
145 assert.deepEqual(yield* listSourceProfiles(helium, context), [
146 { directory: "Default", name: "Personal", cookieCount: 3 },
147 ]);
148 assert.isFalse(yield* isSourceRunning(helium, context));
149 yield* fileSystem.symlink("foreign-host-4242", `${root}/SingletonLock`);
150 assert.isTrue(yield* isSourceRunning(helium, context));
151 }),
152 ),
153 );
154});
155
156describe("Helium on Windows", () => {
157 it.effect("uses Helium's local app-data profile while other Chromium forks stay disabled", () =>
158 run(
159 Effect.gen(function* () {
160 const context = yield* sourcePathContext.pipe(
161 Effect.provideService(HostProcess.Environment, {
162 USERPROFILE: "C:\\Users\\browser-user",
163 LOCALAPPDATA: "C:\\Users\\browser-user\\AppData\\Local",
164 }),
165 Effect.provideService(HostProcess.Platform, "win32"),
166 );
167
168 assert.include(helium.platforms, "win32");
169 assert.equal(
170 helium.userDataDirectory(context),
171 context.path.join(
172 "C:\\Users\\browser-user\\AppData\\Local",
173 "imput",
174 "Helium",
175 "User Data",
176 ),
177 );
178 for (const source of BROWSER_IMPORT_SOURCES) {
179 if (source.engine === "chromium" && source.id !== "helium") {
180 assert.notInclude(source.platforms, "win32");
181 }
182 }
183 }),
184 ),
185 );
186});
187
188describe("isSourceRunning", () => {
189 it.effect("uses the held cookie database as Chromium's Windows running signal", () =>
190 run(
191 Effect.gen(function* () {
192 const fileSystem = yield* FileSystem.FileSystem;
193 const home = yield* fileSystem.makeTempDirectoryScoped({
194 prefix: "t3code-helium-windows-lock-",
195 });
196 const context = yield* sourcePathContext.pipe(
197 Effect.provideService(HostProcess.Environment, {
198 HOME: home,
199 LOCALAPPDATA: home,
200 }),
201 Effect.provideService(HostProcess.Platform, "win32"),
202 );
203 const profile = context.path.join(helium.userDataDirectory(context)!, "Default");
204 const database = context.path.join(profile, "Network", "Cookies");
205 yield* fileSystem.makeDirectory(context.path.join(profile, "Network"), { recursive: true });
206 yield* writeCookieDatabase(database, 1);
207
208 const probed: string[] = [];
209 assert.isTrue(
210 yield* windowsChromiumCookiesAreHeld(helium, context, (path) =>
211 Effect.sync(() => {
212 probed.push(path);
213 return true;
214 }),
215 ),
216 );
217 assert.deepEqual(probed, [database]);
218 }),
219 ),
220 );
221
222 it.effect.skipIf(!symlinksSupported)(
223 "reads Chromium's dangling SingletonLock symlink as a running browser",
224 () =>
225 run(
226 Effect.gen(function* () {
227 const fileSystem = yield* FileSystem.FileSystem;
228 const context = yield* withSourceHome();
229 assert.isFalse(yield* isSourceRunning(helium, context));
230
231 // Chromium points the lock at `<host>-<pid>`, a target that never
232 // exists on disk. A check that follows the link reports a running
233 // browser as closed, letting an import read a live, mid-write database.
234 yield* fileSystem.symlink(
235 "host-that-does-not-exist-1234",
236 `${userDataDirectory(context)}/SingletonLock`,
237 );
238
239 assert.isTrue(yield* isSourceRunning(helium, context));
240 }),
241 ),
242 );
243
244 it.effect.skipIf(!symlinksSupported)(
245 "uses the provided hostname to classify Chromium locks",
246 () =>
247 run(
248 Effect.gen(function* () {
249 const fileSystem = yield* FileSystem.FileSystem;
250 const paths = yield* withSourceHome();
251 yield* fileSystem.symlink(
252 "lock-owner-99999999",
253 `${helium.userDataDirectory(paths)}/SingletonLock`,
254 );
255
256 assert.isTrue(
257 yield* isSourceRunning(helium, paths).pipe(
258 Effect.provideService(HostProcess.Hostname, "another-host"),
259 ),
260 );
261 assert.isFalse(
262 yield* isSourceRunning(helium, paths).pipe(
263 Effect.provideService(HostProcess.Hostname, "lock-owner"),
264 ),
265 );
266 }),
267 ),
268 );
269});
270
271describe("chromiumSingletonLockIsHeld", () => {
272 it.effect("ignores a positively dead PID on the current host", () =>
273 Effect.gen(function* () {
274 const checked: number[] = [];
275 const held = yield* chromiumSingletonLockIsHeld("current-host-4321", "current-host", (pid) =>
276 Effect.sync(() => {
277 checked.push(pid);
278 return false;
279 }),
280 );
281 assert.isFalse(held);
282 assert.deepEqual(checked, [4321]);
283 }),
284 );
285
286 it.effect("keeps a live PID on the current host", () =>
287 chromiumSingletonLockIsHeld("current-host-4321", "current-host", () =>
288 Effect.succeed(true),
289 ).pipe(Effect.tap((held) => Effect.sync(() => assert.isTrue(held)))),
290 );
291
292 it.effect("keeps foreign-host and malformed targets without probing a PID", () =>
293 Effect.gen(function* () {
294 let probes = 0;
295 const probe = (_pid: number) =>
296 Effect.sync(() => {
297 probes += 1;
298 return false;
299 });
300 assert.isTrue(yield* chromiumSingletonLockIsHeld("another-host-4321", "current-host", probe));
301 assert.isTrue(
302 yield* chromiumSingletonLockIsHeld("current-host-no-pid", "current-host", probe),
303 );
304 assert.isTrue(yield* chromiumSingletonLockIsHeld("current-host-0", "current-host", probe));
305 assert.strictEqual(probes, 0);
306 }),
307 );
308});
309
310describe("chromiumProcessIsAlive", () => {
311 it.effect("returns false only when signal 0 reports a missing process", () =>
312 Effect.gen(function* () {
313 const missing = Object.assign(new Error("missing"), { code: "ESRCH" });
314 const denied = Object.assign(new Error("denied"), { code: "EPERM" });
315 assert.isFalse(
316 yield* chromiumProcessIsAlive(4321, () => {
317 throw missing;
318 }),
319 );
320 assert.isTrue(
321 yield* chromiumProcessIsAlive(4321, () => {
322 throw denied;
323 }),
324 );
325 assert.isTrue(
326 yield* chromiumProcessIsAlive(4321, () => {
327 throw undefined;
328 }),
329 );
330 assert.isTrue(
331 yield* chromiumProcessIsAlive(4321, () => {
332 throw "unknown failure";
333 }),
334 );
335 assert.isTrue(
336 yield* chromiumProcessIsAlive(4321, () => {
337 throw null;
338 }),
339 );
340 assert.isTrue(yield* chromiumProcessIsAlive(4321, () => true));
341 }),
342 );
343});
344
345describe("isSourceInstalled", () => {
346 it.effect("ignores a user-data directory that holds no cookie database", () =>
347 run(
348 Effect.gen(function* () {
349 const fileSystem = yield* FileSystem.FileSystem;
350 const context = yield* withSourceHome();
351 const root = userDataDirectory(context);
352
353 // Installers for native messaging hosts create an empty user-data
354 // directory for every Chromium fork they know about, so treating the
355 // directory as evidence lists browsers the user does not have.
356 yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true });
357 assert.isFalse(yield* isSourceInstalled(helium, context));
358
359 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
360 yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db");
361 assert.isTrue(yield* isSourceInstalled(helium, context));
362
363 // A real install whose cookies live outside `Default` still counts:
364 // reporting it as absent hides the source from the menu entirely.
365 yield* fileSystem.remove(`${root}/Default`, { recursive: true });
366 yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true });
367 yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db");
368 assert.isTrue(yield* isSourceInstalled(helium, context));
369
370 yield* fileSystem.remove(root, { recursive: true });
371 assert.isFalse(yield* isSourceInstalled(helium, context));
372 }),
373 ),
374 );
375
376 it.effect("detects a Chromium 127+ install with cookies under Network/", () =>
377 run(
378 Effect.gen(function* () {
379 const fileSystem = yield* FileSystem.FileSystem;
380 const context = yield* withSourceHome();
381 const root = userDataDirectory(context);
382
383 yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true });
384 yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "db");
385 assert.isTrue(yield* isSourceInstalled(helium, context));
386 }),
387 ),
388 );
389
390 it.effect.skipIf(!symlinksSupported)(
391 "follows cookie database symlinks when detecting profiles",
392 () =>
393 run(
394 Effect.gen(function* () {
395 const fileSystem = yield* FileSystem.FileSystem;
396 const context = yield* withSourceHome();
397 const root = userDataDirectory(context);
398 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
399 yield* fileSystem.symlink("missing-cookies", `${root}/Default/Cookies`);
400
401 assert.deepEqual(yield* listSourceProfiles(helium, context), []);
402 assert.isFalse(yield* isSourceInstalled(helium, context));
403
404 yield* fileSystem.writeFileString(`${root}/Default/missing-cookies`, "db");
405 assert.deepEqual(yield* listSourceProfiles(helium, context), [
406 { directory: "Default", name: "Default" },
407 ]);
408 assert.isTrue(yield* isSourceInstalled(helium, context));
409 }),
410 ),
411 );
412});
413
414describe("listSourceProfiles", () => {
415 it.effect("ignores a profile whose Cookies entry is not a file", () =>
416 run(
417 Effect.gen(function* () {
418 const fileSystem = yield* FileSystem.FileSystem;
419 const paths = yield* withSourceHome();
420 const root = helium.userDataDirectory(paths);
421 // A directory named `Cookies` would list as importable and then fail
422 // the SQLite open, so only a regular file counts as a database.
423 yield* fileSystem.makeDirectory(`${root}/Broken/Cookies`, { recursive: true });
424 yield* fileSystem.makeDirectory(`${root}/Real`, { recursive: true });
425 yield* fileSystem.writeFileString(`${root}/Real/Cookies`, "db");
426
427 assert.deepEqual(yield* listSourceProfiles(helium, paths), [
428 { directory: "Real", name: "Real" },
429 ]);
430 assert.isTrue(yield* isSourceInstalled(helium, paths));
431 }),
432 ),
433 );
434
435 it.effect("discovers profiles by their cookie database when Local State is absent", () =>
436 run(
437 Effect.gen(function* () {
438 const fileSystem = yield* FileSystem.FileSystem;
439 const context = yield* withSourceHome();
440 const root = userDataDirectory(context);
441 // Assuming `Default` would report a browser whose cookies live in
442 // `Profile 1` as having nothing to import, and it is then hidden.
443 yield* fileSystem.makeDirectory(`${root}/Profile 1`, { recursive: true });
444 yield* fileSystem.writeFileString(`${root}/Profile 1/Cookies`, "db");
445 yield* fileSystem.makeDirectory(`${root}/NativeMessagingHosts`, { recursive: true });
446
447 assert.deepEqual(yield* listSourceProfiles(helium, context), [
448 { directory: "Profile 1", name: "Profile 1" },
449 ]);
450 }),
451 ),
452 );
453
454 it.effect("reads the profile names the browser shows", () =>
455 run(
456 Effect.gen(function* () {
457 const fileSystem = yield* FileSystem.FileSystem;
458 const context = yield* withSourceHome();
459 yield* fileSystem.writeFileString(
460 `${userDataDirectory(context)}/Local State`,
461 `{"profile":{"info_cache":{"Default":{"name":"You"},"Profile 2":{"name":" "}}}}`,
462 );
463
464 assert.deepEqual(yield* listSourceProfiles(helium, context), [
465 { directory: "Default", name: "You" },
466 // Blank display name falls back to the directory rather than
467 // rendering an empty row.
468 { directory: "Profile 2", name: "Profile 2" },
469 ]);
470 }),
471 ),
472 );
473
474 it.effect("scans for profiles when Local State is malformed", () =>
475 run(
476 Effect.gen(function* () {
477 const fileSystem = yield* FileSystem.FileSystem;
478 const context = yield* withSourceHome();
479 const root = userDataDirectory(context);
480 yield* fileSystem.writeFileString(`${root}/Local State`, "{not-json");
481 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
482 yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db");
483
484 assert.deepEqual(yield* listSourceProfiles(helium, context), [
485 { directory: "Default", name: "Default" },
486 ]);
487 }),
488 ),
489 );
490
491 it.effect("reports nothing when no directory holds a cookie database", () =>
492 run(
493 Effect.gen(function* () {
494 const context = yield* withSourceHome();
495 assert.deepEqual(yield* listSourceProfiles(helium, context), []);
496 }),
497 ),
498 );
499
500 it.effect("drops Firefox profiles that hold no cookie database", () =>
501 run(
502 Effect.gen(function* () {
503 const fileSystem = yield* FileSystem.FileSystem;
504 const context = yield* withSourceHome();
505 const root = firefox.userDataDirectory(context)!;
506 yield* fileSystem.makeDirectory(root, { recursive: true });
507 yield* fileSystem.writeFileString(
508 `${root}/profiles.ini`,
509 `[Profile0]
510Name=original
511IsRelative=1
512Path=Profiles/abcd.default-release
513Default=1
514
515[Profile1]
516Name=empty
517IsRelative=1
518Path=Profiles/wxyz.empty
519`,
520 );
521 yield* fileSystem.makeDirectory(`${root}/Profiles/abcd.default-release`, {
522 recursive: true,
523 });
524 yield* fileSystem.writeFileString(
525 `${root}/Profiles/abcd.default-release/cookies.sqlite`,
526 "db",
527 );
528 yield* fileSystem.makeDirectory(`${root}/Profiles/wxyz.empty`, { recursive: true });
529
530 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
531 { directory: context.path.join("Profiles", "abcd.default-release"), name: "original" },
532 ]);
533 }),
534 ),
535 );
536
537 it.effect("drops empty profiles when falling back to the Profiles/ scan", () =>
538 run(
539 Effect.gen(function* () {
540 const fileSystem = yield* FileSystem.FileSystem;
541 const context = yield* withSourceHome();
542 const root = firefox.userDataDirectory(context)!;
543 yield* fileSystem.makeDirectory(`${root}/Profiles/filled.default`, { recursive: true });
544 yield* fileSystem.writeFileString(`${root}/Profiles/filled.default/cookies.sqlite`, "db");
545 yield* fileSystem.makeDirectory(`${root}/Profiles/empty.default`, { recursive: true });
546
547 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
548 {
549 directory: context.path.join("Profiles", "filled.default"),
550 name: "filled.default",
551 },
552 ]);
553 }),
554 ),
555 );
556
557 it.effect("discovers profiles with cookies under Network/ (Chromium 127+)", () =>
558 run(
559 Effect.gen(function* () {
560 const fileSystem = yield* FileSystem.FileSystem;
561 const context = yield* withSourceHome();
562 const root = userDataDirectory(context);
563 yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true });
564 yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "db");
565
566 assert.deepEqual(yield* listSourceProfiles(helium, context), [
567 { directory: "Default", name: "Default" },
568 ]);
569 }),
570 ),
571 );
572
573 it.effect("counts a profile's cookies without decrypting them", () =>
574 run(
575 Effect.gen(function* () {
576 const fileSystem = yield* FileSystem.FileSystem;
577 const paths = yield* withSourceHome();
578 const root = helium.userDataDirectory(paths);
579 yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
580 yield* writeCookieDatabase(`${root}/Default/Cookies`, 3);
581
582 const [profile] = yield* listSourceProfiles(helium, paths);
583 assert.equal(profile?.cookieCount, 3);
584 }),
585 ),
586 );
587
588 it.effect("falls through to the legacy database when Network/Cookies is a directory", () =>
589 run(
590 Effect.gen(function* () {
591 const fileSystem = yield* FileSystem.FileSystem;
592 const paths = yield* withSourceHome();
593 const root = helium.userDataDirectory(paths);
594 // A folder squatting on the preferred candidate path must not shadow
595 // the real legacy database behind it.
596 yield* fileSystem.makeDirectory(`${root}/Default/Network/Cookies`, { recursive: true });
597 yield* writeCookieDatabase(`${root}/Default/Cookies`, 2);
598
599 const [profile] = yield* listSourceProfiles(helium, paths);
600 assert.equal(profile?.directory, "Default");
601 assert.equal(profile?.cookieCount, 2);
602 }),
603 ),
604 );
605});
606
607describe("cookieDatabaseCandidatePaths", () => {
608 it.effect("prefers Network/Cookies and falls back to the legacy Cookies", () =>
609 run(
610 Effect.gen(function* () {
611 const context = yield* withSourceHome();
612 const profile = context.path.join(
613 context.home,
614 "Library",
615 "Application Support",
616 "net.imput.helium",
617 "Profile 1",
618 );
619 assert.deepEqual(cookieDatabaseCandidatePaths(helium, context, "Profile 1"), [
620 context.path.join(profile, "Network", "Cookies"),
621 context.path.join(profile, "Cookies"),
622 ]);
623 }),
624 ),
625 );
626
627 it.effect("resolves the live Network/ jar over a leftover root Cookies", () =>
628 run(
629 Effect.gen(function* () {
630 const fileSystem = yield* FileSystem.FileSystem;
631 const context = yield* withSourceHome();
632 const root = userDataDirectory(context);
633 // Chromium 96+ keeps sessions in Network/; a root Cookies left behind
634 // by the move is stale and must not be the one imported.
635 yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true });
636 yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "live");
637 yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "stale");
638
639 assert.equal(
640 yield* resolveCookieDatabase(helium, context, "Default"),
641 context.path.join(root, "Default", "Network", "Cookies"),
642 );
643 // A fresh install with only the Network/ jar is installed, not hidden.
644 yield* fileSystem.remove(`${root}/Default/Cookies`);
645 assert.isTrue(yield* isSourceInstalled(helium, context));
646 }),
647 ),
648 );
649
650 it.effect("returns only cookies.sqlite for Firefox", () =>
651 run(
652 Effect.gen(function* () {
653 const path = yield* Path.Path;
654 const context = yield* sourcePathContext.pipe(
655 Effect.provideService(HostProcess.Environment, { HOME: "/tmp/test" }),
656 Effect.provideService(HostProcess.Platform, "darwin"),
657 );
658 const candidates = cookieDatabaseCandidatePaths(firefox, context, "Profiles/abc.default");
659 assert.deepEqual(candidates, [
660 path.join(
661 "/tmp/test",
662 "Library/Application Support/Firefox/Profiles/abc.default/cookies.sqlite",
663 ),
664 ]);
665 }),
666 ),
667 );
668});
669
670const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!;
671
672describe("Firefox Snap profiles", () => {
673 it.effect.skipIf(!symlinksSupported)(
674 "finds Snap profiles with or without profiles.ini and checks their locks",
675 () =>
676 run(
677 Effect.gen(function* () {
678 const fileSystem = yield* FileSystem.FileSystem;
679 const home = yield* fileSystem.makeTempDirectoryScoped({
680 prefix: "t3code-firefox-snap-",
681 });
682 const context = yield* sourcePathContext.pipe(
683 Effect.provideService(HostProcess.Environment, { HOME: home }),
684 Effect.provideService(HostProcess.Platform, "linux"),
685 );
686 const root = context.path.join(home, "snap", "firefox", "common", ".mozilla", "firefox");
687 const directory = context.path.join(root, "abcd.default");
688 yield* fileSystem.makeDirectory(directory, { recursive: true });
689 yield* writeFirefoxCookieDatabase(`${directory}/cookies.sqlite`, 2, 1);
690 yield* fileSystem.writeFileString(
691 `${root}/profiles.ini`,
692 "[Profile0]\nName=Personal\nIsRelative=1\nPath=abcd.default\n",
693 );
694
695 assert.isTrue(yield* isSourceInstalled(firefox, context));
696 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
697 { directory, name: "Personal", cookieCount: 2 },
698 ]);
699 assert.equal(
700 yield* resolveCookieDatabase(firefox, context, directory),
701 context.path.join(directory, "cookies.sqlite"),
702 );
703 assert.isFalse(yield* isSourceRunning(firefox, context));
704 yield* fileSystem.symlink("foreign-host:+4242", `${directory}/lock`);
705 assert.isTrue(yield* isSourceRunning(firefox, context));
706 yield* fileSystem.remove(`${directory}/lock`);
707 assert.isFalse(yield* isSourceRunning(firefox, context));
708
709 yield* fileSystem.remove(`${root}/profiles.ini`);
710 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
711 { directory, name: "abcd.default", cookieCount: 2 },
712 ]);
713 }),
714 ),
715 );
716
717 it.effect("keeps matching profile names in native and Snap installs distinct", () =>
718 run(
719 Effect.gen(function* () {
720 const fileSystem = yield* FileSystem.FileSystem;
721 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-snap-" });
722 const context = yield* sourcePathContext.pipe(
723 Effect.provideService(HostProcess.Environment, { HOME: home }),
724 Effect.provideService(HostProcess.Platform, "linux"),
725 );
726 const native = context.path.join(home, ".mozilla", "firefox");
727 const snap = context.path.join(home, "snap", "firefox", "common", ".mozilla", "firefox");
728 for (const root of [native, snap]) {
729 yield* fileSystem.makeDirectory(`${root}/abcd.default`, { recursive: true });
730 yield* writeFirefoxCookieDatabase(`${root}/abcd.default/cookies.sqlite`, 1, 0);
731 yield* fileSystem.writeFileString(
732 `${root}/profiles.ini`,
733 "[Profile0]\nName=Personal\nIsRelative=1\nPath=abcd.default\n" +
734 `[Profile1]\nName=Shared\nIsRelative=0\nPath=${snap}/abcd.default\n`,
735 );
736 }
737
738 const profiles = yield* listSourceProfiles(firefox, context);
739 assert.deepEqual(
740 profiles.map((profile) => profile.directory),
741 ["abcd.default", context.path.join(snap, "abcd.default")],
742 );
743 const databases = yield* Effect.forEach(profiles, (profile) =>
744 resolveCookieDatabase(firefox, context, profile.directory),
745 );
746 assert.deepEqual(databases, [
747 context.path.join(native, "abcd.default", "cookies.sqlite"),
748 context.path.join(snap, "abcd.default", "cookies.sqlite"),
749 ]);
750 }),
751 ),
752 );
753});
754
755describe("listSourceProfiles Firefox fallback", () => {
756 const cases = [
757 { platform: "linux" as const, profileDirectory: "linux.default" },
758 { platform: "darwin" as const, profileDirectory: NodePath.join("Profiles", "macos.default") },
759 { platform: "win32" as const, profileDirectory: NodePath.join("Profiles", "windows.default") },
760 ];
761
762 it.effect.each(cases)(
763 "scans the $platform profile location and excludes stale entries",
764 ({ platform, profileDirectory }) =>
765 run(
766 Effect.gen(function* () {
767 const fileSystem = yield* FileSystem.FileSystem;
768 const path = yield* Path.Path;
769 const home = yield* fileSystem.makeTempDirectoryScoped({
770 prefix: `t3code-firefox-${platform}-`,
771 });
772 const appData = path.join(home, "AppData", "Roaming");
773 const context = yield* sourcePathContext.pipe(
774 Effect.provideService(HostProcess.Environment, {
775 HOME: home,
776 APPDATA: appData,
777 }),
778 Effect.provideService(HostProcess.Platform, platform),
779 );
780 const root = firefox.userDataDirectory(context)!;
781 const scanRoot = platform === "linux" ? root : path.join(root, "Profiles");
782 yield* fileSystem.makeDirectory(path.join(root, profileDirectory), { recursive: true });
783 yield* fileSystem.writeFileString(
784 path.join(root, profileDirectory, "cookies.sqlite"),
785 "db",
786 );
787 yield* fileSystem.makeDirectory(path.join(scanRoot, "stale.default"), {
788 recursive: true,
789 });
790 yield* fileSystem.writeFileString(path.join(scanRoot, "stale-file.default"), "not-dir");
791
792 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
793 {
794 directory: profileDirectory,
795 name: path.basename(profileDirectory),
796 },
797 ]);
798 }),
799 ),
800 );
801
802 it.effect("scans for profiles when profiles.ini declares only ones without cookies", () =>
803 run(
804 Effect.gen(function* () {
805 const fileSystem = yield* FileSystem.FileSystem;
806 const path = yield* Path.Path;
807 const home = yield* fileSystem.makeTempDirectoryScoped({
808 prefix: "t3code-firefox-stale-ini-",
809 });
810 const context = yield* sourcePathContext.pipe(
811 Effect.provideService(HostProcess.Environment, { HOME: home }),
812 Effect.provideService(HostProcess.Platform, "darwin"),
813 );
814 const root = firefox.userDataDirectory(context)!;
815 // `profiles.ini` names a profile that was never launched (no cookie
816 // database), while the real cookies sit in an undeclared one.
817 yield* fileSystem.makeDirectory(path.join(root, "Profiles", "stale.default"), {
818 recursive: true,
819 });
820 const realDirectory = path.join(root, "Profiles", "real.default");
821 yield* fileSystem.makeDirectory(realDirectory, { recursive: true });
822 yield* writeFirefoxCookieDatabase(path.join(realDirectory, "cookies.sqlite"), 3, 0);
823 yield* fileSystem.writeFileString(
824 path.join(root, "profiles.ini"),
825 ["[Profile0]", "Name=Stale", "IsRelative=1", "Path=Profiles/stale.default"].join("\n"),
826 );
827
828 // Returning the empty declared list would hide the browser entirely.
829 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
830 {
831 directory: path.join("Profiles", "real.default"),
832 name: "real.default",
833 cookieCount: 3,
834 },
835 ]);
836 assert.isTrue(yield* isSourceInstalled(firefox, context));
837 }),
838 ),
839 );
840
841 it.effect("counts only importable cookies for declared and fallback profiles", () =>
842 run(
843 Effect.gen(function* () {
844 const fileSystem = yield* FileSystem.FileSystem;
845 const path = yield* Path.Path;
846 const home = yield* fileSystem.makeTempDirectoryScoped({
847 prefix: "t3code-firefox-counts-",
848 });
849 const context = yield* sourcePathContext.pipe(
850 Effect.provideService(HostProcess.Environment, { HOME: home }),
851 Effect.provideService(HostProcess.Platform, "darwin"),
852 );
853 const root = firefox.userDataDirectory(context)!;
854 const declaredDirectory = path.join(root, "Profiles", "declared.default");
855 yield* fileSystem.makeDirectory(declaredDirectory, { recursive: true });
856 yield* writeFirefoxCookieDatabase(path.join(declaredDirectory, "cookies.sqlite"), 2, 3);
857 yield* fileSystem.writeFileString(
858 path.join(root, "profiles.ini"),
859 ["[Profile0]", "Name=Declared", "IsRelative=1", "Path=Profiles/declared.default"].join(
860 "\n",
861 ),
862 );
863
864 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
865 {
866 directory: path.join("Profiles", "declared.default"),
867 name: "Declared",
868 cookieCount: 2,
869 },
870 ]);
871
872 yield* fileSystem.remove(path.join(root, "profiles.ini"));
873 const fallbackDirectory = path.join(root, "Profiles", "fallback.default");
874 yield* fileSystem.makeDirectory(fallbackDirectory, { recursive: true });
875 yield* writeFirefoxCookieDatabase(path.join(fallbackDirectory, "cookies.sqlite"), 1, 4);
876
877 assert.deepEqual(yield* listSourceProfiles(firefox, context), [
878 {
879 directory: path.join("Profiles", "declared.default"),
880 name: "declared.default",
881 cookieCount: 2,
882 },
883 {
884 directory: path.join("Profiles", "fallback.default"),
885 name: "fallback.default",
886 cookieCount: 1,
887 },
888 ]);
889 }),
890 ),
891 );
892});
893
894describe("isSourceRunning for Firefox", () => {
895 it.effect.skipIf(!symlinksSupported)("finds the lock inside the profile, not at the root", () =>
896 run(
897 Effect.gen(function* () {
898 const fileSystem = yield* FileSystem.FileSystem;
899 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" });
900 const context = yield* sourcePathContext.pipe(
901 Effect.provideService(HostProcess.Environment, { HOME: home }),
902 Effect.provideService(HostProcess.Platform, "darwin"),
903 );
904 const root = firefox.userDataDirectory(context)!;
905 const profile = `${root}/Profiles/abcd.default-release`;
906 yield* fileSystem.makeDirectory(profile, { recursive: true });
907 yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db");
908
909 assert.isFalse(yield* isSourceRunning(firefox, context));
910
911 // Firefox keeps its locks per profile. A root-level lock is not one,
912 // and looking there was why a running Firefox read as importable.
913 yield* fileSystem.writeFileString(`${root}/lock`, "");
914 assert.isFalse(yield* isSourceRunning(firefox, context));
915
916 // `.parentlock` is deliberately left on disk after a clean exit as a
917 // last-used marker, so an unlocked one is not evidence of a running
918 // browser — treating it as one blocked every import after first use.
919 yield* fileSystem.writeFileString(`${profile}/.parentlock`, "");
920 assert.isFalse(yield* isSourceRunning(firefox, context));
921
922 // The `lock` symlink is what Firefox removes on exit; a live pid in
923 // its target means the profile is held.
924 yield* fileSystem.symlink(`127.0.0.1:+${process.pid}`, `${profile}/lock`);
925 assert.isTrue(yield* isSourceRunning(firefox, context));
926 }),
927 ),
928 );
929
930 it.effect("reports not-held when no interpreter can run the fcntl probe", () =>
931 run(
932 Effect.gen(function* () {
933 const fileSystem = yield* FileSystem.FileSystem;
934 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-lock-" });
935 const lock = `${directory}/.parentlock`;
936 yield* fileSystem.writeFileString(lock, "");
937 // A Mac without the developer tools has only Apple's shim, which
938 // refuses to run the script; a machine with no python at all has
939 // nothing. Either way the probe is unavailable, not the lock held —
940 // treating it as held would block Firefox import on that machine for
941 // good.
942 assert.isFalse(yield* posixLockIsHeld(lock, ["/nonexistent/python3"]));
943 // And a fake "interpreter" that exits non-zero without a verdict, as
944 // the shim does, is the same case.
945 assert.isFalse(yield* posixLockIsHeld(lock, ["/usr/bin/false"]));
946 }),
947 ),
948 );
949
950 // Holds the lock with python3's fcntl, which does not exist on Windows.
951 it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")(
952 "detects a live fcntl lock on .parentlock, as macOS Firefox leaves it",
953 () =>
954 run(
955 Effect.gen(function* () {
956 const fileSystem = yield* FileSystem.FileSystem;
957 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
958 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" });
959 const context = yield* sourcePathContext.pipe(
960 Effect.provideService(HostProcess.Environment, { HOME: home }),
961 Effect.provideService(HostProcess.Platform, "darwin"),
962 );
963 const root = firefox.userDataDirectory(context)!;
964 const profile = `${root}/Profiles/abcd.default-release`;
965 yield* fileSystem.makeDirectory(profile, { recursive: true });
966 yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db");
967 const parentLock = `${profile}/.parentlock`;
968 yield* fileSystem.writeFileString(parentLock, "");
969
970 // Hold the lock from a child the way Firefox does (F_SETLK, write),
971 // and keep it until the scope closes.
972 const holder = yield* spawner.spawn(
973 ChildProcess.make(
974 "python3",
975 [
976 "-c",
977 "import fcntl,os,sys,time\n" +
978 "fd=os.open(sys.argv[1],os.O_WRONLY)\n" +
979 "fcntl.lockf(fd,fcntl.LOCK_EX|fcntl.LOCK_NB)\n" +
980 "print('locked',flush=True)\n" +
981 "time.sleep(30)",
982 parentLock,
983 ],
984 { stdin: "ignore" },
985 ),
986 );
987 // Wait for the child to confirm it holds the lock before probing.
988 yield* holder.stdout.pipe(
989 Stream.decodeText(),
990 Stream.splitLines,
991 Stream.filter((line) => line.trim() === "locked"),
992 Stream.take(1),
993 Stream.runDrain,
994 );
995
996 assert.isTrue(yield* isSourceRunning(firefox, context));
997 yield* holder.kill();
998 }),
999 ),
1000 );
1001
1002 it.effect("reads a Firefox lock symlink's pid to tell live from crashed", () =>
1003 Effect.gen(function* () {
1004 const alive = (pid: number) => Effect.succeed(pid === 4242);
1005 // The resolver may hand Firefox any of the machine's addresses, not
1006 // just 127.0.0.1 — 127.0.1.1 on Debian-style hosts, a LAN address
1007 // elsewhere — so every local address counts as ours.
1008 const local = new Set(["127.0.0.1", "127.0.1.1", "192.168.1.20"]);
1009 // Both the plain and the fcntl-marked (`+`) forms carry the pid.
1010 assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.0.1:4242", local, alive));
1011 assert.isTrue(yield* firefoxSymlinkLockIsHeld("127.0.1.1:+4242", local, alive));
1012 assert.isTrue(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+4242", local, alive));
1013 // A crash leaves the symlink behind with a dead pid, on any local address.
1014 assert.isFalse(yield* firefoxSymlinkLockIsHeld("127.0.0.1:+9999", local, alive));
1015 assert.isFalse(yield* firefoxSymlinkLockIsHeld("192.168.1.20:+9999", local, alive));
1016 // Anything unparseable stays conservative.
1017 assert.isTrue(yield* firefoxSymlinkLockIsHeld("garbage", local, alive));
1018 // A foreign owner (a shared profile locked from another machine) names
1019 // a pid we cannot probe, so it is held regardless of local liveness.
1020 assert.isTrue(yield* firefoxSymlinkLockIsHeld("10.0.0.7:+9999", local, alive));
1021 }),
1022 );
1023
1024 it.effect("does not treat a stale parent.lock file as a running browser", () =>
1025 run(
1026 Effect.gen(function* () {
1027 const fileSystem = yield* FileSystem.FileSystem;
1028 const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" });
1029 const context = yield* sourcePathContext.pipe(
1030 // Firefox's win32 root hangs off %APPDATA%; without it the root is
1031 // undefined and the fixture would escape the sandbox into the repo.
1032 Effect.provideService(HostProcess.Environment, {
1033 HOME: home,
1034 APPDATA: `${home}/AppData/Roaming`,
1035 }),
1036 Effect.provideService(HostProcess.Platform, "win32"),
1037 );
1038 const root = firefox.userDataDirectory(context)!;
1039 const profile = `${root}/Profiles/gx7x7fqx.default-release`;
1040 yield* fileSystem.makeDirectory(profile, { recursive: true });
1041 yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db");
1042
1043 // On Windows, Firefox creates parent.lock as a regular file that
1044 // persists after the process exits. The file is only locked while
1045 // Firefox is running; the old stat-based check always found it.
1046 yield* fileSystem.writeFileString(`${profile}/parent.lock`, "");
1047 assert.isFalse(yield* isSourceRunning(firefox, context));
1048 }),
1049 ),
1050 );
1051});
1052
1053describe("Windows user-data directories", () => {
1054 it.effect("keeps app-bound Chromium forks unsupported on win32", () =>
1055 Effect.sync(() => {
1056 // Helium retains the older DPAPI-backed store. Other Chromium forks use
1057 // App-Bound Encryption, so omitting win32 makes `unavailableReason`
1058 // report `unsupportedPlatform` and keeps them out of the menu.
1059 for (const source of BROWSER_IMPORT_SOURCES) {
1060 if (source.engine === "chromium" && source.id !== "helium") {
1061 assert.notInclude(source.platforms, "win32");
1062 }
1063 }
1064 }),
1065 );
1066});
1067
1068describe("listSourceProfiles hardening", () => {
1069 it.effect("drops profile directories that are not a single plain segment", () =>
1070 run(
1071 Effect.gen(function* () {
1072 const fileSystem = yield* FileSystem.FileSystem;
1073 const context = yield* withSourceHome();
1074 // `Local State` is writable by anything running as the user, so a
1075 // crafted key must not reach `cookieDatabasePath` and read a database
1076 // outside the browser's user-data directory.
1077 yield* fileSystem.writeFileString(
1078 `${userDataDirectory(context)}/Local State`,
1079 `{"profile":{"info_cache":{"Default":{"name":"You"},"../../../../secrets":{"name":"Escape"},"a/b":{"name":"Nested"},"..":{"name":"Parent"}}}}`,
1080 );
1081
1082 const profiles = yield* listSourceProfiles(helium, context);
1083
1084 assert.deepEqual(
1085 profiles.map((profile) => profile.directory),
1086 ["Default"],
1087 );
1088 }),
1089 ),
1090 );
1091});
1092
1093describe("Safari profiles", () => {
1094 const safari = BROWSER_IMPORT_SOURCES.find((source) => source.id === "safari")!;
1095 const workUuid = "C561D071-67AD-4537-866F-54F65FB8E8DD";
1096 const otherUuid = "2875EB19-B938-4E38-BE92-5AE97C256BDD";
1097
1098 const fixture = Effect.fnUntraced(function* () {
1099 const context = yield* withSourceHome();
1100 const fileSystem = yield* FileSystem.FileSystem;
1101 const root = safari.userDataDirectory(context)!;
1102 const library = context.path.dirname(root);
1103 yield* fileSystem.makeDirectory(root, { recursive: true });
1104 yield* fileSystem.writeFileString(context.path.join(root, "Cookies.binarycookies"), "default");
1105 const store = (uuid: string) =>
1106 context.path.join(library, "WebKit", "WebsiteDataStore", uuid.toLowerCase(), "Cookies");
1107 for (const uuid of [workUuid, otherUuid]) {
1108 yield* fileSystem.makeDirectory(store(uuid), { recursive: true });
1109 yield* fileSystem.writeFileString(
1110 context.path.join(store(uuid), "Cookies.binarycookies"),
1111 uuid,
1112 );
1113 }
1114 yield* fileSystem.makeDirectory(context.path.join(library, "Safari"), { recursive: true });
1115 const metadata = context.path.join(library, "Safari", "SafariTabs.db");
1116 return { context, root, store, metadata };
1117 });
1118
1119 it.effect("discovers named profiles and resolves only the selected profile's cookies", () =>
1120 run(
1121 Effect.gen(function* () {
1122 const { context, root, store, metadata } = yield* fixture();
1123 yield* Effect.sync(() => {
1124 const database = new NodeSqlite.DatabaseSync(metadata);
1125 try {
1126 database.exec(`CREATE TABLE bookmarks (
1127 title TEXT, external_uuid TEXT, parent INTEGER DEFAULT 0,
1128 type INTEGER DEFAULT 1, subtype INTEGER DEFAULT 2,
1129 deleted INTEGER DEFAULT 0, order_index INTEGER DEFAULT 0
1130 )`);
1131 const insert = database.prepare(
1132 "INSERT INTO bookmarks (title, external_uuid, deleted) VALUES (?, ?, ?)",
1133 );
1134 insert.run("", "DefaultProfile", 0);
1135 insert.run("Ping", workUuid, 0);
1136 insert.run("Deleted", otherUuid, 1);
1137 insert.run("Unsafe", "../../outside", 0);
1138 database.exec(
1139 "INSERT INTO bookmarks (title, external_uuid, subtype) VALUES ('Tab group', 'group', 1)",
1140 );
1141 } finally {
1142 database.close();
1143 }
1144 });
1145 const profiles = yield* listSourceProfiles(safari, context);
1146 assert.deepEqual(profiles, [
1147 { directory: ".", name: "Personal" },
1148 { directory: store(workUuid), name: "Ping" },
1149 ]);
1150 assert.strictEqual(
1151 yield* resolveCookieDatabase(safari, context, "."),
1152 context.path.join(root, "Cookies.binarycookies"),
1153 );
1154 const selected = yield* resolveCookieDatabase(safari, context, profiles[1]!.directory);
1155 assert.strictEqual(selected, context.path.join(store(workUuid), "Cookies.binarycookies"));
1156 const fileSystem = yield* FileSystem.FileSystem;
1157 assert.strictEqual(yield* fileSystem.readFileString(selected!), workUuid);
1158 yield* fileSystem.remove(selected!);
1159 assert.isUndefined(yield* resolveCookieDatabase(safari, context, profiles[1]!.directory));
1160 assert.deepEqual(yield* listSourceProfiles(safari, context), profiles);
1161 }),
1162 ),
1163 );
1164
1165 it.effect.each(["missing", "corrupt"] as const)(
1166 "recovers separate cookie stores when metadata is %s",
1167 (metadataState) =>
1168 run(
1169 Effect.gen(function* () {
1170 const { context, store, metadata } = yield* fixture();
1171 const fileSystem = yield* FileSystem.FileSystem;
1172 if (metadataState === "corrupt") yield* fileSystem.writeFileString(metadata, "invalid");
1173 yield* fileSystem.remove(context.path.join(store(otherUuid), "Cookies.binarycookies"));
1174 assert.deepEqual(yield* listSourceProfiles(safari, context), [
1175 { directory: ".", name: "Safari" },
1176 { directory: store(workUuid), name: workUuid.toLowerCase() },
1177 ]);
1178 assert.isTrue(yield* isSourceInstalled(safari, context));
1179 }),
1180 ),
1181 );
1182
1183 it.effect("keeps Safari without profiles available", () =>
1184 run(
1185 Effect.gen(function* () {
1186 const context = yield* withSourceHome();
1187 assert.deepEqual(yield* listSourceProfiles(safari, context), [
1188 { directory: ".", name: "Safari" },
1189 ]);
1190 assert.isFalse(yield* isSourceInstalled(safari, context));
1191 }),
1192 ),
1193 );
1194});