// @effect-diagnostics nodeBuiltinImport:off - Hand-builds Safari's binary jar // format byte by byte. import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as PlatformError from "effect/PlatformError"; import { isPermissionDenied, parseBinaryCookies, readSafariCookies, safariAccessDenied, SafariCookieReadError, } from "./SafariCookies.ts"; const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200; interface FixtureCookie { readonly domain: string; readonly name: string; readonly path: string; readonly value: string; readonly flags: number; /** Seconds since 2001-01-01, as Safari stores them. */ readonly expiry: number; } /** Encodes one cookie exactly as Safari lays it out. */ function encodeCookie(cookie: FixtureCookie): Buffer { const strings = [cookie.domain, cookie.name, cookie.path, cookie.value]; const headerSize = 56; const offsets: number[] = []; let cursor = headerSize; for (const value of strings) { offsets.push(cursor); cursor += Buffer.byteLength(value) + 1; } const size = cursor; const buffer = Buffer.alloc(size); buffer.writeUInt32LE(size, 0); buffer.writeUInt32LE(0, 4); buffer.writeUInt32LE(cookie.flags, 8); buffer.writeUInt32LE(0, 12); buffer.writeUInt32LE(offsets[0]!, 16); buffer.writeUInt32LE(offsets[1]!, 20); buffer.writeUInt32LE(offsets[2]!, 24); buffer.writeUInt32LE(offsets[3]!, 28); buffer.writeUInt32LE(0, 32); buffer.writeUInt32LE(0, 36); buffer.writeDoubleLE(cookie.expiry, 40); buffer.writeDoubleLE(0, 48); strings.forEach((value, index) => { buffer.write(value, offsets[index]!, "utf8"); }); return buffer; } /** Builds a single-page `Cookies.binarycookies` file. */ function encodeBinaryCookies(cookies: ReadonlyArray): Buffer { const encoded = cookies.map(encodeCookie); const headerSize = 12 + encoded.length * 4; const offsets: number[] = []; let cursor = headerSize; for (const cookie of encoded) { offsets.push(cursor); cursor += cookie.length; } const page = Buffer.alloc(cursor); page.writeUInt32BE(0x0000_0100, 0); page.writeUInt32LE(encoded.length, 4); offsets.forEach((offset, index) => page.writeUInt32LE(offset, 8 + index * 4)); encoded.forEach((cookie, index) => cookie.copy(page, offsets[index]!)); const header = Buffer.alloc(8 + 4); header.write("cook", 0, "latin1"); header.writeUInt32BE(1, 4); header.writeUInt32BE(page.length, 8); return Buffer.concat([header, page]); } describe("parseBinaryCookies", () => { it("reads Safari's format and rebases its 2001 epoch", () => { const file = encodeBinaryCookies([ { domain: ".apple.com", name: "session", path: "/", value: "abc", // secure | httpOnly flags: 0x1 | 0x4, expiry: 800_000_000, }, { domain: "example.test", name: "plain", path: "/app", value: "v", flags: 0, expiry: 0, }, ]); expect(parseBinaryCookies(file)).toEqual([ { url: "https://apple.com/", name: "session", value: "abc", domain: ".apple.com", path: "/", secure: true, httpOnly: true, // Safari counts from 2001-01-01, Electron from 1970. expirationDate: 800_000_000 + APPLE_EPOCH_OFFSET_SECONDS, // The format predates SameSite; Lax is the safe modern default. sameSite: "lax", }, { url: "http://example.test/app", name: "plain", value: "v", // Host-only: no leading dot in the jar, so no `domain` for Electron, // which would otherwise re-add the dot and widen it to subdomains. domain: undefined, path: "/app", secure: false, httpOnly: false, expirationDate: undefined, sameSite: "lax", }, ]); }); it("keeps __Host- cookies host-only so Electron accepts them", () => { const file = encodeBinaryCookies([ { domain: "example.test", name: "__Host-id", path: "/", value: "v", flags: 0x1, expiry: 0 }, ]); expect(parseBinaryCookies(file)[0]).toMatchObject({ url: "https://example.test/", name: "__Host-id", domain: undefined, }); }); it("brackets IPv6 hosts in the cookie URL", () => { const file = encodeBinaryCookies([ { domain: "::1", name: "local", path: "/", value: "v", flags: 0, expiry: 0 }, ]); expect(parseBinaryCookies(file)[0]).toMatchObject({ url: "http://[::1]/", domain: undefined, }); }); it("reads cookies spread across multiple pages", () => { // Safari pages its cookie file, and a single-page reader would silently // return only the first slice. const first = encodeBinaryCookies([ { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, ]); const second = encodeBinaryCookies([ { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 }, ]); // Splice the two single-page files into one two-page file. const firstPage = first.subarray(12); const secondPage = second.subarray(12); const header = Buffer.alloc(16); header.write("cook", 0, "latin1"); header.writeUInt32BE(2, 4); header.writeUInt32BE(firstPage.length, 8); header.writeUInt32BE(secondPage.length, 12); const parsed = parseBinaryCookies(Buffer.concat([header, firstPage, secondPage])); expect(parsed.map((cookie) => cookie.name)).toEqual(["one", "two"]); }); it("rejects a page that runs past the end of the file", () => { // `Buffer.subarray` clamps rather than throwing, so an overlong first page // swallows the second one's bytes and advances the cursor past the end. // Every cookie after the boundary then vanishes from a "successful" import. const first = encodeBinaryCookies([ { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 }, ]); const second = encodeBinaryCookies([ { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 }, ]); const firstPage = first.subarray(12); const secondPage = second.subarray(12); const header = Buffer.alloc(16); header.write("cook", 0, "latin1"); header.writeUInt32BE(2, 4); // Declares more bytes for page one than the file holds in total. header.writeUInt32BE(firstPage.length + secondPage.length + 32, 8); header.writeUInt32BE(secondPage.length, 12); expect(() => parseBinaryCookies(Buffer.concat([header, firstPage, secondPage]))).toThrow( SafariCookieReadError, ); }); it("rejects a record whose declared size runs past its page", () => { const valid = encodeBinaryCookies([ { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, ]); // The record's own length is what bounds its string offsets; an inflated // one lets them read the following record's bytes as this cookie's value. const pageStart = 8 + 4; const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); const corrupt = Buffer.from(valid); corrupt.writeUInt32LE(0xffff, recordStart); expect(() => parseBinaryCookies(corrupt)).toThrow(SafariCookieReadError); }); it("rejects records truncated inside the 56-byte header", () => { const valid = encodeBinaryCookies([ { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, ]); const pageStart = 8 + 4; const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); for (let size = 48; size < 56; size += 1) { const corrupt = Buffer.from(valid); corrupt.writeUInt32LE(size, recordStart); expect(() => parseBinaryCookies(corrupt), `record size ${size}`).toThrow( SafariCookieReadError, ); } }); it("rejects record offsets that point into the page header or an earlier record", () => { const valid = encodeBinaryCookies([ { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, { domain: "b.test", name: "m", path: "/", value: "w", expiry: 1_000, flags: 0 }, ]); const pageStart = 8 + 4; const firstRecord = valid.readUInt32LE(pageStart + 8); // Pointing the second offset at the page's offset table would let those // table bytes parse as a fabricated record. const intoTable = Buffer.from(valid); intoTable.writeUInt32LE(4, pageStart + 12); expect(() => parseBinaryCookies(intoTable)).toThrow(SafariCookieReadError); // Pointing it back at the first record makes the same bytes count twice. const overlapping = Buffer.from(valid); overlapping.writeUInt32LE(firstRecord, pageStart + 12); expect(() => parseBinaryCookies(overlapping)).toThrow(SafariCookieReadError); // And a well-formed two-record page still parses. expect(parseBinaryCookies(valid)).toHaveLength(2); }); it("rejects string offsets that point into the record header", () => { const valid = encodeBinaryCookies([ { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 }, ]); const pageStart = 8 + 4; const recordStart = pageStart + valid.readUInt32LE(pageStart + 8); for (const offsetField of [16, 20, 24, 28]) { const corrupt = Buffer.from(valid); corrupt.writeUInt32LE(55, recordStart + offsetField); expect(() => parseBinaryCookies(corrupt), `offset field ${offsetField}`).toThrow( SafariCookieReadError, ); } }); it("accepts the checksum and property-list trailer Safari writes", () => { const file = encodeBinaryCookies([ { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 }, ]); const checksum = Buffer.alloc(8); const plist = Buffer.from("bplist00 stub"); const plistLength = Buffer.alloc(4); plistLength.writeUInt32BE(plist.length, 0); expect(parseBinaryCookies(Buffer.concat([file, checksum]))).toHaveLength(1); expect(parseBinaryCookies(Buffer.concat([file, checksum, plistLength, plist]))).toHaveLength(1); }); it("rejects a jar whose page table stops short of its contents", () => { // A second, undeclared page after the first would be silently dropped — // the cookies it holds vanish from the import with no error — so a file // the header does not fully describe is refused instead. const first = encodeBinaryCookies([ { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 }, ]); const extraPage = encodeBinaryCookies([ { domain: "b.test", name: "d", path: "/", value: "w", flags: 0, expiry: 0 }, ]).subarray(12); expect(() => parseBinaryCookies(Buffer.concat([first, extraPage]))).toThrow( SafariCookieReadError, ); // A trailer that claims a property list it doesn't contain is refused too. const badLength = Buffer.alloc(4); badLength.writeUInt32BE(99, 0); expect(() => parseBinaryCookies(Buffer.concat([first, Buffer.alloc(8), badLength, Buffer.from("x")])), ).toThrow(SafariCookieReadError); }); it("rejects a file that is not binarycookies", () => { expect(() => parseBinaryCookies(Buffer.from("not a cookie jar"))).toThrow( SafariCookieReadError, ); }); }); describe("readSafariCookies", () => { it.effect("adds the cookie path and parser cause to malformed jar failures", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); const jar = `${directory}/Cookies.binarycookies`; yield* fileSystem.writeFileString(jar, "not a cookie jar"); const error = yield* readSafariCookies(jar).pipe(Effect.flip); assert.equal(error.reason, "readFailed"); assert.equal(error.cookieDatabasePath, jar); assert.instanceOf(error.cause, SafariCookieReadError); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); it.effect("reports a TCC denial as a permission the user can grant", () => Effect.gen(function* () { // What Full Disk Access actually looks like: the file is there, the read // is refused with EPERM. Effect tags that `Unknown`, not // `PermissionDenied`, so the reader has to look at the errno. Reporting // it as a generic failure would send the user looking for a missing // browser instead of a checkbox. const denied = PlatformError.systemError({ _tag: "Unknown", module: "FileSystem", method: "readFile", pathOrDescriptor: "/protected/Cookies.binarycookies", cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }), }); const error = yield* readSafariCookies("/protected/Cookies.binarycookies").pipe( Effect.flip, Effect.provide(FileSystem.layerNoop({ readFile: () => Effect.fail(denied) })), ); assert.equal(error.reason, "needsFullDiskAccess"); }), ); it.effect("reports an ordinary permission failure as a plain read failure", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); const jar = `${directory}/Cookies.binarycookies`; yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); // A mode-bits refusal is EACCES: granting Full Disk Access cannot fix // it, so it must not be routed to that grant. yield* fileSystem.chmod(jar, 0o000); const error = yield* readSafariCookies(jar).pipe(Effect.flip); assert.equal(error.reason, "readFailed"); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); it.effect("reports a missing jar as a plain read failure", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); const error = yield* readSafariCookies(`${directory}/absent.binarycookies`).pipe(Effect.flip); assert.equal(error.reason, "readFailed"); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); }); describe("safariAccessDenied", () => { const eperm = PlatformError.systemError({ _tag: "Unknown", module: "FileSystem", method: "open", pathOrDescriptor: "/protected/Cookies.binarycookies", cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }), }); const denied = (error: PlatformError.PlatformError) => FileSystem.layerNoop({ open: () => Effect.fail(error) }); it.effect("reports TCC's EPERM as a missing Full Disk Access grant", () => Effect.gen(function* () { // `stat` finds the jar without the grant, so only an open tells the // listing whether the import would actually be allowed. assert.isTrue( yield* safariAccessDenied("/protected/Cookies.binarycookies").pipe( Effect.provide(denied(eperm)), ), ); }), ); it.effect("does not read a readable jar, or any other failure, as denied", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" }); const jar = `${directory}/Cookies.binarycookies`; yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b])); assert.isFalse(yield* safariAccessDenied(jar)); // Missing entirely is "not installed", not "denied". assert.isFalse(yield* safariAccessDenied(`${directory}/absent.binarycookies`)); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); }); describe("isPermissionDenied", () => { // Shapes taken from a real `FileSystem.readFile` failure on macOS — verified // against Safari's TCC-protected jar, whose denial is EPERM, tagged // `Unknown` rather than `PermissionDenied`. const platformError = (reasonTag: string, code: string): PlatformError.PlatformError => ({ _tag: "PlatformError", reason: { _tag: reasonTag, cause: { code } } }) as never; it("treats a TCC EPERM denial as permission denied", () => { // The regression: EPERM is tagged `Unknown`, so checking the tag alone // reported Safari's Full Disk Access refusal as a generic read failure. expect(isPermissionDenied(platformError("Unknown", "EPERM"))).toBe(true); }); it("does not send an ordinary EACCES failure to the Full Disk Access grant", () => { // A POSIX permission or ACL refusal cannot be fixed by granting Full Disk // Access, so it stays a plain read failure; only TCC's EPERM routes there. expect(isPermissionDenied(platformError("PermissionDenied", "EACCES"))).toBe(false); }); it("does not treat an unrelated failure as permission denied", () => { expect(isPermissionDenied(platformError("Unknown", "EIO"))).toBe(false); }); });