Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions apps/desktop/src/preview/BrowserImport/BrowserImport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import * as Layer from "effect/Layer";

import * as BrowserSession from "../BrowserSession.ts";
import * as BrowserImport from "./BrowserImport.ts";
import { BROWSER_IMPORT_SOURCES, sourcePaths } from "./Sources.ts";
import { BROWSER_IMPORT_SOURCES, sourcePathContext } from "./Sources.ts";

const helium = BROWSER_IMPORT_SOURCES.find((source) => source.id === "helium")!;

Expand All @@ -34,15 +34,16 @@ const withImporter = Effect.fnUntraced(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const home = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-import-" });
const environment = Layer.succeed(HostProcessEnvironment, { HOME: home });
const paths = yield* sourcePaths.pipe(
const context = yield* sourcePathContext.pipe(
Effect.provideService(HostProcessEnvironment, { HOME: home }),
Effect.provideService(HostProcessPlatform, "darwin"),
);
yield* fileSystem.makeDirectory(`${helium.userDataDirectory(paths)}/Default`, {
recursive: true,
});
const root = helium.userDataDirectory(context);
if (root === undefined) throw new Error("Helium has no macOS user-data directory");
yield* fileSystem.makeDirectory(`${root}/Default`, { recursive: true });
// The cookie database is what marks a source as installed, so a fixture
// without one is reported as absent before any other check runs.
yield* fileSystem.writeFileString(`${helium.userDataDirectory(paths)}/Default/Cookies`, "db");
yield* fileSystem.writeFileString(`${root}/Default/Cookies`, "db");

const importer = yield* BrowserImport.BrowserImport.pipe(
Effect.provide(
Expand All @@ -55,7 +56,7 @@ const withImporter = Effect.fnUntraced(function* () {
),
),
);
return { importer, home, paths };
return { importer, home, root };
});

describe("BrowserImport.importCookies", () => {
Expand Down Expand Up @@ -89,13 +90,10 @@ describe("BrowserImport.importCookies", () => {
it.effect("refuses to import while the source browser holds its profile", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const { importer, paths } = yield* withImporter();
const { importer, root } = yield* withImporter();
// The lock Chromium leaves while it is running, dangling target and
// all. This must stop the import before it ever asks the keychain.
yield* fileSystem.symlink(
"host-that-does-not-exist-1234",
`${helium.userDataDirectory(paths)}/SingletonLock`,
);
yield* fileSystem.symlink("host-that-does-not-exist-1234", `${root}/SingletonLock`);

const error = yield* importer
.importCookies({
Expand Down
97 changes: 71 additions & 26 deletions apps/desktop/src/preview/BrowserImport/BrowserImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,26 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";

import { HostProcessExecutablePath, HostProcessPlatform } from "@t3tools/shared/hostProcess";

import * as BrowserSession from "../BrowserSession.ts";
import { readChromiumCookies } from "./ChromiumCookies.ts";
import {
ChromiumCookieReadError,
readChromiumCookies,
type CookieReadResult,
} from "./ChromiumCookies.ts";
import { FirefoxCookieReadError, readFirefoxCookies } from "./FirefoxCookies.ts";
import {
BROWSER_IMPORT_SOURCES,
cookieDatabasePath,
isSourceInstalled,
isSourceRunning,
listSourceProfiles,
sourcePaths,
sourcePathContext,
type BrowserImportPathContext,
type BrowserImportSourceDefinition,
type SourcePaths,
} from "./Sources.ts";

export class BrowserImportFailedError extends Schema.TaggedErrorClass<BrowserImportFailedError>()(
Expand Down Expand Up @@ -64,12 +70,16 @@ export class BrowserImport extends Context.Service<

const unavailableReason = Effect.fn("BrowserImport.unavailableReason")(function* (
definition: BrowserImportSourceDefinition,
platform: NodeJS.Platform,
paths: SourcePaths,
context: BrowserImportPathContext,
): Effect.fn.Return<BrowserImportUnavailableReason | undefined, never, FileSystem.FileSystem> {
if (!definition.platforms.includes(platform)) return "unsupportedPlatform";
if (!(yield* isSourceInstalled(definition, paths))) return "notInstalled";
if (yield* isSourceRunning(definition, paths)) return "browserRunning";
if (!definition.platforms.includes(context.platform)) return "unsupportedPlatform";
// Chromium's key lives in an OS credential store, and only the macOS one is
// implemented; Firefox needs no key at all, so it works everywhere.
if (definition.engine === "chromium" && context.platform !== "darwin") {
return "unsupportedPlatform";
}
if (!(yield* isSourceInstalled(definition, context))) return "notInstalled";
if (yield* isSourceRunning(definition, context)) return "browserRunning";
return undefined;
});

Expand All @@ -89,18 +99,19 @@ export const make = Effect.gen(function* BrowserImportMake() {
// Captured here so the service's methods stay free of a requirements
// channel: the layer is built where NodeServices is already in scope.
const platformServices = yield* Effect.context<FileSystem.FileSystem | Path.Path>();
const paths = yield* sourcePaths;
const pathContext = yield* sourcePathContext;

const listSources: Effect.Effect<ReadonlyArray<BrowserImportSource>> = Effect.forEach(
BROWSER_IMPORT_SOURCES,
Effect.fnUntraced(function* (definition) {
const unavailable = yield* unavailableReason(definition, platform, paths);
const unavailable = yield* unavailableReason(definition, pathContext);
return {
id: definition.id,
name: definition.name,
// Listing profiles touches the source's own files, so skip it when the
// source is unusable anyway.
profiles: unavailable === undefined ? yield* listSourceProfiles(definition, paths) : [],
profiles:
unavailable === undefined ? yield* listSourceProfiles(definition, pathContext) : [],
...(unavailable === undefined ? {} : { unavailable }),
} satisfies BrowserImportSource;
}),
Expand All @@ -121,7 +132,7 @@ export const make = Effect.gen(function* BrowserImportMake() {
});
}

const blocked = yield* unavailableReason(definition, platform, paths).pipe(
const blocked = yield* unavailableReason(definition, pathContext).pipe(
Effect.provide(platformServices),
);
if (blocked !== undefined) {
Expand All @@ -140,7 +151,7 @@ export const make = Effect.gen(function* BrowserImportMake() {
// source itself reported it. Forwarding it unchecked would let `..`
// segments walk out of the browser's user-data directory and read any
// cookie database reachable on disk.
const sourceProfiles = yield* listSourceProfiles(definition, paths).pipe(
const sourceProfiles = yield* listSourceProfiles(definition, pathContext).pipe(
Effect.provide(platformServices),
);
const requestedProfile = sourceProfiles.find(
Expand All @@ -153,18 +164,52 @@ export const make = Effect.gen(function* BrowserImportMake() {
});
}

const read = yield* readChromiumCookies({
cookieDatabasePath: cookieDatabasePath(definition, paths, requestedProfile.directory),
keychainService: definition.keychainService,
keychainAccount: definition.keychainAccount,
platform,
}).pipe(
const databasePath = cookieDatabasePath(definition, pathContext, requestedProfile.directory);
if (databasePath === undefined) {
return yield* new BrowserImportFailedError({
sourceId: definition.id,
reason: "unsupportedPlatform",
});
}

// Both branches fail with a tagged error, so the union stays structurally
// identifiable and each tag is handled on its own below. The success side
// is normalized to one shape too, so the skipped tally survives either
// engine — Firefox stores plaintext, so nothing there is ever unreadable.
const read: Effect.Effect<
CookieReadResult,
ChromiumCookieReadError | FirefoxCookieReadError,
FileSystem.FileSystem | Path.Path | Scope.Scope
> =
definition.engine === "firefox"
? readFirefoxCookies(databasePath).pipe(
Effect.map((cookies) => ({ cookies, undecryptable: 0, undecryptableHosts: [] })),
)
: readChromiumCookies({
cookieDatabasePath: databasePath,
// Only reached on macOS: `unavailableReason` rejects Chromium
// elsewhere until those key stores are implemented.
keychainService: definition.keychainService ?? "",
keychainAccount: definition.keychainAccount ?? "",
platform,
});

const result = yield* read.pipe(
Effect.scoped,
Effect.provide(platformServices),
Effect.mapError(
(cause) =>
new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }),
),
Effect.catchTags({
ChromiumCookieReadError: (cause) =>
Effect.fail(
new BrowserImportFailedError({ sourceId: definition.id, reason: cause.reason, cause }),
),
// Firefox has one failure mode — its plaintext database would not open
// — so its error carries no reason of its own and the user-facing one
// is supplied here.
FirefoxCookieReadError: (cause) =>
Effect.fail(
new BrowserImportFailedError({ sourceId: definition.id, reason: "readFailed", cause }),
),
}),
);

const session = yield* browserSession.getSession(input.scope, input.persistent).pipe(
Expand All @@ -183,9 +228,9 @@ export const make = Effect.gen(function* BrowserImportMake() {
let imported = 0;
// Rows the reader could not decrypt are already lost cookies, so they
// count as skipped rather than vanishing from the tally.
let skipped = read.undecryptable;
const skippedDomains = new Set(read.undecryptableHosts);
for (const cookie of read.cookies) {
let skipped = result.undecryptable;
const skippedDomains = new Set(result.undecryptableHosts);
for (const cookie of result.cookies) {
const written = yield* Effect.tryPromise({
try: () =>
session.cookies.set({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "@effect/vitest";

import { cookieScope } from "./ChromiumCookies.ts";
import { cookieScope } from "./CookieDatabase.ts";

describe("cookieScope", () => {
it("keeps a host-only cookie host-only", () => {
Expand Down
86 changes: 8 additions & 78 deletions apps/desktop/src/preview/BrowserImport/ChromiumCookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,16 @@ import * as NodeCrypto from "node:crypto";

import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as SqlClient from "effect/unstable/sql/SqlClient";

import {
bareHost,
cookieScope,
snapshotCookieDatabase,
type ImportedCookie,
} from "./CookieDatabase.ts";

/** macOS OSCrypt parameters. Chromium has used these since the feature landed. */
const MAC_KEY_ITERATIONS = 1003;
const MAC_KEY_SALT = "saltysalt";
Expand All @@ -32,24 +37,7 @@ const MAC_KEY_LENGTH = 16;
const AES_IV = Buffer.alloc(16, 0x20);
const V10_PREFIX = "v10";

export interface ChromiumCookie {
readonly url: string;
readonly name: string;
readonly value: string;
/**
* Set only for domain cookies, which Chromium stores with a leading dot.
* A host-only cookie leaves this undefined: Electron treats any `domain` it
* is given as a domain cookie and re-adds the dot, which would widen the
* cookie to every subdomain of the host it was scoped to.
*/
readonly domain: string | undefined;
readonly path: string;
readonly secure: boolean;
readonly httpOnly: boolean;
/** Seconds since the UNIX epoch, or undefined for a session cookie. */
readonly expirationDate: number | undefined;
readonly sameSite: "no_restriction" | "lax" | "strict";
}
export type ChromiumCookie = ImportedCookie;

export const ChromiumCookieReadReason = Schema.Literals([
"needsKeychainApproval",
Expand Down Expand Up @@ -161,64 +149,6 @@ const readMacKeychainPassword = Effect.fn("ChromiumCookies.readMacKeychainPasswo
return password;
});

/**
* Chromium keeps the cookie DB open with WAL, and reading it in place can
* observe a torn state. Copying first — including the sidecars — gives a
* consistent snapshot without touching the browser's own files.
*
* Scoped: the temp directory is removed when the caller's scope closes.
*/
const snapshotCookieDatabase = Effect.fn("ChromiumCookies.snapshotCookieDatabase")(function* (
cookiePath: string,
) {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;

const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-cookie-import-" });
const target = path.join(directory, "Cookies");
yield* fileSystem.copyFile(cookiePath, target);
// A sidecar only exists while the browser holds the database open, so an
// absent one is normal. Anything else — a permission error, a partial read —
// is not: SQLite would then open the snapshot without the write-ahead log
// and quietly return a cookie set missing its most recent transactions.
yield* Effect.forEach(["-wal", "-shm"], (suffix) =>
fileSystem.copyFile(`${cookiePath}${suffix}`, `${target}${suffix}`).pipe(
Effect.catchIf(
(error) => error.reason._tag === "NotFound",
() => Effect.void,
),
),
);
return target;
});

/**
* The URL and domain Electron should register a stored row under.
*
* Chromium marks a domain cookie with a leading dot on `host_key`. Electron
* matches on a URL, so the dot comes off for that; `domain` is passed through
* only for domain cookies, because supplying it at all makes Electron treat
* the cookie as one and re-add the dot — widening a host-only cookie to every
* subdomain of the host it was scoped to, and rejecting `__Host-` cookies,
* which require it to be absent.
*/
export const cookieScope = (
hostKey: string,
path: string,
secure: boolean,
): { readonly url: string; readonly domain: string | undefined } => {
const isDomainCookie = hostKey.startsWith(".");
const host = isDomainCookie ? hostKey.slice(1) : hostKey;
return {
url: `${secure ? "https" : "http"}://${host}${path}`,
...(isDomainCookie ? { domain: hostKey } : { domain: undefined }),
};
};

/** The host without Chromium's domain-cookie leading dot, for display. */
const bareHost = (hostKey: string): string =>
hostKey.startsWith(".") ? hostKey.slice(1) : hostKey;

const decryptValue = (encrypted: Uint8Array, key: Buffer, domain: string): string | null => {
const buffer = Buffer.from(encrypted);
if (buffer.length === 0) return "";
Expand Down
Loading
Loading