browser-commander 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/package.json +1 -1
- package/src/browser/browser-cookies.js +37 -5
- package/src/browser/browser-profiles.js +18 -0
- package/src/browser/browser-sources.json +30 -0
- package/src/browser/migration/index.js +33 -0
- package/src/browser/safari-cookies.js +173 -0
- package/tests/unit/browser/safari-cookies.test.js +246 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.23.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- b56775b: Add Safari and Safari Technology Preview cookie sources, including installed,
|
|
8
|
+
default/auto and custom-directory import, counts-only domain discovery, validated
|
|
9
|
+
binarycookies decoding and actionable Full Disk Access errors. Migration reports
|
|
10
|
+
unsupported Safari classes and the SameSite Lax fallback. Other Safari stores,
|
|
11
|
+
Firefox/WebKit targets, full clones and additional data classes remain tracked in
|
|
12
|
+
issues #117–#120; this release does not claim full Safari profile import.
|
|
13
|
+
|
|
3
14
|
## 0.22.0
|
|
4
15
|
|
|
5
16
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -33,6 +33,11 @@ import {
|
|
|
33
33
|
resolveSourceBrowser,
|
|
34
34
|
} from './browser-profiles.js';
|
|
35
35
|
import { resolveDefaultBrowser } from './default-browser.js';
|
|
36
|
+
import {
|
|
37
|
+
countSafariCookies,
|
|
38
|
+
parseSafariCookies,
|
|
39
|
+
readSafariCookieFile,
|
|
40
|
+
} from './safari-cookies.js';
|
|
36
41
|
|
|
37
42
|
const CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600n;
|
|
38
43
|
const MICROSECONDS_PER_SECOND = 1_000_000n;
|
|
@@ -319,6 +324,14 @@ export async function readBrowserCookiesWithDependencies(
|
|
|
319
324
|
return cachedCookies;
|
|
320
325
|
}
|
|
321
326
|
|
|
327
|
+
if (browserFamily(browser) === 'safari') {
|
|
328
|
+
const cookies = parseSafariCookies(
|
|
329
|
+
await readSafariCookieFile(cookiePath, { environment }),
|
|
330
|
+
options.domainFilter
|
|
331
|
+
);
|
|
332
|
+
await writeCookieResultCache({ cache, identity, cookies, now });
|
|
333
|
+
return cookies;
|
|
334
|
+
}
|
|
322
335
|
const database = await openCookieDatabase(cookiePath);
|
|
323
336
|
let cookies;
|
|
324
337
|
try {
|
|
@@ -426,8 +439,15 @@ export async function listCookieSources({
|
|
|
426
439
|
let error;
|
|
427
440
|
let database;
|
|
428
441
|
try {
|
|
429
|
-
|
|
430
|
-
|
|
442
|
+
if (family === 'safari') {
|
|
443
|
+
counts = countSafariCookies(
|
|
444
|
+
await readSafariCookieFile(cookiePath, { environment }),
|
|
445
|
+
domains
|
|
446
|
+
);
|
|
447
|
+
} else {
|
|
448
|
+
database = await openCookieDatabase(cookiePath);
|
|
449
|
+
counts = countCookiesByDomain(database, family, domains);
|
|
450
|
+
}
|
|
431
451
|
} catch (openError) {
|
|
432
452
|
error = openError.message;
|
|
433
453
|
} finally {
|
|
@@ -514,12 +534,24 @@ export async function resolveImportSource({
|
|
|
514
534
|
environment,
|
|
515
535
|
runCommand,
|
|
516
536
|
});
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
537
|
+
const listedSources = await listCookieSources({
|
|
538
|
+
domains,
|
|
539
|
+
platform,
|
|
540
|
+
homeDir,
|
|
541
|
+
environment,
|
|
542
|
+
});
|
|
543
|
+
const holders = listedSources.filter((source) => !source.error);
|
|
520
544
|
const fromDefault = holders.filter(
|
|
521
545
|
(source) => source.browser === systemDefault
|
|
522
546
|
);
|
|
547
|
+
const unreadableDefault = listedSources.find(
|
|
548
|
+
(source) => source.browser === systemDefault && source.error
|
|
549
|
+
);
|
|
550
|
+
if (fromDefault.length === 0 && unreadableDefault) {
|
|
551
|
+
throw new Error(
|
|
552
|
+
`Could not inspect the default browser (${systemDefault}): ${unreadableDefault.error}`
|
|
553
|
+
);
|
|
554
|
+
}
|
|
523
555
|
const candidates = fromDefault.length > 0 ? fromDefault : holders;
|
|
524
556
|
if (candidates.length === 0) {
|
|
525
557
|
if (!systemDefault) {
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
resolveBrowserRoots,
|
|
11
11
|
} from './browser-sources.js';
|
|
12
12
|
import { resolveDefaultBrowser } from './default-browser.js';
|
|
13
|
+
import { findSafariCookieFile } from './safari-cookies.js';
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Keywords that select the operating-system default browser instead of a named
|
|
@@ -112,6 +113,9 @@ function chromiumCookiePaths(profilePath, pathApi = path) {
|
|
|
112
113
|
|
|
113
114
|
export async function findCookieDatabase(browser, profilePath, platform) {
|
|
114
115
|
const pathApi = platformPath(platform ?? process.platform);
|
|
116
|
+
if (browserFamily(browser) === 'safari') {
|
|
117
|
+
return findSafariCookieFile(profilePath);
|
|
118
|
+
}
|
|
115
119
|
if (browserFamily(browser) === 'firefox') {
|
|
116
120
|
const candidate = pathApi.join(profilePath, 'cookies.sqlite');
|
|
117
121
|
return (await pathExists(candidate)) ? candidate : null;
|
|
@@ -274,6 +278,20 @@ async function listProfilesForBrowser(browser, platform, homeDir, environment) {
|
|
|
274
278
|
const family = browserFamily(browser);
|
|
275
279
|
const profiles = [];
|
|
276
280
|
for (const root of roots) {
|
|
281
|
+
if (family === 'safari') {
|
|
282
|
+
if (await findSafariCookieFile(root)) {
|
|
283
|
+
return [
|
|
284
|
+
{
|
|
285
|
+
browser,
|
|
286
|
+
name: 'Default',
|
|
287
|
+
displayName: 'Default',
|
|
288
|
+
path: root,
|
|
289
|
+
isDefault: true,
|
|
290
|
+
},
|
|
291
|
+
];
|
|
292
|
+
}
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
277
295
|
profiles.push(
|
|
278
296
|
...(family === 'firefox'
|
|
279
297
|
? await listFirefoxProfiles(browser, root, platform)
|
|
@@ -394,6 +394,36 @@
|
|
|
394
394
|
"darwin": ["org.floorp.Floorp"],
|
|
395
395
|
"linux": ["floorp.desktop"]
|
|
396
396
|
}
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
"id": "safari",
|
|
400
|
+
"family": "safari",
|
|
401
|
+
"aliases": ["apple-safari"],
|
|
402
|
+
"singleProfile": true,
|
|
403
|
+
"roots": {
|
|
404
|
+
"darwin": [
|
|
405
|
+
"{home}/Library/Containers/com.apple.Safari/Data/Library",
|
|
406
|
+
"{home}/Library"
|
|
407
|
+
]
|
|
408
|
+
},
|
|
409
|
+
"default": {
|
|
410
|
+
"darwin": ["com.apple.safari"]
|
|
411
|
+
}
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
"id": "safari-technology-preview",
|
|
415
|
+
"family": "safari",
|
|
416
|
+
"aliases": ["safari-tp", "safari-preview"],
|
|
417
|
+
"singleProfile": true,
|
|
418
|
+
"roots": {
|
|
419
|
+
"darwin": [
|
|
420
|
+
"{home}/Library/Containers/com.apple.SafariTechnologyPreview/Data/Library",
|
|
421
|
+
"{home}/Library/Safari Technology Preview"
|
|
422
|
+
]
|
|
423
|
+
},
|
|
424
|
+
"default": {
|
|
425
|
+
"darwin": ["com.apple.safaritechnologypreview"]
|
|
426
|
+
}
|
|
397
427
|
}
|
|
398
428
|
]
|
|
399
429
|
}
|
|
@@ -114,6 +114,34 @@ function mergeReport(report, className, fragment) {
|
|
|
114
114
|
report.warnings.push(...(fragment.warnings ?? []));
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
function reportSafariClasses(report, selected) {
|
|
118
|
+
for (const type of ALL_DATA_CLASSES.filter(
|
|
119
|
+
(name) => name !== 'cookies' && selected.has(name)
|
|
120
|
+
)) {
|
|
121
|
+
report.skipped.push({
|
|
122
|
+
type,
|
|
123
|
+
item: report.source.browser,
|
|
124
|
+
reason:
|
|
125
|
+
type === 'passwords'
|
|
126
|
+
? 'safari-password-export-required'
|
|
127
|
+
: 'safari-class-not-supported',
|
|
128
|
+
detail:
|
|
129
|
+
type === 'passwords'
|
|
130
|
+
? 'Safari passwords live in the Keychain. Export Passwords from Safari or the Passwords app to CSV; CSV import is tracked separately and is not supported yet.'
|
|
131
|
+
: 'Safari currently supports cookie import only; this data class has not been translated.',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (report.cookies.length > 0) {
|
|
135
|
+
report.warnings.push({
|
|
136
|
+
type: 'cookies',
|
|
137
|
+
item: report.source.browser,
|
|
138
|
+
reason: 'safari-samesite-unavailable',
|
|
139
|
+
detail:
|
|
140
|
+
'Cookies.binarycookies does not store SameSite; imported cookies use Lax.',
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
117
145
|
async function resolvePasswordKeys({
|
|
118
146
|
keys,
|
|
119
147
|
browser,
|
|
@@ -234,6 +262,11 @@ export async function migrateProfile({
|
|
|
234
262
|
}
|
|
235
263
|
}
|
|
236
264
|
|
|
265
|
+
if (browserFamily(browser) === 'safari') {
|
|
266
|
+
reportSafariClasses(report, selected);
|
|
267
|
+
return report;
|
|
268
|
+
}
|
|
269
|
+
|
|
237
270
|
if (selected.has('bookmarks')) {
|
|
238
271
|
mergeReport(
|
|
239
272
|
report,
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/** Read Safari's unencrypted, mixed-endian Cookies.binarycookies store.
|
|
2
|
+
* Format: https://github.com/libyal/dtformats/blob/main/documentation/Safari%20Cookies.asciidoc
|
|
3
|
+
* No SameSite attribute is stored in this format; use Lax and report that loss.
|
|
4
|
+
*/
|
|
5
|
+
import { readFile as readFileBytes, stat } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { TextDecoder } from 'node:util';
|
|
8
|
+
|
|
9
|
+
const APPLE_EPOCH = 978_307_200;
|
|
10
|
+
const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
11
|
+
|
|
12
|
+
function invalid(detail) {
|
|
13
|
+
throw new Error(`Invalid Safari binarycookies: ${detail}`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function bounded(data, offset, size) {
|
|
17
|
+
if (offset < 0 || size < 0 || offset + size > data.length) {
|
|
18
|
+
invalid('truncated or out-of-range data');
|
|
19
|
+
}
|
|
20
|
+
return data.subarray(offset, offset + size);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function uint(data, offset, bigEndian = false) {
|
|
24
|
+
const bytes = bounded(data, offset, 4);
|
|
25
|
+
return bigEndian ? bytes.readUInt32BE() : bytes.readUInt32LE();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function string(record, field) {
|
|
29
|
+
const offset = uint(record, field);
|
|
30
|
+
if (offset < 56 || offset >= record.length) {
|
|
31
|
+
invalid('string offset');
|
|
32
|
+
}
|
|
33
|
+
const end = record.indexOf(0, offset);
|
|
34
|
+
if (end < 0) {
|
|
35
|
+
invalid('unterminated string');
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
return utf8.decode(record.subarray(offset, end));
|
|
39
|
+
} catch {
|
|
40
|
+
return invalid('invalid UTF-8');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Validate all structural offsets before walking records; counts are bounded
|
|
45
|
+
* by the file size, so forged counts cannot cause unbounded allocations.
|
|
46
|
+
*/
|
|
47
|
+
function* records(data) {
|
|
48
|
+
if (bounded(data, 0, 4).toString('ascii') !== 'cook') {
|
|
49
|
+
invalid('file signature');
|
|
50
|
+
}
|
|
51
|
+
const pages = uint(data, 4, true);
|
|
52
|
+
bounded(data, 8, pages * 4);
|
|
53
|
+
let pageStart = 8 + pages * 4;
|
|
54
|
+
for (let index = 0; index < pages; index += 1) {
|
|
55
|
+
const page = bounded(data, pageStart, uint(data, 8 + index * 4, true));
|
|
56
|
+
pageStart += page.length;
|
|
57
|
+
if (uint(page, 0) !== 0x00010000) {
|
|
58
|
+
invalid('page signature');
|
|
59
|
+
}
|
|
60
|
+
const count = uint(page, 4);
|
|
61
|
+
const headerSize = 8 + count * 4;
|
|
62
|
+
bounded(page, 0, headerSize);
|
|
63
|
+
let previousEnd = headerSize;
|
|
64
|
+
for (let cookie = 0; cookie < count; cookie += 1) {
|
|
65
|
+
const start = uint(page, 8 + cookie * 4);
|
|
66
|
+
if (start < previousEnd) {
|
|
67
|
+
invalid('overlapping record');
|
|
68
|
+
}
|
|
69
|
+
const size = uint(page, start);
|
|
70
|
+
if (size < 56) {
|
|
71
|
+
invalid('short record');
|
|
72
|
+
}
|
|
73
|
+
const record = bounded(page, start, size);
|
|
74
|
+
previousEnd = start + size;
|
|
75
|
+
yield record;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Safari may append a checksum and a plist trailer. They are not cookies.
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Decode cookies in automation shape, using the existing substring filter. */
|
|
82
|
+
export function parseSafariCookies(data, domainFilter) {
|
|
83
|
+
const cookies = [];
|
|
84
|
+
for (const record of records(data)) {
|
|
85
|
+
const domain = string(record, 16);
|
|
86
|
+
if (
|
|
87
|
+
domainFilter &&
|
|
88
|
+
!domain.toLowerCase().includes(domainFilter.toLowerCase())
|
|
89
|
+
) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const expiry = record.readDoubleLE(40) + APPLE_EPOCH;
|
|
93
|
+
if (
|
|
94
|
+
!Number.isFinite(expiry) ||
|
|
95
|
+
Math.abs(expiry) > Number.MAX_SAFE_INTEGER
|
|
96
|
+
) {
|
|
97
|
+
invalid('expiry');
|
|
98
|
+
}
|
|
99
|
+
const flags = uint(record, 8);
|
|
100
|
+
cookies.push({
|
|
101
|
+
name: string(record, 20),
|
|
102
|
+
value: string(record, 28),
|
|
103
|
+
domain,
|
|
104
|
+
path: string(record, 24) || '/',
|
|
105
|
+
expires: Math.floor(expiry),
|
|
106
|
+
httpOnly: Boolean(flags & 4),
|
|
107
|
+
secure: Boolean(flags & 1),
|
|
108
|
+
sameSite: 'Lax',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return cookies;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Counts decode only domain strings. Names and values are never decoded. */
|
|
115
|
+
export function countSafariCookies(data, domains) {
|
|
116
|
+
const byDomain = domains?.length
|
|
117
|
+
? Object.fromEntries(domains.map((domain) => [domain, 0]))
|
|
118
|
+
: null;
|
|
119
|
+
let total = 0;
|
|
120
|
+
for (const record of records(data)) {
|
|
121
|
+
const host = string(record, 16).toLowerCase();
|
|
122
|
+
total += 1;
|
|
123
|
+
for (const domain of Object.keys(byDomain ?? {})) {
|
|
124
|
+
if (host.includes(domain.toLowerCase())) {
|
|
125
|
+
byDomain[domain] += 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { total, byDomain };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** A protected path is still a source: preserve it for an actionable read error. */
|
|
133
|
+
export async function findSafariCookieFile(profileDir, statFile = stat) {
|
|
134
|
+
for (const candidate of [
|
|
135
|
+
path.join(profileDir, 'Cookies', 'Cookies.binarycookies'),
|
|
136
|
+
path.join(profileDir, 'Cookies.binarycookies'),
|
|
137
|
+
]) {
|
|
138
|
+
try {
|
|
139
|
+
if ((await statFile(candidate)).isFile()) {
|
|
140
|
+
return candidate;
|
|
141
|
+
}
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (['EPERM', 'EACCES'].includes(error.code)) {
|
|
144
|
+
return candidate;
|
|
145
|
+
}
|
|
146
|
+
if (!['ENOENT', 'ENOTDIR'].includes(error.code)) {
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function readSafariCookieFile(
|
|
155
|
+
filePath,
|
|
156
|
+
{ environment = process.env, readFile = readFileBytes } = {}
|
|
157
|
+
) {
|
|
158
|
+
try {
|
|
159
|
+
return await readFile(filePath);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (!['EPERM', 'EACCES'].includes(error.code)) {
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
const app =
|
|
165
|
+
environment.__CFBundleIdentifier ||
|
|
166
|
+
environment.TERM_PROGRAM ||
|
|
167
|
+
process.execPath;
|
|
168
|
+
throw new Error(
|
|
169
|
+
`Safari cookie access denied (${error.code}) at ${filePath}. Grant Full Disk Access to ${app}, the app running Browser Commander, then retry: x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles`,
|
|
170
|
+
{ cause: error }
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// feature-parity: sources.safari-cookies@native-typed
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { EventEmitter } from 'node:events';
|
|
4
|
+
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { PassThrough } from 'node:stream';
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
listCookieSources,
|
|
11
|
+
readBrowserCookiesWithDependencies,
|
|
12
|
+
resolveImportSource,
|
|
13
|
+
} from '../../../src/browser/browser-cookies.js';
|
|
14
|
+
import { listBrowserProfiles } from '../../../src/browser/browser-profiles.js';
|
|
15
|
+
import { migrateProfile } from '../../../src/browser/migration/index.js';
|
|
16
|
+
import { runCli } from '../../../src/cli/main.js';
|
|
17
|
+
import { runScript } from '../../../src/cli/script.js';
|
|
18
|
+
import {
|
|
19
|
+
findSafariCookieFile,
|
|
20
|
+
parseSafariCookies,
|
|
21
|
+
readSafariCookieFile,
|
|
22
|
+
} from '../../../src/browser/safari-cookies.js';
|
|
23
|
+
import { repoPath } from '../../helpers/repo.js';
|
|
24
|
+
import { useTempDirectories } from '../../helpers/temp-directory.js';
|
|
25
|
+
|
|
26
|
+
const fixture = await readFile(
|
|
27
|
+
repoPath('tests/fixtures/safari/Cookies.binarycookies')
|
|
28
|
+
);
|
|
29
|
+
const expected = JSON.parse(
|
|
30
|
+
await readFile(repoPath('tests/fixtures/safari/expected.json'), 'utf8')
|
|
31
|
+
);
|
|
32
|
+
const makeDirectory = useTempDirectories();
|
|
33
|
+
const safariDefault = async () =>
|
|
34
|
+
'( { LSHandlerURLScheme = https; LSHandlerRoleAll = "com.apple.Safari"; } )';
|
|
35
|
+
|
|
36
|
+
async function installed(browser = 'safari', legacy = false) {
|
|
37
|
+
const homeDir = await makeDirectory('bc-safari-');
|
|
38
|
+
const bundle =
|
|
39
|
+
browser === 'safari'
|
|
40
|
+
? 'com.apple.Safari'
|
|
41
|
+
: 'com.apple.SafariTechnologyPreview';
|
|
42
|
+
const root = legacy
|
|
43
|
+
? path.join(homeDir, 'Library')
|
|
44
|
+
: path.join(homeDir, 'Library', 'Containers', bundle, 'Data', 'Library');
|
|
45
|
+
await mkdir(path.join(root, 'Cookies'), { recursive: true });
|
|
46
|
+
const file = path.join(root, 'Cookies', 'Cookies.binarycookies');
|
|
47
|
+
await copyFile(repoPath('tests/fixtures/safari/Cookies.binarycookies'), file);
|
|
48
|
+
return { homeDir, root, file, platform: 'darwin', environment: {} };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('Safari cookies', { timeout: 10000 }, () => {
|
|
52
|
+
it('decodes multiple pages, flags, UTF-8 and Apple epoch without guessing session cookies', () => {
|
|
53
|
+
assert.deepEqual(parseSafariCookies(fixture), expected);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('rejects truncated data and forged counts, record sizes, offsets, strings and expiry', () => {
|
|
57
|
+
// The last eight bytes are an optional footer; every shorter prefix is invalid.
|
|
58
|
+
for (let end = 0; end < fixture.length - 8; end += 1) {
|
|
59
|
+
assert.throws(
|
|
60
|
+
() => parseSafariCookies(fixture.subarray(0, end)),
|
|
61
|
+
/Invalid Safari binarycookies/
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
for (const [offset, value] of [
|
|
65
|
+
[4, 0xffffffff],
|
|
66
|
+
[24, 0xffffffff],
|
|
67
|
+
[28, 0xffffffff],
|
|
68
|
+
[40, 0xffffffff],
|
|
69
|
+
[56, 0],
|
|
70
|
+
]) {
|
|
71
|
+
const broken = Buffer.from(fixture);
|
|
72
|
+
broken.writeUInt32LE(value, offset);
|
|
73
|
+
assert.throws(
|
|
74
|
+
() => parseSafariCookies(broken),
|
|
75
|
+
/Invalid Safari binarycookies/
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const broken = Buffer.from(fixture);
|
|
79
|
+
broken.writeDoubleLE(NaN, 80);
|
|
80
|
+
assert.throws(
|
|
81
|
+
() => parseSafariCookies(broken),
|
|
82
|
+
/Invalid Safari binarycookies/
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('reads both Safari variants and the legacy path without a keystore', async () => {
|
|
87
|
+
for (const [browser, legacy] of [
|
|
88
|
+
['safari', false],
|
|
89
|
+
['safari-technology-preview', false],
|
|
90
|
+
['safari', true],
|
|
91
|
+
]) {
|
|
92
|
+
const options = await installed(browser, legacy);
|
|
93
|
+
const before = await readFile(options.file);
|
|
94
|
+
const profiles = await listBrowserProfiles({ ...options, browser });
|
|
95
|
+
assert.equal(profiles[0].path, options.root);
|
|
96
|
+
const cookies = await readBrowserCookiesWithDependencies(
|
|
97
|
+
{ browser, cache: false },
|
|
98
|
+
options
|
|
99
|
+
);
|
|
100
|
+
assert.deepEqual(cookies, expected);
|
|
101
|
+
assert.deepEqual(await readFile(options.file), before);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('lists counts without decoding cookie values and supports default domain import', async () => {
|
|
106
|
+
const options = await installed();
|
|
107
|
+
const damagedValue = Buffer.from(fixture);
|
|
108
|
+
damagedValue[40 + damagedValue.readUInt32LE(68)] = 0xff;
|
|
109
|
+
await writeFile(options.file, damagedValue);
|
|
110
|
+
const sources = await listCookieSources({
|
|
111
|
+
...options,
|
|
112
|
+
domains: ['GITHUB.COM'],
|
|
113
|
+
});
|
|
114
|
+
assert.equal(sources.length, 1);
|
|
115
|
+
assert.equal(sources[0].cookies, 4);
|
|
116
|
+
assert.deepEqual(sources[0].byDomain, { 'GITHUB.COM': 2 });
|
|
117
|
+
assert.ok(!JSON.stringify(sources).includes('fixture-token'));
|
|
118
|
+
await writeFile(options.file, fixture);
|
|
119
|
+
const source = await resolveImportSource({
|
|
120
|
+
...options,
|
|
121
|
+
browser: 'default',
|
|
122
|
+
domains: ['github.com'],
|
|
123
|
+
runCommand: safariDefault,
|
|
124
|
+
});
|
|
125
|
+
assert.equal(source.browser, 'safari');
|
|
126
|
+
assert.equal(source.warning, null);
|
|
127
|
+
const report = await migrateProfile({
|
|
128
|
+
...options,
|
|
129
|
+
from: { browser: 'auto' },
|
|
130
|
+
to: path.join(options.homeDir, 'target'),
|
|
131
|
+
domains: ['github.com', 'GITHUB.COM'],
|
|
132
|
+
runCommand: safariDefault,
|
|
133
|
+
});
|
|
134
|
+
assert.deepEqual(report.cookies, expected.slice(0, 2));
|
|
135
|
+
assert.equal(report.migrated.cookies, 2);
|
|
136
|
+
assert.equal(report.skipped.length, 5);
|
|
137
|
+
assert.equal(
|
|
138
|
+
report.skipped.find((entry) => entry.type === 'passwords').reason,
|
|
139
|
+
'safari-password-export-required'
|
|
140
|
+
);
|
|
141
|
+
assert.ok(
|
|
142
|
+
report.warnings.some(
|
|
143
|
+
(entry) => entry.reason === 'safari-samesite-unavailable'
|
|
144
|
+
)
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('reads an explicit source directory and reports actionable Full Disk Access errors', async () => {
|
|
149
|
+
const options = await installed();
|
|
150
|
+
const cookies = await readBrowserCookiesWithDependencies(
|
|
151
|
+
{
|
|
152
|
+
browser: 'safari',
|
|
153
|
+
profileDir: options.root,
|
|
154
|
+
domainFilter: 'github.com',
|
|
155
|
+
cache: false,
|
|
156
|
+
},
|
|
157
|
+
{ homeDir: path.join(options.homeDir, 'empty') }
|
|
158
|
+
);
|
|
159
|
+
assert.deepEqual(cookies, expected.slice(0, 2));
|
|
160
|
+
const error = Object.assign(new Error('Operation not permitted'), {
|
|
161
|
+
code: 'EPERM',
|
|
162
|
+
});
|
|
163
|
+
assert.ok(
|
|
164
|
+
await findSafariCookieFile(options.root, async () => {
|
|
165
|
+
throw error;
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
await assert.rejects(
|
|
169
|
+
readSafariCookieFile(options.file, {
|
|
170
|
+
environment: { TERM_PROGRAM: 'TestTerminal' },
|
|
171
|
+
readFile: async () => {
|
|
172
|
+
throw error;
|
|
173
|
+
},
|
|
174
|
+
}),
|
|
175
|
+
/Full Disk Access.*TestTerminal.*x-apple.systempreferences:com.apple.preference.security\?Privacy_AllFiles/
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('preserves unreadable source errors instead of silently treating the default as empty', async () => {
|
|
180
|
+
const options = await installed();
|
|
181
|
+
await writeFile(options.file, Buffer.from('corrupt'));
|
|
182
|
+
const sources = await listCookieSources({
|
|
183
|
+
...options,
|
|
184
|
+
domains: ['github.com'],
|
|
185
|
+
});
|
|
186
|
+
assert.match(sources[0].error, /Invalid Safari binarycookies/);
|
|
187
|
+
await assert.rejects(
|
|
188
|
+
resolveImportSource({
|
|
189
|
+
...options,
|
|
190
|
+
browser: 'default',
|
|
191
|
+
domains: ['github.com'],
|
|
192
|
+
runCommand: safariDefault,
|
|
193
|
+
}),
|
|
194
|
+
/Could not inspect the default browser.*Invalid Safari binarycookies/
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('imports through the CLI and command-stream dispatcher using the real reader', async () => {
|
|
199
|
+
const options = await installed();
|
|
200
|
+
const to = path.join(options.homeDir, 'target');
|
|
201
|
+
const output = new PassThrough();
|
|
202
|
+
const chunks = [];
|
|
203
|
+
output.on('data', (chunk) => chunks.push(chunk.toString()));
|
|
204
|
+
const exitCode = await runCli(
|
|
205
|
+
[
|
|
206
|
+
'profile',
|
|
207
|
+
'migrate',
|
|
208
|
+
'--from',
|
|
209
|
+
'safari',
|
|
210
|
+
'--user-data-dir',
|
|
211
|
+
options.root,
|
|
212
|
+
'--include',
|
|
213
|
+
'cookies',
|
|
214
|
+
'--domain',
|
|
215
|
+
'github.com',
|
|
216
|
+
'--to',
|
|
217
|
+
to,
|
|
218
|
+
],
|
|
219
|
+
{
|
|
220
|
+
stdout: output,
|
|
221
|
+
stdin: new PassThrough(),
|
|
222
|
+
signals: new EventEmitter(),
|
|
223
|
+
}
|
|
224
|
+
);
|
|
225
|
+
const report = JSON.parse(chunks.join(''));
|
|
226
|
+
assert.equal(exitCode, 0, JSON.stringify(report));
|
|
227
|
+
assert.deepEqual(report.cookies, expected.slice(0, 2));
|
|
228
|
+
const script = await runScript({
|
|
229
|
+
steps: [
|
|
230
|
+
{
|
|
231
|
+
method: 'profile.migrate',
|
|
232
|
+
params: {
|
|
233
|
+
from: 'safari',
|
|
234
|
+
userDataDir: options.root,
|
|
235
|
+
to,
|
|
236
|
+
include: ['cookies'],
|
|
237
|
+
domains: ['github.com'],
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
],
|
|
241
|
+
});
|
|
242
|
+
assert.equal(script.failed, false, JSON.stringify(script));
|
|
243
|
+
assert.deepEqual(script.results[0].result.cookies, expected.slice(0, 2));
|
|
244
|
+
assert.deepEqual(await readFile(options.file), fixture);
|
|
245
|
+
});
|
|
246
|
+
});
|