History

fix(desktop): defer keyring loading until macOS cookie import (#10667)

Abdel Abdelwaly committed 2 months ago

Commit 20e2e891 parent 7fbc5453 files · +95 −1Browse files

Unified · Split

3 files
  1. apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts +35 −0
  2. apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts +54 −0
  3. apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts +6 −1
apps/desktop/src/preview/BrowserImport/ChromiumKeys.module.test.ts+35−0
@@ -0,0 +1,35 @@
1import { expect, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import { ChildProcessSpawner } from "effect/unstable/process";
4import { vi } from "vite-plus/test";
5
6vi.mock("@napi-rs/keyring", () => {
7 throw new Error("Cannot find native binding");
8});
9
10it("loads browser import code without a keyring native binding", async () => {
11 await expect(import("./ChromiumKeys.ts")).resolves.toBeDefined();
12});
13
14it.effect("reports an unavailable keychain when the macOS binding cannot load", () =>
15 Effect.gen(function* () {
16 const { ChromiumKeyError, resolveChromiumKeys } = yield* Effect.promise(
17 () => import("./ChromiumKeys.ts"),
18 );
19 const error = yield* resolveChromiumKeys({
20 platform: "darwin",
21 keychainService: "Chrome Safe Storage",
22 keychainAccount: "Chrome",
23 linuxSecretApplication: undefined,
24 }).pipe(Effect.flip);
25
26 expect(error).toBeInstanceOf(ChromiumKeyError);
27 expect(error.reason).toBe("keychainUnavailable");
28 expect(error.cause).toBeInstanceOf(Error);
29 }).pipe(
30 Effect.provideService(
31 ChildProcessSpawner.ChildProcessSpawner,
32 ChildProcessSpawner.make(() => Effect.die("must not spawn")),
33 ),
34 ),
35);
apps/desktop/src/preview/BrowserImport/ChromiumKeys.test.ts+54−0
@@ -7,6 +7,7 @@ import * as Sink from "effect/Sink"; · 6 unmodified lines
7import * as Stream from "effect/Stream";7import * as Stream from "effect/Stream";
8import * as PlatformError from "effect/PlatformError";8import * as PlatformError from "effect/PlatformError";
9import { ChildProcessSpawner } from "effect/unstable/process";9import { ChildProcessSpawner } from "effect/unstable/process";
10import { beforeEach, vi } from "vite-plus/test";
10 11
11import {12import {
12 ChromiumKeyError,13 ChromiumKeyError,
@@ -17,6 +18,18 @@ import { · 4 unmodified lines
17} from "./ChromiumKeys.ts";18} from "./ChromiumKeys.ts";
18import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts";19import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts";
19 20
21const { getPassword } = vi.hoisted(() => ({ getPassword: vi.fn<() => string | null>() }));
22
23vi.mock("@napi-rs/keyring", () => ({
24 Entry: class {
25 getPassword = getPassword;
26 },
27}));
28
29beforeEach(() => {
30 getPassword.mockReset();
31});
32
20type CapturedCommand = {33type CapturedCommand = {
21 readonly command: string;34 readonly command: string;
22 readonly args: ReadonlyArray<string>;35 readonly args: ReadonlyArray<string>;
@@ -63,6 +76,47 @@ const helperLayer = (input: { · 40 unmodified lines
63 ),76 ),
64 );77 );
65 78
79describe("macOS Chromium secrets", () => {
80 const request = {
81 platform: "darwin",
82 keychainService: "Chrome Safe Storage",
83 keychainAccount: "Chrome",
84 linuxSecretApplication: undefined,
85 } as const;
86 const noProcesses = Layer.succeed(
87 ChildProcessSpawner.ChildProcessSpawner,
88 ChildProcessSpawner.make(() => Effect.die("must not spawn")),
89 );
90
91 it.effect("derives the cookie key from the keychain secret", () =>
92 Effect.gen(function* () {
93 getPassword.mockReturnValue("macos-secret");
94 const keys = yield* resolveChromiumKeys(request);
95 expect(keys.cbcV10?.toString("hex")).toBe("3df7306fb1eac353289565a2f6b64f74");
96 }).pipe(Effect.provide(noProcesses)),
97 );
98
99 it.effect("reports a missing keychain entry", () =>
100 Effect.gen(function* () {
101 getPassword.mockReturnValue(null);
102 const error = yield* resolveChromiumKeys(request).pipe(Effect.flip);
103 expect(error.reason).toBe("keychainItemMissing");
104 }).pipe(Effect.provide(noProcesses)),
105 );
106
107 it.effect("preserves a denied keychain approval", () =>
108 Effect.gen(function* () {
109 const denied = new Error("User denied access");
110 getPassword.mockImplementation(() => {
111 throw denied;
112 });
113 const error = yield* resolveChromiumKeys(request).pipe(Effect.flip);
114 expect(error.reason).toBe("needsKeychainApproval");
115 expect(error.cause).toBe(denied);
116 }).pipe(Effect.provide(noProcesses)),
117 );
118});
119
66describe("Linux Chromium secrets", () => {120describe("Linux Chromium secrets", () => {
67 it.effect("retains a missing helper failure alongside the keyring-free fallback", () =>121 it.effect("retains a missing helper failure alongside the keyring-free fallback", () =>
68 Effect.gen(function* () {122 Effect.gen(function* () {
apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts+6−1
@@ -17,7 +17,6 @@ · 16 unmodified lines
17 *17 *
18 * @module ChromiumKeys18 * @module ChromiumKeys
19 */19 */
20import * as Keyring from "@napi-rs/keyring";
21import * as NodeCrypto from "node:crypto";20import * as NodeCrypto from "node:crypto";
22 21
23import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";22import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
@@ -102,6 +101,12 @@ const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function · 78 unmodified lines
102 service: string,101 service: string,
103 account: string,102 account: string,
104) {103) {
104 // Only macOS cookie imports need this binding; loading it at startup can
105 // prevent the desktop from opening on platforms that never use it.
106 const Keyring = yield* Effect.tryPromise({
107 try: () => import("@napi-rs/keyring"),
108 catch: (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }),
109 });
105 const secret = yield* Effect.try({110 const secret = yield* Effect.try({
106 try: () => new Keyring.Entry(service, account).getPassword(),111 try: () => new Keyring.Entry(service, account).getPassword(),
107 catch: (cause) => {112 catch: (cause) => {