| author | |
| committer | |
| log | 3653cb22ffb30bf133fecc30c955941fe267cf1c |
| tree | b99c71cbafa2738953d1c9d6fc278f1df7afc202 |
| parent | 8ea52c8f2f4361e875d72ab30d72d37cfae4d87f |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com>9 files changed, 385 insertions(+), 26 deletions(-)
apps/desktop/src/preview/BrowserImport/BrowserImport.ts+4-7| ... | @@ -206,13 +206,10 @@ export const make = Effect.gen(function* BrowserImportMake() { | ... | @@ -206,13 +206,10 @@ export const make = Effect.gen(function* BrowserImportMake() { |
| 206 | return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked }); | 206 | return yield* new BrowserImportFailedError({ sourceId: definition.id, reason: blocked }); |
| 207 | } | 207 | } |
| 208 | 208 | ||
| 209 | // macOS attributes the Keychain prompt and the resulting ACL grant to the | 209 | if (platform === "darwin" && definition.engine === "chromium") { |
| 210 | // executable that asks, so record which one that was — in a packaged build | 210 | // macOS attributes the Keychain prompt and the resulting ACL grant to the |
| 211 | // it is the signed app, in dev whatever binary hosts the main process. | 211 | // executable that asks, so record which one that was — in a packaged build |
| 212 | // Only Chromium reads a key; a Firefox import touches no keychain, and | 212 | // it is the signed app, in dev whatever binary hosts the main process. |
| 213 | // logging that it did would put a false security-sensitive event in the | ||
| 214 | // audit trail. | ||
| 215 | if (definition.engine === "chromium") { | ||
| 216 | yield* Effect.logInfo("Reading browser cookie key from the keychain", { | 213 | yield* Effect.logInfo("Reading browser cookie key from the keychain", { |
| 217 | sourceId: definition.id, | 214 | sourceId: definition.id, |
| 218 | executablePath, | 215 | executablePath, |
apps/desktop/src/preview/BrowserImport/ChromiumCookies.test.ts+2-2| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Encrypts a fixture with the same | 1 | // @effect-diagnostics nodeBuiltinImport:off - Encrypts fixtures with the same |
| 2 | // OSCrypt primitive as Chromium. | 2 | // OSCrypt primitives the module under test decrypts. |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import { describe, expect, it } from "@effect/vitest"; | 4 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; | 5 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; |
apps/desktop/src/preview/BrowserImport/Sources.test.ts+221| ... | @@ -10,6 +10,7 @@ import { | ... | @@ -10,6 +10,7 @@ import { |
| 10 | import * as Effect from "effect/Effect"; | 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as FileSystem from "effect/FileSystem"; | 11 | import * as FileSystem from "effect/FileSystem"; |
| 12 | import * as Path from "effect/Path"; | 12 | import * as Path from "effect/Path"; |
| 13 | import * as PlatformError from "effect/PlatformError"; | ||
| 13 | import * as Scope from "effect/Scope"; | 14 | import * as Scope from "effect/Scope"; |
| 14 | import * as Stream from "effect/Stream"; | 15 | import * as Stream from "effect/Stream"; |
| 15 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 16 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| ... | @@ -25,6 +26,7 @@ import { | ... | @@ -25,6 +26,7 @@ import { |
| 25 | resolveCookieDatabase, | 26 | resolveCookieDatabase, |
| 26 | isSourceInstalled, | 27 | isSourceInstalled, |
| 27 | isSourceRunning, | 28 | isSourceRunning, |
| 29 | isWindowsLockHeldError, | ||
| 28 | posixLockIsHeld, | 30 | posixLockIsHeld, |
| 29 | listSourceProfiles, | 31 | listSourceProfiles, |
| 30 | sourcePathContext, | 32 | sourcePathContext, |
| ... | @@ -54,6 +56,23 @@ describe("Linux Chromium secret applications", () => { | ... | @@ -54,6 +56,23 @@ describe("Linux Chromium secret applications", () => { |
| 54 | }); | 56 | }); |
| 55 | }); | 57 | }); |
| 56 | 58 | ||
| 59 | const platformError = (reasonTag: string): PlatformError.PlatformError => | ||
| 60 | ({ _tag: "PlatformError", reason: { _tag: reasonTag } }) as never; | ||
| 61 | |||
| 62 | describe("Windows browser lock errors", () => { | ||
| 63 | it("treats sharing and lock violations reported as Busy as held", () => { | ||
| 64 | assert.isTrue(isWindowsLockHeldError(platformError("Busy"))); | ||
| 65 | }); | ||
| 66 | |||
| 67 | it("does not treat access denied as proof of an active lock", () => { | ||
| 68 | assert.isFalse(isWindowsLockHeldError(platformError("PermissionDenied"))); | ||
| 69 | }); | ||
| 70 | |||
| 71 | it("does not treat a missing lock file as held", () => { | ||
| 72 | assert.isFalse(isWindowsLockHeldError(platformError("NotFound"))); | ||
| 73 | }); | ||
| 74 | }); | ||
| 75 | |||
| 57 | /** A scratch home with the source's user-data directory already created. */ | 76 | /** A scratch home with the source's user-data directory already created. */ |
| 58 | const withSourceHome = Effect.fnUntraced(function* () { | 77 | const withSourceHome = Effect.fnUntraced(function* () { |
| 59 | const fileSystem = yield* FileSystem.FileSystem; | 78 | const fileSystem = yield* FileSystem.FileSystem; |
| ... | @@ -352,6 +371,20 @@ describe("isSourceInstalled", () => { | ... | @@ -352,6 +371,20 @@ describe("isSourceInstalled", () => { |
| 352 | ), | 371 | ), |
| 353 | ); | 372 | ); |
| 354 | 373 | ||
| 374 | it.effect("detects a Chromium 127+ install with cookies under Network/", () => | ||
| 375 | run( | ||
| 376 | Effect.gen(function* () { | ||
| 377 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 378 | const context = yield* withSourceHome(); | ||
| 379 | const root = userDataDirectory(context); | ||
| 380 | |||
| 381 | yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true }); | ||
| 382 | yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "db"); | ||
| 383 | assert.isTrue(yield* isSourceInstalled(helium, context)); | ||
| 384 | }), | ||
| 385 | ), | ||
| 386 | ); | ||
| 387 | |||
| 355 | it.effect("follows cookie database symlinks when detecting profiles", () => | 388 | it.effect("follows cookie database symlinks when detecting profiles", () => |
| 356 | run( | 389 | run( |
| 357 | Effect.gen(function* () { | 390 | Effect.gen(function* () { |
| ... | @@ -460,6 +493,79 @@ describe("listSourceProfiles", () => { | ... | @@ -460,6 +493,79 @@ describe("listSourceProfiles", () => { |
| 460 | ), | 493 | ), |
| 461 | ); | 494 | ); |
| 462 | 495 | ||
| 496 | it.effect("drops Firefox profiles that hold no cookie database", () => | ||
| 497 | run( | ||
| 498 | Effect.gen(function* () { | ||
| 499 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 500 | const context = yield* withSourceHome(); | ||
| 501 | const root = firefox.userDataDirectory(context)!; | ||
| 502 | yield* fileSystem.makeDirectory(root, { recursive: true }); | ||
| 503 | yield* fileSystem.writeFileString( | ||
| 504 | `${root}/profiles.ini`, | ||
| 505 | `[Profile0] | ||
| 506 | Name=original | ||
| 507 | IsRelative=1 | ||
| 508 | Path=Profiles/abcd.default-release | ||
| 509 | Default=1 | ||
| 510 | |||
| 511 | [Profile1] | ||
| 512 | Name=empty | ||
| 513 | IsRelative=1 | ||
| 514 | Path=Profiles/wxyz.empty | ||
| 515 | `, | ||
| 516 | ); | ||
| 517 | yield* fileSystem.makeDirectory(`${root}/Profiles/abcd.default-release`, { | ||
| 518 | recursive: true, | ||
| 519 | }); | ||
| 520 | yield* fileSystem.writeFileString( | ||
| 521 | `${root}/Profiles/abcd.default-release/cookies.sqlite`, | ||
| 522 | "db", | ||
| 523 | ); | ||
| 524 | yield* fileSystem.makeDirectory(`${root}/Profiles/wxyz.empty`, { recursive: true }); | ||
| 525 | |||
| 526 | assert.deepEqual(yield* listSourceProfiles(firefox, context), [ | ||
| 527 | { directory: "Profiles/abcd.default-release", name: "original" }, | ||
| 528 | ]); | ||
| 529 | }), | ||
| 530 | ), | ||
| 531 | ); | ||
| 532 | |||
| 533 | it.effect("drops empty profiles when falling back to the Profiles/ scan", () => | ||
| 534 | run( | ||
| 535 | Effect.gen(function* () { | ||
| 536 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 537 | const context = yield* withSourceHome(); | ||
| 538 | const root = firefox.userDataDirectory(context)!; | ||
| 539 | yield* fileSystem.makeDirectory(`${root}/Profiles/filled.default`, { recursive: true }); | ||
| 540 | yield* fileSystem.writeFileString(`${root}/Profiles/filled.default/cookies.sqlite`, "db"); | ||
| 541 | yield* fileSystem.makeDirectory(`${root}/Profiles/empty.default`, { recursive: true }); | ||
| 542 | |||
| 543 | assert.deepEqual(yield* listSourceProfiles(firefox, context), [ | ||
| 544 | { | ||
| 545 | directory: context.path.join("Profiles", "filled.default"), | ||
| 546 | name: "filled.default", | ||
| 547 | }, | ||
| 548 | ]); | ||
| 549 | }), | ||
| 550 | ), | ||
| 551 | ); | ||
| 552 | |||
| 553 | it.effect("discovers profiles with cookies under Network/ (Chromium 127+)", () => | ||
| 554 | run( | ||
| 555 | Effect.gen(function* () { | ||
| 556 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 557 | const context = yield* withSourceHome(); | ||
| 558 | const root = userDataDirectory(context); | ||
| 559 | yield* fileSystem.makeDirectory(`${root}/Default/Network`, { recursive: true }); | ||
| 560 | yield* fileSystem.writeFileString(`${root}/Default/Network/Cookies`, "db"); | ||
| 561 | |||
| 562 | assert.deepEqual(yield* listSourceProfiles(helium, context), [ | ||
| 563 | { directory: "Default", name: "Default" }, | ||
| 564 | ]); | ||
| 565 | }), | ||
| 566 | ), | ||
| 567 | ); | ||
| 568 | |||
| 463 | it.effect("counts a profile's cookies without decrypting them", () => | 569 | it.effect("counts a profile's cookies without decrypting them", () => |
| 464 | run( | 570 | run( |
| 465 | Effect.gen(function* () { | 571 | Effect.gen(function* () { |
| ... | @@ -474,6 +580,24 @@ describe("listSourceProfiles", () => { | ... | @@ -474,6 +580,24 @@ describe("listSourceProfiles", () => { |
| 474 | }), | 580 | }), |
| 475 | ), | 581 | ), |
| 476 | ); | 582 | ); |
| 583 | |||
| 584 | it.effect("falls through to the legacy database when Network/Cookies is a directory", () => | ||
| 585 | run( | ||
| 586 | Effect.gen(function* () { | ||
| 587 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 588 | const paths = yield* withSourceHome(); | ||
| 589 | const root = helium.userDataDirectory(paths); | ||
| 590 | // A folder squatting on the preferred candidate path must not shadow | ||
| 591 | // the real legacy database behind it. | ||
| 592 | yield* fileSystem.makeDirectory(`${root}/Default/Network/Cookies`, { recursive: true }); | ||
| 593 | yield* writeCookieDatabase(`${root}/Default/Cookies`, 2); | ||
| 594 | |||
| 595 | const [profile] = yield* listSourceProfiles(helium, paths); | ||
| 596 | assert.equal(profile?.directory, "Default"); | ||
| 597 | assert.equal(profile?.cookieCount, 2); | ||
| 598 | }), | ||
| 599 | ), | ||
| 600 | ); | ||
| 477 | }); | 601 | }); |
| 478 | 602 | ||
| 479 | describe("cookieDatabaseCandidatePaths", () => { | 603 | describe("cookieDatabaseCandidatePaths", () => { |
| ... | @@ -512,6 +636,25 @@ describe("cookieDatabaseCandidatePaths", () => { | ... | @@ -512,6 +636,25 @@ describe("cookieDatabaseCandidatePaths", () => { |
| 512 | }), | 636 | }), |
| 513 | ), | 637 | ), |
| 514 | ); | 638 | ); |
| 639 | |||
| 640 | it.effect("returns only cookies.sqlite for Firefox", () => | ||
| 641 | run( | ||
| 642 | Effect.gen(function* () { | ||
| 643 | const path = yield* Path.Path; | ||
| 644 | const context = yield* sourcePathContext.pipe( | ||
| 645 | Effect.provideService(HostProcessEnvironment, { HOME: "/tmp/test" }), | ||
| 646 | Effect.provideService(HostProcessPlatform, "darwin"), | ||
| 647 | ); | ||
| 648 | const candidates = cookieDatabaseCandidatePaths(firefox, context, "Profiles/abc.default"); | ||
| 649 | assert.deepEqual(candidates, [ | ||
| 650 | path.join( | ||
| 651 | "/tmp/test", | ||
| 652 | "Library/Application Support/Firefox/Profiles/abc.default/cookies.sqlite", | ||
| 653 | ), | ||
| 654 | ]); | ||
| 655 | }), | ||
| 656 | ), | ||
| 657 | ); | ||
| 515 | }); | 658 | }); |
| 516 | 659 | ||
| 517 | const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!; | 660 | const firefox = BROWSER_IMPORT_SOURCES.find((source) => source.id === "firefox")!; |
| ... | @@ -642,6 +785,41 @@ describe("listSourceProfiles Firefox fallback", () => { | ... | @@ -642,6 +785,41 @@ describe("listSourceProfiles Firefox fallback", () => { |
| 642 | ); | 785 | ); |
| 643 | } | 786 | } |
| 644 | 787 | ||
| 788 | it.effect("scans for profiles when profiles.ini declares only ones without cookies", () => | ||
| 789 | run( | ||
| 790 | Effect.gen(function* () { | ||
| 791 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 792 | const path = yield* Path.Path; | ||
| 793 | const home = yield* fileSystem.makeTempDirectoryScoped({ | ||
| 794 | prefix: "t3code-firefox-stale-ini-", | ||
| 795 | }); | ||
| 796 | const context = yield* sourcePathContext.pipe( | ||
| 797 | Effect.provideService(HostProcessEnvironment, { HOME: home }), | ||
| 798 | Effect.provideService(HostProcessPlatform, "darwin"), | ||
| 799 | ); | ||
| 800 | const root = firefox.userDataDirectory(context)!; | ||
| 801 | // `profiles.ini` names a profile that was never launched (no cookie | ||
| 802 | // database), while the real cookies sit in an undeclared one. | ||
| 803 | yield* fileSystem.makeDirectory(path.join(root, "Profiles", "stale.default"), { | ||
| 804 | recursive: true, | ||
| 805 | }); | ||
| 806 | const realDirectory = path.join(root, "Profiles", "real.default"); | ||
| 807 | yield* fileSystem.makeDirectory(realDirectory, { recursive: true }); | ||
| 808 | yield* writeFirefoxCookieDatabase(path.join(realDirectory, "cookies.sqlite"), 3, 0); | ||
| 809 | yield* fileSystem.writeFileString( | ||
| 810 | path.join(root, "profiles.ini"), | ||
| 811 | ["[Profile0]", "Name=Stale", "IsRelative=1", "Path=Profiles/stale.default"].join("\n"), | ||
| 812 | ); | ||
| 813 | |||
| 814 | // Returning the empty declared list would hide the browser entirely. | ||
| 815 | assert.deepEqual(yield* listSourceProfiles(firefox, context), [ | ||
| 816 | { directory: "Profiles/real.default", name: "real.default", cookieCount: 3 }, | ||
| 817 | ]); | ||
| 818 | assert.isTrue(yield* isSourceInstalled(firefox, context)); | ||
| 819 | }), | ||
| 820 | ), | ||
| 821 | ); | ||
| 822 | |||
| 645 | it.effect("counts only importable cookies for declared and fallback profiles", () => | 823 | it.effect("counts only importable cookies for declared and fallback profiles", () => |
| 646 | run( | 824 | run( |
| 647 | Effect.gen(function* () { | 825 | Effect.gen(function* () { |
| ... | @@ -809,6 +987,49 @@ describe("isSourceRunning for Firefox", () => { | ... | @@ -809,6 +987,49 @@ describe("isSourceRunning for Firefox", () => { |
| 809 | assert.isTrue(yield* firefoxSymlinkLockIsHeld("10.0.0.7:+9999", local, alive)); | 987 | assert.isTrue(yield* firefoxSymlinkLockIsHeld("10.0.0.7:+9999", local, alive)); |
| 810 | }), | 988 | }), |
| 811 | ); | 989 | ); |
| 990 | |||
| 991 | it.effect("does not treat a stale parent.lock file as a running browser", () => | ||
| 992 | run( | ||
| 993 | Effect.gen(function* () { | ||
| 994 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 995 | const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-firefox-" }); | ||
| 996 | const context = yield* sourcePathContext.pipe( | ||
| 997 | // Firefox's win32 root hangs off %APPDATA%; without it the root is | ||
| 998 | // undefined and the fixture would escape the sandbox into the repo. | ||
| 999 | Effect.provideService(HostProcessEnvironment, { | ||
| 1000 | HOME: home, | ||
| 1001 | APPDATA: `${home}/AppData/Roaming`, | ||
| 1002 | }), | ||
| 1003 | Effect.provideService(HostProcessPlatform, "win32"), | ||
| 1004 | ); | ||
| 1005 | const root = firefox.userDataDirectory(context)!; | ||
| 1006 | const profile = `${root}/Profiles/gx7x7fqx.default-release`; | ||
| 1007 | yield* fileSystem.makeDirectory(profile, { recursive: true }); | ||
| 1008 | yield* fileSystem.writeFileString(`${profile}/cookies.sqlite`, "db"); | ||
| 1009 | |||
| 1010 | // On Windows, Firefox creates parent.lock as a regular file that | ||
| 1011 | // persists after the process exits. The file is only locked while | ||
| 1012 | // Firefox is running; the old stat-based check always found it. | ||
| 1013 | yield* fileSystem.writeFileString(`${profile}/parent.lock`, ""); | ||
| 1014 | assert.isFalse(yield* isSourceRunning(firefox, context)); | ||
| 1015 | }), | ||
| 1016 | ), | ||
| 1017 | ); | ||
| 1018 | }); | ||
| 1019 | |||
| 1020 | describe("Windows user-data directories", () => { | ||
| 1021 | it.effect("keeps app-bound Chromium forks unsupported on win32", () => | ||
| 1022 | Effect.sync(() => { | ||
| 1023 | // Helium retains the older DPAPI-backed store. Other Chromium forks use | ||
| 1024 | // App-Bound Encryption, so omitting win32 makes `unavailableReason` | ||
| 1025 | // report `unsupportedPlatform` and keeps them out of the menu. | ||
| 1026 | for (const source of BROWSER_IMPORT_SOURCES) { | ||
| 1027 | if (source.engine === "chromium" && source.id !== "helium") { | ||
| 1028 | assert.notInclude(source.platforms, "win32"); | ||
| 1029 | } | ||
| 1030 | } | ||
| 1031 | }), | ||
| 1032 | ); | ||
| 812 | }); | 1033 | }); |
| 813 | 1034 | ||
| 814 | describe("listSourceProfiles hardening", () => { | 1035 | describe("listSourceProfiles hardening", () => { |
apps/desktop/src/preview/BrowserImport/Sources.ts+47-10| ... | @@ -109,6 +109,10 @@ const chromiumSource = (input: { | ... | @@ -109,6 +109,10 @@ const chromiumSource = (input: { |
| 109 | }); | 109 | }); |
| 110 | 110 | ||
| 111 | export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [ | 111 | export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition> = [ |
| 112 | // No Chromium fork is importable on Windows: since Chrome 127 their cookies | ||
| 113 | // are encrypted to the browser's own identity (App-Bound Encryption), so no | ||
| 114 | // other process can read them. macOS and Linux keep working, so only the | ||
| 115 | // Windows segments are omitted. | ||
| 112 | chromiumSource({ | 116 | chromiumSource({ |
| 113 | id: "chrome", | 117 | id: "chrome", |
| 114 | name: "Chrome", | 118 | name: "Chrome", |
| ... | @@ -198,6 +202,10 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition | ... | @@ -198,6 +202,10 @@ export const BROWSER_IMPORT_SOURCES: ReadonlyArray<BrowserImportSourceDefinition |
| 198 | * and a fresh install with only `Network/Cookies` would read as not installed. | 202 | * and a fresh install with only `Network/Cookies` would read as not installed. |
| 199 | * Firefox uses `cookies.sqlite`, and its profile paths from `profiles.ini` | 203 | * Firefox uses `cookies.sqlite`, and its profile paths from `profiles.ini` |
| 200 | * may already be absolute. | 204 | * may already be absolute. |
| 205 | * | ||
| 206 | * Chrome 96+ moved network-related files (including Cookies) into a `Network` | ||
| 207 | * subdirectory for sandboxing. The candidate list includes both locations so | ||
| 208 | * callers tolerate fresh and legacy installs alike. | ||
| 201 | */ | 209 | */ |
| 202 | export const cookieDatabaseCandidatePaths = ( | 210 | export const cookieDatabaseCandidatePaths = ( |
| 203 | definition: BrowserImportSourceDefinition, | 211 | definition: BrowserImportSourceDefinition, |
| ... | @@ -206,11 +214,18 @@ export const cookieDatabaseCandidatePaths = ( | ... | @@ -206,11 +214,18 @@ export const cookieDatabaseCandidatePaths = ( |
| 206 | ): ReadonlyArray<string> => { | 214 | ): ReadonlyArray<string> => { |
| 207 | const root = definition.userDataDirectory(context); | 215 | const root = definition.userDataDirectory(context); |
| 208 | if (root === undefined) return []; | 216 | if (root === undefined) return []; |
| 209 | const profile = context.path.isAbsolute(profileDirectory) | 217 | const profilePath = context.path.isAbsolute(profileDirectory) |
| 210 | ? profileDirectory | 218 | ? profileDirectory |
| 211 | : context.path.join(root, profileDirectory); | 219 | : context.path.join(root, profileDirectory); |
| 212 | if (definition.engine === "firefox") return [context.path.join(profile, "cookies.sqlite")]; | 220 | if (definition.engine === "firefox") { |
| 213 | return [context.path.join(profile, "Network", "Cookies"), context.path.join(profile, "Cookies")]; | 221 | return [context.path.join(profilePath, "cookies.sqlite")]; |
| 222 | } | ||
| 223 | // Chromium: pre-96 uses `Cookies`, 96+ use `Network/Cookies`. An upgrade | ||
| 224 | // leaves the legacy file behind, so prefer the current one and fall back. | ||
| 225 | return [ | ||
| 226 | context.path.join(profilePath, "Network", "Cookies"), | ||
| 227 | context.path.join(profilePath, "Cookies"), | ||
| 228 | ]; | ||
| 214 | }; | 229 | }; |
| 215 | 230 | ||
| 216 | /** The first candidate that is a regular file, or undefined when none is. */ | 231 | /** The first candidate that is a regular file, or undefined when none is. */ |
| ... | @@ -393,16 +408,35 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( | ... | @@ -393,16 +408,35 @@ const listSourceProfilesInDirectory = Effect.fnUntraced(function* ( |
| 393 | Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)), | 408 | Effect.map((ini) => parseFirefoxProfiles(ini, context.path, root)), |
| 394 | Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>), | 409 | Effect.orElseSucceed(() => [] as ReadonlyArray<BrowserImportSourceProfile>), |
| 395 | ); | 410 | ); |
| 396 | if (declared.length > 0) return yield* withCookieCounts(definition, context, declared); | 411 | // `profiles.ini` also lists profiles the installer created but the user |
| 412 | // never launched, which hold no cookie database and nothing to import. | ||
| 413 | // Only keep the ones a database proves exist, like the directory scans | ||
| 414 | // below do. When none of the declared profiles has one, fall through to | ||
| 415 | // the scan rather than returning empty: `profiles.ini` can list stale or | ||
| 416 | // never-launched profiles while the cookies live in one it does not | ||
| 417 | // mention, and an empty answer here hides the browser entirely. | ||
| 418 | if (declared.length > 0) { | ||
| 419 | const found = yield* Effect.forEach(declared, (profile) => | ||
| 420 | Effect.forEach( | ||
| 421 | cookieDatabaseCandidatePaths(definition, context, profile.directory), | ||
| 422 | (candidate) => databaseFileExists(candidate), | ||
| 423 | ).pipe(Effect.map((results) => (results.some(Boolean) ? profile : undefined))), | ||
| 424 | ); | ||
| 425 | const withDatabase = found.filter((profile) => profile !== undefined); | ||
| 426 | if (withDatabase.length > 0) { | ||
| 427 | return yield* withCookieCounts(definition, context, withDatabase); | ||
| 428 | } | ||
| 429 | } | ||
| 397 | 430 | ||
| 398 | // Linux keeps profile directories directly under the Firefox root. macOS | 431 | // No usable `profiles.ini`, so fall back to scanning the directory the |
| 399 | // and Windows put them in `Profiles/`. | 432 | // profiles actually live in, keeping only the ones a cookie database |
| 433 | // proves were launched. | ||
| 400 | const fallbackDirectory = | 434 | const fallbackDirectory = |
| 401 | context.platform === "linux" ? root : context.path.join(root, "Profiles"); | 435 | context.platform === "linux" ? root : context.path.join(root, "Profiles"); |
| 402 | const entries = yield* fileSystem | 436 | const scanned = yield* fileSystem |
| 403 | .readDirectory(fallbackDirectory) | 437 | .readDirectory(fallbackDirectory) |
| 404 | .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>)); | 438 | .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>)); |
| 405 | const found = yield* Effect.forEach(entries, (entry) => { | 439 | const found = yield* Effect.forEach(scanned, (entry) => { |
| 406 | const directory = context.platform === "linux" ? entry : context.path.join("Profiles", entry); | 440 | const directory = context.platform === "linux" ? entry : context.path.join("Profiles", entry); |
| 407 | return resolveCookieDatabase(definition, context, directory).pipe( | 441 | return resolveCookieDatabase(definition, context, directory).pipe( |
| 408 | Effect.map((database) => (database === undefined ? undefined : { directory, name: entry })), | 442 | Effect.map((database) => (database === undefined ? undefined : { directory, name: entry })), |
| ... | @@ -739,8 +773,11 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") | ... | @@ -739,8 +773,11 @@ export const isSourceRunning = Effect.fn("BrowserImportSources.isSourceRunning") |
| 739 | const root = definition.userDataDirectory(context); | 773 | const root = definition.userDataDirectory(context); |
| 740 | if (root === undefined) return false; | 774 | if (root === undefined) return false; |
| 741 | // Probe the source's own lock state rather than scanning the process table. | 775 | // Probe the source's own lock state rather than scanning the process table. |
| 742 | // Chromium exposes that through the cookie jar on Windows and through its | 776 | // Chromium exposes its lock through the cookie jar on Windows and through a |
| 743 | // user-data SingletonLock on POSIX. | 777 | // user-data SingletonLock on POSIX. Firefox keeps its locks inside each |
| 778 | // profile under three names across platforms (`lock` on macOS and Linux, | ||
| 779 | // `.parentlock` beside it, `parent.lock` on Windows). Looking for Firefox's | ||
| 780 | // at the root finds nothing and reports a running browser as importable. | ||
| 744 | if (definition.engine !== "firefox") { | 781 | if (definition.engine !== "firefox") { |
| 745 | if (context.platform === "win32") { | 782 | if (context.platform === "win32") { |
| 746 | return yield* windowsChromiumCookiesAreHeld(definition, context); | 783 | return yield* windowsChromiumCookiesAreHeld(definition, context); |
apps/web/src/components/settings/IntegrationsSettings.tsx+5-4| ... | @@ -781,10 +781,11 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { | ... | @@ -781,10 +781,11 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { |
| 781 | setProfilePendingRemoval(null); | 781 | setProfilePendingRemoval(null); |
| 782 | }; | 782 | }; |
| 783 | 783 | ||
| 784 | // A browser that is not on this machine, or that this platform cannot import | 784 | // A browser that is not on this machine is left out rather than listed as a |
| 785 | // from at all, is left out rather than listed as a dead row: there is nothing | 785 | // dead row: there is nothing to act on, and the menu is a list of things you |
| 786 | // to act on, and the menu is a list of things you can import from. Every | 786 | // can import from. An unsupported one is left out for the same reason — the |
| 787 | // other unavailable reason stays, since each names a step the user can take. | 787 | // blocked wizard step can't be fixed from here. Every other unavailable |
| 788 | // reason stays, since each names a step the user can take. | ||
| 788 | const importableSources = (sources ?? []).filter( | 789 | const importableSources = (sources ?? []).filter( |
| 789 | (source) => | 790 | (source) => |
| 790 | source.unavailable !== "notInstalled" && source.unavailable !== "unsupportedPlatform", | 791 | source.unavailable !== "notInstalled" && source.unavailable !== "unsupportedPlatform", |
native/browser-secret/main.c+4-1| ... | @@ -5,9 +5,12 @@ | ... | @@ -5,9 +5,12 @@ |
| 5 | * with no newline or other framing that could change the derived cookie key. */ | 5 | * with no newline or other framing that could change the derived cookie key. */ |
| 6 | enum { KEY_MISSING = 2, KEY_LOCKED = 3, READ_FAILED = 4 }; | 6 | enum { KEY_MISSING = 2, KEY_LOCKED = 3, READ_FAILED = 4 }; |
| 7 | 7 | ||
| 8 | /* Chromium looks its key up by attributes alone: items written by older | ||
| 9 | * libgnome-keyring builds carry no schema name, and SECRET_SCHEMA_NONE would | ||
| 10 | * refuse them. DONT_MATCH_NAME keeps the attribute match and drops the name. */ | ||
| 8 | static const SecretSchema chromium_schema = { | 11 | static const SecretSchema chromium_schema = { |
| 9 | .name = "chrome_libsecret_os_crypt_password_v2", | 12 | .name = "chrome_libsecret_os_crypt_password_v2", |
| 10 | .flags = SECRET_SCHEMA_NONE, | 13 | .flags = SECRET_SCHEMA_DONT_MATCH_NAME, |
| 11 | .attributes = {{"application", SECRET_SCHEMA_ATTRIBUTE_STRING}, {NULL, 0}}, | 14 | .attributes = {{"application", SECRET_SCHEMA_ATTRIBUTE_STRING}, {NULL, 0}}, |
| 12 | }; | 15 | }; |
| 13 | 16 |
native/browser-secret/test.c+1-1| ... | @@ -11,7 +11,7 @@ GList *__wrap_secret_service_search_sync(SecretService *service, const SecretSch | ... | @@ -11,7 +11,7 @@ GList *__wrap_secret_service_search_sync(SecretService *service, const SecretSch |
| 11 | g_assert_null(service); | 11 | g_assert_null(service); |
| 12 | g_assert_null(cancellable); | 12 | g_assert_null(cancellable); |
| 13 | g_assert_cmpstr(schema->name, ==, "chrome_libsecret_os_crypt_password_v2"); | 13 | g_assert_cmpstr(schema->name, ==, "chrome_libsecret_os_crypt_password_v2"); |
| 14 | g_assert_cmpint(schema->flags, ==, SECRET_SCHEMA_NONE); | 14 | g_assert_cmpint(schema->flags, ==, SECRET_SCHEMA_DONT_MATCH_NAME); |
| 15 | g_assert_cmpint(flags, ==, SECRET_SEARCH_UNLOCK | SECRET_SEARCH_LOAD_SECRETS); | 15 | g_assert_cmpint(flags, ==, SECRET_SEARCH_UNLOCK | SECRET_SEARCH_LOAD_SECRETS); |
| 16 | g_assert_cmpuint(g_hash_table_size(attributes), ==, 1); | 16 | g_assert_cmpuint(g_hash_table_size(attributes), ==, 1); |
| 17 | scenario = g_intern_string(g_hash_table_lookup(attributes, "application")); | 17 | scenario = g_intern_string(g_hash_table_lookup(attributes, "application")); |
scripts/build-desktop-artifact.test.ts+80| ... | @@ -66,6 +66,8 @@ import { | ... | @@ -66,6 +66,8 @@ import { |
| 66 | STAGE_INSTALL_ARGS, | 66 | STAGE_INSTALL_ARGS, |
| 67 | ancestorNodeModulesPaths, | 67 | ancestorNodeModulesPaths, |
| 68 | copyDirectoryPreservingSymlinks, | 68 | copyDirectoryPreservingSymlinks, |
| 69 | LinuxBrowserSecretHostError, | ||
| 70 | stageBrowserSecret, | ||
| 69 | validateWindowsPackagedPayload, | 71 | validateWindowsPackagedPayload, |
| 70 | WindowsPrimaryNativeProbeError, | 72 | WindowsPrimaryNativeProbeError, |
| 71 | WindowsDesktopBuildPrerequisitesMissingError, | 73 | WindowsDesktopBuildPrerequisitesMissingError, |
| ... | @@ -1207,6 +1209,84 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { | ... | @@ -1207,6 +1209,84 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1207 | ); | 1209 | ); |
| 1208 | }); | 1210 | }); |
| 1209 | 1211 | ||
| 1212 | it.effect("builds the Linux browser secret helper for a concrete architecture", () => { | ||
| 1213 | const commands: Array<{ readonly command: string; readonly args: ReadonlyArray<string> }> = []; | ||
| 1214 | const spawnerLayer = Layer.succeed( | ||
| 1215 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 1216 | ChildProcessSpawner.make((command) => { | ||
| 1217 | commands.push(command as unknown as (typeof commands)[number]); | ||
| 1218 | return Effect.succeed(mockProcess(0)); | ||
| 1219 | }), | ||
| 1220 | ); | ||
| 1221 | |||
| 1222 | return Effect.gen(function* () { | ||
| 1223 | // `universal` is a mac-only arch the option type still admits. The helper | ||
| 1224 | // script only knows x64 and arm64, so the request maps to x64, the same | ||
| 1225 | // concrete target the Linux resource monitor resolves it to. | ||
| 1226 | yield* stageBrowserSecret({ | ||
| 1227 | repoRoot: "/repo", | ||
| 1228 | stageResourcesDir: "/stage/resources", | ||
| 1229 | platform: "linux", | ||
| 1230 | arch: "universal", | ||
| 1231 | verbose: false, | ||
| 1232 | }); | ||
| 1233 | const helper = commands.find((command) => | ||
| 1234 | command.args.some((arg) => arg.endsWith("build-browser-secret.mjs")), | ||
| 1235 | ); | ||
| 1236 | assert.isDefined(helper); | ||
| 1237 | assert.deepStrictEqual(helper.args.slice(-4), [ | ||
| 1238 | "--arch", | ||
| 1239 | "x64", | ||
| 1240 | "--output", | ||
| 1241 | "/stage/resources/browser-secret/t3-browser-secret", | ||
| 1242 | ]); | ||
| 1243 | }).pipe( | ||
| 1244 | Effect.provide( | ||
| 1245 | Layer.mergeAll( | ||
| 1246 | spawnerLayer, | ||
| 1247 | Layer.succeed(HostProcessPlatform, "linux"), | ||
| 1248 | Layer.succeed(HostProcessArchitecture, "x64"), | ||
| 1249 | ), | ||
| 1250 | ), | ||
| 1251 | ); | ||
| 1252 | }); | ||
| 1253 | |||
| 1254 | it.effect("refuses a Linux build on a host that cannot build the browser secret helper", () => { | ||
| 1255 | const commands: Array<{ readonly command: string }> = []; | ||
| 1256 | const spawnerLayer = Layer.succeed( | ||
| 1257 | ChildProcessSpawner.ChildProcessSpawner, | ||
| 1258 | ChildProcessSpawner.make((command) => { | ||
| 1259 | commands.push(command as unknown as (typeof commands)[number]); | ||
| 1260 | return Effect.succeed(mockProcess(0)); | ||
| 1261 | }), | ||
| 1262 | ); | ||
| 1263 | |||
| 1264 | return Effect.gen(function* () { | ||
| 1265 | // The helper links against the host's libsecret and its build script is | ||
| 1266 | // a no-op elsewhere, so a Linux artifact built on macOS would ship | ||
| 1267 | // without it and report the keyring as unavailable on every import. | ||
| 1268 | const error = yield* stageBrowserSecret({ | ||
| 1269 | repoRoot: "/repo", | ||
| 1270 | stageResourcesDir: "/stage/resources", | ||
| 1271 | platform: "linux", | ||
| 1272 | arch: "x64", | ||
| 1273 | verbose: false, | ||
| 1274 | }).pipe(Effect.flip); | ||
| 1275 | assert.instanceOf(error, LinuxBrowserSecretHostError); | ||
| 1276 | assert.equal(error.hostPlatform, "darwin"); | ||
| 1277 | assert.include(error.message, "Linux host"); | ||
| 1278 | assert.lengthOf(commands, 0); | ||
| 1279 | }).pipe( | ||
| 1280 | Effect.provide( | ||
| 1281 | Layer.mergeAll( | ||
| 1282 | spawnerLayer, | ||
| 1283 | Layer.succeed(HostProcessPlatform, "darwin"), | ||
| 1284 | Layer.succeed(HostProcessArchitecture, "arm64"), | ||
| 1285 | ), | ||
| 1286 | ), | ||
| 1287 | ); | ||
| 1288 | }); | ||
| 1289 | |||
| 1210 | it.effect("skips the primary native probe for cross-architecture Windows payloads", () => { | 1290 | it.effect("skips the primary native probe for cross-architecture Windows payloads", () => { |
| 1211 | const commands: Array<{ | 1291 | const commands: Array<{ |
| 1212 | readonly command: string; | 1292 | readonly command: string; |
scripts/build-desktop-artifact.ts+21-1| ... | @@ -447,6 +447,15 @@ const desktopIconPlatformNames = { | ... | @@ -447,6 +447,15 @@ const desktopIconPlatformNames = { |
| 447 | win: "Windows", | 447 | win: "Windows", |
| 448 | } satisfies Record<typeof BuildPlatform.Type, string>; | 448 | } satisfies Record<typeof BuildPlatform.Type, string>; |
| 449 | 449 | ||
| 450 | export class LinuxBrowserSecretHostError extends Schema.TaggedErrorClass<LinuxBrowserSecretHostError>()( | ||
| 451 | "LinuxBrowserSecretHostError", | ||
| 452 | { hostPlatform: Schema.String }, | ||
| 453 | ) { | ||
| 454 | override get message(): string { | ||
| 455 | return `Linux desktop builds must run on a Linux host: the browser secret helper links against libsecret and cannot be built on '${this.hostPlatform}'.`; | ||
| 456 | } | ||
| 457 | } | ||
| 458 | |||
| 450 | export class DesktopIconSourceMissingError extends Schema.TaggedErrorClass<DesktopIconSourceMissingError>()( | 459 | export class DesktopIconSourceMissingError extends Schema.TaggedErrorClass<DesktopIconSourceMissingError>()( |
| 451 | "DesktopIconSourceMissingError", | 460 | "DesktopIconSourceMissingError", |
| 452 | { | 461 | { |
| ... | @@ -2220,6 +2229,17 @@ export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (inp | ... | @@ -2220,6 +2229,17 @@ export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (inp |
| 2220 | readonly verbose: boolean; | 2229 | readonly verbose: boolean; |
| 2221 | }) { | 2230 | }) { |
| 2222 | if (input.platform !== "linux") return; | 2231 | if (input.platform !== "linux") return; |
| 2232 | // The helper links against the host's libsecret, so it can only be built on | ||
| 2233 | // Linux; the build script is a no-op elsewhere. A Linux artifact from | ||
| 2234 | // another host would ship without it and every v11 cookie import would | ||
| 2235 | // report the keyring as unavailable, so refuse rather than package that | ||
| 2236 | // silently. `universal` is a mac-only arch the option type still admits; | ||
| 2237 | // the helper script rejects it, so it maps to the concrete x64 the Linux | ||
| 2238 | // resource monitor uses for the same request. | ||
| 2239 | const hostPlatform = yield* HostProcessPlatform; | ||
| 2240 | if (hostPlatform !== "linux") { | ||
| 2241 | return yield* new LinuxBrowserSecretHostError({ hostPlatform }); | ||
| 2242 | } | ||
| 2223 | const path = yield* Path.Path; | 2243 | const path = yield* Path.Path; |
| 2224 | yield* runCommand( | 2244 | yield* runCommand( |
| 2225 | ChildProcess.make( | 2245 | ChildProcess.make( |
| ... | @@ -2227,7 +2247,7 @@ export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (inp | ... | @@ -2227,7 +2247,7 @@ export const stageBrowserSecret = Effect.fn("stageBrowserSecret")(function* (inp |
| 2227 | [ | 2247 | [ |
| 2228 | path.join(input.repoRoot, "apps/desktop/scripts/build-browser-secret.mjs"), | 2248 | path.join(input.repoRoot, "apps/desktop/scripts/build-browser-secret.mjs"), |
| 2229 | "--arch", | 2249 | "--arch", |
| 2230 | input.arch, | 2250 | input.arch === "arm64" ? "arm64" : "x64", |
| 2231 | "--output", | 2251 | "--output", |
| 2232 | path.join(input.stageResourcesDir, "browser-secret", "t3-browser-secret"), | 2252 | path.join(input.stageResourcesDir, "browser-secret", "t3-browser-secret"), |
| 2233 | ], | 2253 | ], |