1// @effect-diagnostics nodeBuiltinImport:off - Hand-builds Safari's binary jar
2// format byte by byte.
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { assert, describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as PlatformError from "effect/PlatformError";
8
9import {
10 isPermissionDenied,
11 parseBinaryCookies,
12 readSafariCookies,
13 safariAccessDenied,
14 safariAccessGranted,
15 SafariCookieReadError,
16} from "./SafariCookies.ts";
17
18const APPLE_EPOCH_OFFSET_SECONDS = 978_307_200;
19
20interface FixtureCookie {
21 readonly domain: string;
22 readonly name: string;
23 readonly path: string;
24 readonly value: string;
25 readonly flags: number;
26 /** Seconds since 2001-01-01, as Safari stores them. */
27 readonly expiry: number;
28}
29
30/** Encodes one cookie exactly as Safari lays it out. */
31function encodeCookie(cookie: FixtureCookie): Buffer {
32 const strings = [cookie.domain, cookie.name, cookie.path, cookie.value];
33 const headerSize = 56;
34 const offsets: number[] = [];
35 let cursor = headerSize;
36 for (const value of strings) {
37 offsets.push(cursor);
38 cursor += Buffer.byteLength(value) + 1;
39 }
40 const size = cursor;
41
42 const buffer = Buffer.alloc(size);
43 buffer.writeUInt32LE(size, 0);
44 buffer.writeUInt32LE(0, 4);
45 buffer.writeUInt32LE(cookie.flags, 8);
46 buffer.writeUInt32LE(0, 12);
47 buffer.writeUInt32LE(offsets[0]!, 16);
48 buffer.writeUInt32LE(offsets[1]!, 20);
49 buffer.writeUInt32LE(offsets[2]!, 24);
50 buffer.writeUInt32LE(offsets[3]!, 28);
51 buffer.writeUInt32LE(0, 32);
52 buffer.writeUInt32LE(0, 36);
53 buffer.writeDoubleLE(cookie.expiry, 40);
54 buffer.writeDoubleLE(0, 48);
55 strings.forEach((value, index) => {
56 buffer.write(value, offsets[index]!, "utf8");
57 });
58 return buffer;
59}
60
61/** Builds a single-page `Cookies.binarycookies` file. */
62function encodeBinaryCookies(cookies: ReadonlyArray<FixtureCookie>): Buffer {
63 const encoded = cookies.map(encodeCookie);
64 const headerSize = 12 + encoded.length * 4;
65 const offsets: number[] = [];
66 let cursor = headerSize;
67 for (const cookie of encoded) {
68 offsets.push(cursor);
69 cursor += cookie.length;
70 }
71
72 const page = Buffer.alloc(cursor);
73 page.writeUInt32BE(0x0000_0100, 0);
74 page.writeUInt32LE(encoded.length, 4);
75 offsets.forEach((offset, index) => page.writeUInt32LE(offset, 8 + index * 4));
76 encoded.forEach((cookie, index) => cookie.copy(page, offsets[index]!));
77
78 const header = Buffer.alloc(8 + 4);
79 header.write("cook", 0, "latin1");
80 header.writeUInt32BE(1, 4);
81 header.writeUInt32BE(page.length, 8);
82 return Buffer.concat([header, page]);
83}
84
85describe("parseBinaryCookies", () => {
86 it("reads Safari's format and rebases its 2001 epoch", () => {
87 const file = encodeBinaryCookies([
88 {
89 domain: ".apple.com",
90 name: "session",
91 path: "/",
92 value: "abc",
93 // secure | httpOnly
94 flags: 0x1 | 0x4,
95 expiry: 800_000_000,
96 },
97 {
98 domain: "example.test",
99 name: "plain",
100 path: "/app",
101 value: "v",
102 flags: 0,
103 expiry: 0,
104 },
105 ]);
106
107 expect(parseBinaryCookies(file)).toEqual([
108 {
109 url: "https://apple.com/",
110 name: "session",
111 value: "abc",
112 domain: ".apple.com",
113 path: "/",
114 secure: true,
115 httpOnly: true,
116 // Safari counts from 2001-01-01, Electron from 1970.
117 expirationDate: 800_000_000 + APPLE_EPOCH_OFFSET_SECONDS,
118 // The format predates SameSite; Lax is the safe modern default.
119 sameSite: "lax",
120 },
121 {
122 url: "http://example.test/app",
123 name: "plain",
124 value: "v",
125 // Host-only: no leading dot in the jar, so no `domain` for Electron,
126 // which would otherwise re-add the dot and widen it to subdomains.
127 domain: undefined,
128 path: "/app",
129 secure: false,
130 httpOnly: false,
131 expirationDate: undefined,
132 sameSite: "lax",
133 },
134 ]);
135 });
136
137 it("keeps __Host- cookies host-only so Electron accepts them", () => {
138 const file = encodeBinaryCookies([
139 { domain: "example.test", name: "__Host-id", path: "/", value: "v", flags: 0x1, expiry: 0 },
140 ]);
141
142 expect(parseBinaryCookies(file)[0]).toMatchObject({
143 url: "https://example.test/",
144 name: "__Host-id",
145 domain: undefined,
146 });
147 });
148
149 it("brackets IPv6 hosts in the cookie URL", () => {
150 const file = encodeBinaryCookies([
151 { domain: "::1", name: "local", path: "/", value: "v", flags: 0, expiry: 0 },
152 ]);
153
154 expect(parseBinaryCookies(file)[0]).toMatchObject({
155 url: "http://[::1]/",
156 domain: undefined,
157 });
158 });
159
160 it("reads cookies spread across multiple pages", () => {
161 // Safari pages its cookie file, and a single-page reader would silently
162 // return only the first slice.
163 const first = encodeBinaryCookies([
164 { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 },
165 ]);
166 const second = encodeBinaryCookies([
167 { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 },
168 ]);
169 // Splice the two single-page files into one two-page file.
170 const firstPage = first.subarray(12);
171 const secondPage = second.subarray(12);
172 const header = Buffer.alloc(16);
173 header.write("cook", 0, "latin1");
174 header.writeUInt32BE(2, 4);
175 header.writeUInt32BE(firstPage.length, 8);
176 header.writeUInt32BE(secondPage.length, 12);
177
178 const parsed = parseBinaryCookies(Buffer.concat([header, firstPage, secondPage]));
179
180 expect(parsed.map((cookie) => cookie.name)).toEqual(["one", "two"]);
181 });
182
183 it("rejects a page that runs past the end of the file", () => {
184 // `Buffer.subarray` clamps rather than throwing, so an overlong first page
185 // swallows the second one's bytes and advances the cursor past the end.
186 // Every cookie after the boundary then vanishes from a "successful" import.
187 const first = encodeBinaryCookies([
188 { domain: "a.test", name: "one", path: "/", value: "1", flags: 0, expiry: 1 },
189 ]);
190 const second = encodeBinaryCookies([
191 { domain: "b.test", name: "two", path: "/", value: "2", flags: 0, expiry: 1 },
192 ]);
193 const firstPage = first.subarray(12);
194 const secondPage = second.subarray(12);
195 const header = Buffer.alloc(16);
196 header.write("cook", 0, "latin1");
197 header.writeUInt32BE(2, 4);
198 // Declares more bytes for page one than the file holds in total.
199 header.writeUInt32BE(firstPage.length + secondPage.length + 32, 8);
200 header.writeUInt32BE(secondPage.length, 12);
201
202 expect(() => parseBinaryCookies(Buffer.concat([header, firstPage, secondPage]))).toThrow(
203 SafariCookieReadError,
204 );
205 });
206
207 it("rejects a record whose declared size runs past its page", () => {
208 const valid = encodeBinaryCookies([
209 { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 },
210 ]);
211 // The record's own length is what bounds its string offsets; an inflated
212 // one lets them read the following record's bytes as this cookie's value.
213 const pageStart = 8 + 4;
214 const recordStart = pageStart + valid.readUInt32LE(pageStart + 8);
215 const corrupt = Buffer.from(valid);
216 corrupt.writeUInt32LE(0xffff, recordStart);
217
218 expect(() => parseBinaryCookies(corrupt)).toThrow(SafariCookieReadError);
219 });
220
221 it("rejects records truncated inside the 56-byte header", () => {
222 const valid = encodeBinaryCookies([
223 { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 },
224 ]);
225 const pageStart = 8 + 4;
226 const recordStart = pageStart + valid.readUInt32LE(pageStart + 8);
227
228 for (let size = 48; size < 56; size += 1) {
229 const corrupt = Buffer.from(valid);
230 corrupt.writeUInt32LE(size, recordStart);
231 expect(() => parseBinaryCookies(corrupt), `record size ${size}`).toThrow(
232 SafariCookieReadError,
233 );
234 }
235 });
236
237 it("rejects record offsets that point into the page header or an earlier record", () => {
238 const valid = encodeBinaryCookies([
239 { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 },
240 { domain: "b.test", name: "m", path: "/", value: "w", expiry: 1_000, flags: 0 },
241 ]);
242 const pageStart = 8 + 4;
243 const firstRecord = valid.readUInt32LE(pageStart + 8);
244
245 // Pointing the second offset at the page's offset table would let those
246 // table bytes parse as a fabricated record.
247 const intoTable = Buffer.from(valid);
248 intoTable.writeUInt32LE(4, pageStart + 12);
249 expect(() => parseBinaryCookies(intoTable)).toThrow(SafariCookieReadError);
250
251 // Pointing it back at the first record makes the same bytes count twice.
252 const overlapping = Buffer.from(valid);
253 overlapping.writeUInt32LE(firstRecord, pageStart + 12);
254 expect(() => parseBinaryCookies(overlapping)).toThrow(SafariCookieReadError);
255
256 // And a well-formed two-record page still parses.
257 expect(parseBinaryCookies(valid)).toHaveLength(2);
258 });
259
260 it("rejects string offsets that point into the record header", () => {
261 const valid = encodeBinaryCookies([
262 { domain: "a.test", name: "n", path: "/", value: "v", expiry: 1_000, flags: 0 },
263 ]);
264 const pageStart = 8 + 4;
265 const recordStart = pageStart + valid.readUInt32LE(pageStart + 8);
266
267 for (const offsetField of [16, 20, 24, 28]) {
268 const corrupt = Buffer.from(valid);
269 corrupt.writeUInt32LE(55, recordStart + offsetField);
270 expect(() => parseBinaryCookies(corrupt), `offset field ${offsetField}`).toThrow(
271 SafariCookieReadError,
272 );
273 }
274 });
275
276 it("accepts the checksum and property-list trailer Safari writes", () => {
277 const file = encodeBinaryCookies([
278 { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 },
279 ]);
280 const checksum = Buffer.alloc(8);
281 const plist = Buffer.from("bplist00 stub");
282 const plistLength = Buffer.alloc(4);
283 plistLength.writeUInt32BE(plist.length, 0);
284
285 expect(parseBinaryCookies(Buffer.concat([file, checksum]))).toHaveLength(1);
286 expect(parseBinaryCookies(Buffer.concat([file, checksum, plistLength, plist]))).toHaveLength(1);
287 });
288
289 it("rejects a jar whose page table stops short of its contents", () => {
290 // A second, undeclared page after the first would be silently dropped —
291 // the cookies it holds vanish from the import with no error — so a file
292 // the header does not fully describe is refused instead.
293 const first = encodeBinaryCookies([
294 { domain: "a.test", name: "c", path: "/", value: "v", flags: 0, expiry: 0 },
295 ]);
296 const extraPage = encodeBinaryCookies([
297 { domain: "b.test", name: "d", path: "/", value: "w", flags: 0, expiry: 0 },
298 ]).subarray(12);
299
300 expect(() => parseBinaryCookies(Buffer.concat([first, extraPage]))).toThrow(
301 SafariCookieReadError,
302 );
303 // A trailer that claims a property list it doesn't contain is refused too.
304 const badLength = Buffer.alloc(4);
305 badLength.writeUInt32BE(99, 0);
306 expect(() =>
307 parseBinaryCookies(Buffer.concat([first, Buffer.alloc(8), badLength, Buffer.from("x")])),
308 ).toThrow(SafariCookieReadError);
309 });
310
311 it("rejects a file that is not binarycookies", () => {
312 expect(() => parseBinaryCookies(Buffer.from("not a cookie jar"))).toThrow(
313 SafariCookieReadError,
314 );
315 });
316});
317
318describe("readSafariCookies", () => {
319 it.effect("adds the cookie path and parser cause to malformed jar failures", () =>
320 Effect.gen(function* () {
321 const fileSystem = yield* FileSystem.FileSystem;
322 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" });
323 const jar = `${directory}/Cookies.binarycookies`;
324 yield* fileSystem.writeFileString(jar, "not a cookie jar");
325
326 const error = yield* readSafariCookies(jar).pipe(Effect.flip);
327
328 assert.equal(error.reason, "readFailed");
329 assert.equal(error.cookieDatabasePath, jar);
330 assert.instanceOf(error.cause, SafariCookieReadError);
331 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
332 );
333
334 it.effect("reports a TCC denial as a permission the user can grant", () =>
335 Effect.gen(function* () {
336 // What Full Disk Access actually looks like: the file is there, the read
337 // is refused with EPERM. Effect tags that `Unknown`, not
338 // `PermissionDenied`, so the reader has to look at the errno. Reporting
339 // it as a generic failure would send the user looking for a missing
340 // browser instead of a checkbox.
341 const denied = PlatformError.systemError({
342 _tag: "Unknown",
343 module: "FileSystem",
344 method: "readFile",
345 pathOrDescriptor: "/protected/Cookies.binarycookies",
346 cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }),
347 });
348
349 const error = yield* readSafariCookies("/protected/Cookies.binarycookies").pipe(
350 Effect.flip,
351 Effect.provide(FileSystem.layerNoop({ readFile: () => Effect.fail(denied) })),
352 );
353
354 assert.equal(error.reason, "needsFullDiskAccess");
355 }),
356 );
357
358 it.effect("reports an ordinary permission failure as a plain read failure", () =>
359 Effect.gen(function* () {
360 const fileSystem = yield* FileSystem.FileSystem;
361 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" });
362 const jar = `${directory}/Cookies.binarycookies`;
363 yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b]));
364 // A mode-bits refusal is EACCES: granting Full Disk Access cannot fix
365 // it, so it must not be routed to that grant.
366 yield* fileSystem.chmod(jar, 0o000);
367
368 const error = yield* readSafariCookies(jar).pipe(Effect.flip);
369
370 assert.equal(error.reason, "readFailed");
371 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
372 );
373
374 it.effect("reports a missing jar as a plain read failure", () =>
375 Effect.gen(function* () {
376 const fileSystem = yield* FileSystem.FileSystem;
377 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" });
378
379 const error = yield* readSafariCookies(`${directory}/absent.binarycookies`).pipe(Effect.flip);
380
381 assert.equal(error.reason, "readFailed");
382 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
383 );
384});
385
386describe("safariAccessDenied", () => {
387 const eperm = PlatformError.systemError({
388 _tag: "Unknown",
389 module: "FileSystem",
390 method: "open",
391 pathOrDescriptor: "/protected/Cookies.binarycookies",
392 cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }),
393 });
394 const denied = (error: PlatformError.PlatformError) =>
395 FileSystem.layerNoop({ open: () => Effect.fail(error) });
396
397 it.effect("reports TCC's EPERM as a missing Full Disk Access grant", () =>
398 Effect.gen(function* () {
399 // `stat` finds the jar without the grant, so only an open tells the
400 // listing whether the import would actually be allowed.
401 assert.isTrue(
402 yield* safariAccessDenied("/protected/Cookies.binarycookies").pipe(
403 Effect.provide(denied(eperm)),
404 ),
405 );
406 }),
407 );
408
409 it.effect("does not read a readable jar, or any other failure, as denied", () =>
410 Effect.gen(function* () {
411 const fileSystem = yield* FileSystem.FileSystem;
412 const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-safari-" });
413 const jar = `${directory}/Cookies.binarycookies`;
414 yield* fileSystem.writeFile(jar, new Uint8Array([0x63, 0x6f, 0x6f, 0x6b]));
415 assert.isFalse(yield* safariAccessDenied(jar));
416 // Missing entirely is "not installed", not "denied".
417 assert.isFalse(yield* safariAccessDenied(`${directory}/absent.binarycookies`));
418 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
419 );
420});
421
422describe("isPermissionDenied", () => {
423 // Shapes taken from a real `FileSystem.readFile` failure on macOS — verified
424 // against Safari's TCC-protected jar, whose denial is EPERM, tagged
425 // `Unknown` rather than `PermissionDenied`.
426 const platformError = (reasonTag: string, code: string): PlatformError.PlatformError =>
427 ({ _tag: "PlatformError", reason: { _tag: reasonTag, cause: { code } } }) as never;
428
429 it("treats a TCC EPERM denial as permission denied", () => {
430 // The regression: EPERM is tagged `Unknown`, so checking the tag alone
431 // reported Safari's Full Disk Access refusal as a generic read failure.
432 expect(isPermissionDenied(platformError("Unknown", "EPERM"))).toBe(true);
433 });
434
435 it("does not send an ordinary EACCES failure to the Full Disk Access grant", () => {
436 // A POSIX permission or ACL refusal cannot be fixed by granting Full Disk
437 // Access, so it stays a plain read failure; only TCC's EPERM routes there.
438 expect(isPermissionDenied(platformError("PermissionDenied", "EACCES"))).toBe(false);
439 });
440
441 it("does not treat an unrelated failure as permission denied", () => {
442 expect(isPermissionDenied(platformError("Unknown", "EIO"))).toBe(false);
443 });
444});
445
446describe("safariAccessGranted", () => {
447 it.effect("only reports a successful read-only open as granted", () =>
448 Effect.gen(function* () {
449 const fileSystem = yield* FileSystem.FileSystem;
450 const directory = yield* fileSystem.makeTempDirectoryScoped({
451 prefix: "t3-safari-permission-",
452 });
453 const jar = `${directory}/Cookies.binarycookies`;
454 assert.isFalse(yield* safariAccessGranted(jar));
455 yield* fileSystem.writeFileString(jar, "no cookie parsing needed");
456 assert.isTrue(yield* safariAccessGranted(jar));
457 yield* fileSystem.remove(jar);
458 assert.isFalse(yield* safariAccessGranted(jar));
459 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
460 );
461
462 it.effect("does not mistake TCC denial for a grant", () =>
463 Effect.gen(function* () {
464 const denied = PlatformError.systemError({
465 _tag: "Unknown",
466 module: "FileSystem",
467 method: "open",
468 cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }),
469 });
470 assert.isFalse(
471 yield* safariAccessGranted("/protected/Cookies.binarycookies").pipe(
472 Effect.provide(FileSystem.layerNoop({ open: () => Effect.fail(denied) })),
473 ),
474 );
475 }),
476 );
477});