443 lines · 16.8 KB · TypeScript
RawDownload
// @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<FixtureCookie>): 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);  });});

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t