browser-commander 0.21.2 → 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/.jscpd-baseline.json +5 -6
- package/CHANGELOG.md +43 -0
- package/package.json +1 -1
- package/src/browser/browser-cookie-credentials.js +15 -24
- package/src/browser/browser-cookies.js +251 -11
- package/src/browser/browser-profiles.js +163 -79
- package/src/browser/browser-sources.js +184 -0
- package/src/browser/browser-sources.json +429 -0
- package/src/browser/default-browser.js +176 -0
- package/src/browser/migration/cookies.js +3 -0
- package/src/browser/migration/index.js +74 -14
- package/src/browser/safari-cookies.js +173 -0
- package/src/cli/args.js +10 -0
- package/src/cli/commands.js +27 -0
- package/src/cli/high-level-methods.js +28 -2
- package/src/cli/modules.js +6 -0
- package/src/exports.js +4 -0
- package/tests/helpers/cli-fakes.js +21 -0
- package/tests/helpers/migration-fixtures.js +30 -1
- package/tests/unit/browser/browser-cookies-profiledir.test.js +36 -0
- package/tests/unit/browser/browser-cookies.test.js +2 -11
- package/tests/unit/browser/browser-profiles-sources.test.js +150 -0
- package/tests/unit/browser/browser-sources.test.js +154 -0
- package/tests/unit/browser/cookie-sources.test.js +146 -0
- package/tests/unit/browser/default-browser.test.js +121 -0
- package/tests/unit/browser/migration/index.test.js +62 -1
- package/tests/unit/browser/safari-cookies.test.js +246 -0
- package/tests/unit/cli/main.test.js +32 -5
- package/tests/unit/scripts/browser-support.test.js +56 -0
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
|
|
4
|
+
import { resolveImportSource } from '../browser-cookies.js';
|
|
5
|
+
import { browserFamily, isSingleProfileBrowser } from '../browser-sources.js';
|
|
4
6
|
import {
|
|
5
7
|
browserProfileRoot,
|
|
6
|
-
normalizeCookieBrowser,
|
|
7
8
|
resolveBrowserProfile,
|
|
8
9
|
} from '../browser-profiles.js';
|
|
9
10
|
import { migrateBookmarks } from './bookmarks.js';
|
|
@@ -48,7 +49,17 @@ export const ALL_DATA_CLASSES = Object.freeze([
|
|
|
48
49
|
'extensions',
|
|
49
50
|
]);
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
// Classification is driven by the shared catalogue so every Chromium variant
|
|
53
|
+
// (vivaldi, opera, arc, the chrome/edge channels, …) and every Firefox fork
|
|
54
|
+
// (librewolf, waterfox, zen, floorp, the firefox channels, …) is handled the
|
|
55
|
+
// same way as its canonical engine, not just the original four browsers.
|
|
56
|
+
function isChromiumBrowser(browser) {
|
|
57
|
+
return browserFamily(browser) === 'chromium';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isFirefoxBrowser(browser) {
|
|
61
|
+
return browserFamily(browser) === 'firefox';
|
|
62
|
+
}
|
|
52
63
|
|
|
53
64
|
function emptyMigrated() {
|
|
54
65
|
return {
|
|
@@ -69,21 +80,23 @@ async function resolveSourceProfileDir({
|
|
|
69
80
|
homeDir,
|
|
70
81
|
environment,
|
|
71
82
|
}) {
|
|
83
|
+
// A Chromium profile lives in a named subdirectory of the user data dir,
|
|
84
|
+
// except in single-profile browsers (Opera) that keep it in the root; for
|
|
85
|
+
// Firefox the userDataDir already points at the profile.
|
|
86
|
+
const nestsProfiles =
|
|
87
|
+
isChromiumBrowser(browser) && !isSingleProfileBrowser(browser);
|
|
72
88
|
if (userDataDir) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return path.join(userDataDir, profile ?? 'Default');
|
|
77
|
-
}
|
|
78
|
-
return userDataDir;
|
|
89
|
+
return nestsProfiles
|
|
90
|
+
? path.join(userDataDir, profile ?? 'Default')
|
|
91
|
+
: userDataDir;
|
|
79
92
|
}
|
|
80
|
-
if (
|
|
93
|
+
if (isChromiumBrowser(browser)) {
|
|
81
94
|
const root = browserProfileRoot(browser, {
|
|
82
95
|
platform,
|
|
83
96
|
homeDir,
|
|
84
97
|
environment,
|
|
85
98
|
});
|
|
86
|
-
return path.join(root, profile ?? 'Default');
|
|
99
|
+
return nestsProfiles ? path.join(root, profile ?? 'Default') : root;
|
|
87
100
|
}
|
|
88
101
|
const resolved = await resolveBrowserProfile({
|
|
89
102
|
browser,
|
|
@@ -101,6 +114,34 @@ function mergeReport(report, className, fragment) {
|
|
|
101
114
|
report.warnings.push(...(fragment.warnings ?? []));
|
|
102
115
|
}
|
|
103
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
|
+
|
|
104
145
|
async function resolvePasswordKeys({
|
|
105
146
|
keys,
|
|
106
147
|
browser,
|
|
@@ -120,7 +161,7 @@ async function resolvePasswordKeys({
|
|
|
120
161
|
platform,
|
|
121
162
|
environment,
|
|
122
163
|
});
|
|
123
|
-
const resolveSourceKey =
|
|
164
|
+
const resolveSourceKey = isChromiumBrowser(browser)
|
|
124
165
|
? createSourceKeyResolver({
|
|
125
166
|
browser,
|
|
126
167
|
platform,
|
|
@@ -156,6 +197,7 @@ export async function migrateProfile({
|
|
|
156
197
|
keys,
|
|
157
198
|
homeDir = os.homedir(),
|
|
158
199
|
environment = process.env,
|
|
200
|
+
runCommand,
|
|
159
201
|
}) {
|
|
160
202
|
if (!from?.browser) {
|
|
161
203
|
throw new TypeError('migrateProfile requires from.browser');
|
|
@@ -163,9 +205,19 @@ export async function migrateProfile({
|
|
|
163
205
|
if (!to) {
|
|
164
206
|
throw new TypeError('migrateProfile requires a target directory (to)');
|
|
165
207
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const
|
|
208
|
+
// An explicit userDataDir names the source, so only an installed-browser
|
|
209
|
+
// import is steered towards the profile holding the requested domains.
|
|
210
|
+
const source = await resolveImportSource({
|
|
211
|
+
browser: from.browser,
|
|
212
|
+
domains: from.userDataDir ? undefined : domains,
|
|
213
|
+
platform,
|
|
214
|
+
homeDir,
|
|
215
|
+
environment,
|
|
216
|
+
runCommand,
|
|
217
|
+
});
|
|
218
|
+
const { browser } = source;
|
|
219
|
+
const profile = from.profile ?? source.profile ?? 'Default';
|
|
220
|
+
const isFirefox = isFirefoxBrowser(browser);
|
|
169
221
|
const sourceProfileDir = await resolveSourceProfileDir({
|
|
170
222
|
browser,
|
|
171
223
|
profile,
|
|
@@ -183,6 +235,9 @@ export async function migrateProfile({
|
|
|
183
235
|
warnings: [],
|
|
184
236
|
cookies: [],
|
|
185
237
|
};
|
|
238
|
+
if (source.warning) {
|
|
239
|
+
report.warnings.push(source.warning);
|
|
240
|
+
}
|
|
186
241
|
|
|
187
242
|
if (selected.has('cookies')) {
|
|
188
243
|
if (isFirefox) {
|
|
@@ -207,6 +262,11 @@ export async function migrateProfile({
|
|
|
207
262
|
}
|
|
208
263
|
}
|
|
209
264
|
|
|
265
|
+
if (browserFamily(browser) === 'safari') {
|
|
266
|
+
reportSafariClasses(report, selected);
|
|
267
|
+
return report;
|
|
268
|
+
}
|
|
269
|
+
|
|
210
270
|
if (selected.has('bookmarks')) {
|
|
211
271
|
mergeReport(
|
|
212
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
|
+
}
|
package/src/cli/args.js
CHANGED
|
@@ -78,11 +78,17 @@ export const COMMANDS = Object.freeze({
|
|
|
78
78
|
domain: STRINGS,
|
|
79
79
|
},
|
|
80
80
|
},
|
|
81
|
+
'cookies sources': {
|
|
82
|
+
positionals: [],
|
|
83
|
+
options: { domain: STRINGS },
|
|
84
|
+
},
|
|
81
85
|
'profile migrate': {
|
|
82
86
|
positionals: [],
|
|
83
87
|
options: {
|
|
84
88
|
from: STRING,
|
|
85
89
|
profile: STRING,
|
|
90
|
+
'user-data-dir': STRING,
|
|
91
|
+
'target-browser': STRING,
|
|
86
92
|
to: STRING,
|
|
87
93
|
include: STRING,
|
|
88
94
|
domain: STRINGS,
|
|
@@ -92,6 +98,10 @@ export const COMMANDS = Object.freeze({
|
|
|
92
98
|
positionals: [],
|
|
93
99
|
options: { from: STRING, profile: STRING, to: STRING },
|
|
94
100
|
},
|
|
101
|
+
'profile sources': {
|
|
102
|
+
positionals: [],
|
|
103
|
+
options: { browser: STRING },
|
|
104
|
+
},
|
|
95
105
|
attach: {
|
|
96
106
|
positionals: [],
|
|
97
107
|
options: { mode: STRING, port: STRING, timeout: STRING },
|
package/src/cli/commands.js
CHANGED
|
@@ -210,6 +210,8 @@ async function profileMigrate(parsed, io) {
|
|
|
210
210
|
await dispatchOnce(io, 'profile.migrate', {
|
|
211
211
|
from: requireOption(options, 'from', 'profile migrate'),
|
|
212
212
|
profile: options.profile,
|
|
213
|
+
userDataDir: options.userDataDir,
|
|
214
|
+
targetBrowser: options.targetBrowser,
|
|
213
215
|
to: options.to,
|
|
214
216
|
include: options.include
|
|
215
217
|
?.split(',')
|
|
@@ -220,6 +222,29 @@ async function profileMigrate(parsed, io) {
|
|
|
220
222
|
);
|
|
221
223
|
}
|
|
222
224
|
|
|
225
|
+
/**
|
|
226
|
+
* List the installed browser profiles that hold cookies (names and counts
|
|
227
|
+
* only, never values), optionally filtered and counted by `--domain`.
|
|
228
|
+
*/
|
|
229
|
+
async function cookieSources(parsed, io) {
|
|
230
|
+
const { options } = parsed;
|
|
231
|
+
return done(
|
|
232
|
+
await dispatchOnce(io, 'cookies.sources', {
|
|
233
|
+
domains: options.domain,
|
|
234
|
+
})
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** List installed browser profiles, optionally for one `--browser`. */
|
|
239
|
+
async function profileSources(parsed, io) {
|
|
240
|
+
const { options } = parsed;
|
|
241
|
+
return done(
|
|
242
|
+
await dispatchOnce(io, 'profile.sources', {
|
|
243
|
+
browser: options.browser,
|
|
244
|
+
})
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
223
248
|
/** Copy a real profile into `--to` (or a new temporary directory). */
|
|
224
249
|
async function profileSnapshot(parsed, io) {
|
|
225
250
|
const { options } = parsed;
|
|
@@ -323,8 +348,10 @@ export const COMMAND_HANDLERS = Object.freeze({
|
|
|
323
348
|
profile: options.profile,
|
|
324
349
|
domains: options.domain,
|
|
325
350
|
})),
|
|
351
|
+
'cookies sources': cookieSources,
|
|
326
352
|
'profile migrate': profileMigrate,
|
|
327
353
|
'profile snapshot': profileSnapshot,
|
|
354
|
+
'profile sources': profileSources,
|
|
328
355
|
attach,
|
|
329
356
|
doctor,
|
|
330
357
|
run,
|
|
@@ -191,12 +191,18 @@ async function importCookies(state, params) {
|
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
function migrateProfile(state, params) {
|
|
194
|
+
// The CLI passes the source browser flat (`--from`, `--profile`,
|
|
195
|
+
// `--user-data-dir`); migrateProfile expects them grouped under `from`.
|
|
194
196
|
return state.dependencies.migrateProfile({
|
|
195
|
-
from:
|
|
196
|
-
|
|
197
|
+
from: {
|
|
198
|
+
browser: requireString(params, 'from'),
|
|
199
|
+
profile: params.profile,
|
|
200
|
+
userDataDir: params.userDataDir,
|
|
201
|
+
},
|
|
197
202
|
to: params.to,
|
|
198
203
|
include: params.include,
|
|
199
204
|
domains: params.domains,
|
|
205
|
+
targetBrowser: params.targetBrowser,
|
|
200
206
|
});
|
|
201
207
|
}
|
|
202
208
|
|
|
@@ -209,6 +215,24 @@ function snapshotProfile(state, params) {
|
|
|
209
215
|
});
|
|
210
216
|
}
|
|
211
217
|
|
|
218
|
+
/** List browsers/profiles holding cookies, with optional per-domain counts. */
|
|
219
|
+
async function cookieSources(state, params) {
|
|
220
|
+
const domains = params.domains;
|
|
221
|
+
if (domains !== undefined && !Array.isArray(domains)) {
|
|
222
|
+
throw invalidParams('domains must be an array of strings');
|
|
223
|
+
}
|
|
224
|
+
return { sources: await state.dependencies.listCookieSources({ domains }) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** List the installed browser profiles (never cookie values). */
|
|
228
|
+
async function profileSources(state, params) {
|
|
229
|
+
return {
|
|
230
|
+
profiles: await state.dependencies.listBrowserProfiles({
|
|
231
|
+
browser: params.browser,
|
|
232
|
+
}),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
212
236
|
function relayOf(state) {
|
|
213
237
|
if (!state.relay) {
|
|
214
238
|
throw invalidParams('No extension is attached; call attach first');
|
|
@@ -298,8 +322,10 @@ export const HIGH_LEVEL_METHODS = Object.freeze({
|
|
|
298
322
|
'trace.start': traceStart,
|
|
299
323
|
'trace.stop': traceStop,
|
|
300
324
|
'cookies.import': importCookies,
|
|
325
|
+
'cookies.sources': cookieSources,
|
|
301
326
|
'profile.migrate': migrateProfile,
|
|
302
327
|
'profile.snapshot': snapshotProfile,
|
|
328
|
+
'profile.sources': profileSources,
|
|
303
329
|
attach,
|
|
304
330
|
'attach.tabs': attachTabs,
|
|
305
331
|
'attach.send': attachSend,
|
package/src/cli/modules.js
CHANGED
|
@@ -64,6 +64,12 @@ export const DEFAULT_DEPENDENCIES = Object.freeze({
|
|
|
64
64
|
writeTraceViewer,
|
|
65
65
|
readBrowserCookies: async (options) =>
|
|
66
66
|
(await import('../browser/browser-cookies.js')).readBrowserCookies(options),
|
|
67
|
+
listCookieSources: async (options) =>
|
|
68
|
+
(await import('../browser/browser-cookies.js')).listCookieSources(options),
|
|
69
|
+
listBrowserProfiles: async (options) =>
|
|
70
|
+
(await import('../browser/browser-profiles.js')).listBrowserProfiles(
|
|
71
|
+
options
|
|
72
|
+
),
|
|
67
73
|
loadEngine: (name) => importOptional(name, `The ${name} engine`),
|
|
68
74
|
openInUserBrowser: async (url) =>
|
|
69
75
|
(
|
package/src/exports.js
CHANGED
|
@@ -60,8 +60,12 @@ export {
|
|
|
60
60
|
export { connectBrowser } from './browser/connector.js';
|
|
61
61
|
export {
|
|
62
62
|
listBrowserProfiles,
|
|
63
|
+
listCookieSources,
|
|
63
64
|
readBrowserCookies,
|
|
65
|
+
resolveImportSource,
|
|
64
66
|
} from './browser/browser-cookies.js';
|
|
67
|
+
export { resolveDefaultBrowser } from './browser/default-browser.js';
|
|
68
|
+
export { BROWSER_IDS, findBrowserSource } from './browser/browser-sources.js';
|
|
65
69
|
export {
|
|
66
70
|
launchAndConnectRealBrowser,
|
|
67
71
|
launchRealBrowser,
|
|
@@ -158,6 +158,27 @@ export function createFakeDependencies() {
|
|
|
158
158
|
{ name: 'sid', value: '1', domain: `.${domainFilter}`, path: '/' },
|
|
159
159
|
{ name: 'bad', value: '2', domain: `.${domainFilter}`, path: '/' },
|
|
160
160
|
],
|
|
161
|
+
listCookieSources: async ({ domains } = {}) => [
|
|
162
|
+
{
|
|
163
|
+
browser: 'chrome',
|
|
164
|
+
profile: 'Default',
|
|
165
|
+
path: '/home/user/.config/google-chrome/Default',
|
|
166
|
+
isDefault: true,
|
|
167
|
+
cookies: 7,
|
|
168
|
+
byDomain: domains
|
|
169
|
+
? Object.fromEntries(domains.map((d) => [d, 1]))
|
|
170
|
+
: null,
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
listBrowserProfiles: async ({ browser } = {}) => [
|
|
174
|
+
{
|
|
175
|
+
browser: browser ?? 'chrome',
|
|
176
|
+
name: 'Default',
|
|
177
|
+
displayName: 'Default',
|
|
178
|
+
path: `/home/user/.config/${browser ?? 'google-chrome'}/Default`,
|
|
179
|
+
isDefault: true,
|
|
180
|
+
},
|
|
181
|
+
],
|
|
161
182
|
loadEngine: async (name) => ({ default: new FakeEngine(name) }),
|
|
162
183
|
openInUserBrowser: async (url) => ({ opened: url, command: ['open', url] }),
|
|
163
184
|
migrateProfile: async (options) => ({ migrated: {}, options }),
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import assert from 'node:assert';
|
|
11
|
-
import { readFile, stat, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
|
|
14
14
|
import BetterSqlite3 from 'better-sqlite3';
|
|
@@ -148,6 +148,35 @@ export async function writeFirefoxCookies(profileDir, rows) {
|
|
|
148
148
|
return cookiePath;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Write a Linux Firefox-family install under `home` with one default profile,
|
|
153
|
+
* listed in `profiles.ini`, whose `cookies.sqlite` holds `rows`.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} home - Home directory the install lives under
|
|
156
|
+
* @param {Array<Object>} rows - Cookie rows, as for `writeFirefoxCookies`
|
|
157
|
+
* @param {Object} [options]
|
|
158
|
+
* @param {string} [options.root='.mozilla/firefox'] - Install root, relative
|
|
159
|
+
* to `home` (a fork such as LibreWolf uses its own)
|
|
160
|
+
* @param {string} [options.name='default-release'] - Profile name
|
|
161
|
+
* @returns {Promise<string>} The profile directory
|
|
162
|
+
*/
|
|
163
|
+
export async function writeFirefoxProfile(
|
|
164
|
+
home,
|
|
165
|
+
rows,
|
|
166
|
+
{ root = '.mozilla/firefox', name = 'default-release' } = {}
|
|
167
|
+
) {
|
|
168
|
+
const rootPath = path.join(home, ...root.split('/'));
|
|
169
|
+
const profileName = `xyz.${name}`;
|
|
170
|
+
const profilePath = path.join(rootPath, profileName);
|
|
171
|
+
await mkdir(profilePath, { recursive: true });
|
|
172
|
+
await writeFile(
|
|
173
|
+
path.join(rootPath, 'profiles.ini'),
|
|
174
|
+
`[Profile0]\nName=${name}\nIsRelative=1\nPath=${profileName}\nDefault=1\n`
|
|
175
|
+
);
|
|
176
|
+
await writeFirefoxCookies(profilePath, rows);
|
|
177
|
+
return profilePath;
|
|
178
|
+
}
|
|
179
|
+
|
|
151
180
|
/**
|
|
152
181
|
* Write a Firefox `places.sqlite` with a toolbar bookmark and, unless
|
|
153
182
|
* `withMenuBookmark` is false, a bookmarks-menu one. Each bookmark has its own
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import assert from 'node:assert';
|
|
2
|
+
import { mkdir } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { describe, it } from 'node:test';
|
|
5
|
+
|
|
6
|
+
import { readBrowserCookiesWithDependencies } from '../../../src/browser/browser-cookies.js';
|
|
7
|
+
import { writeFirefoxCookies } from '../../helpers/migration-fixtures.js';
|
|
8
|
+
import { useTempDirectories } from '../../helpers/temp-directory.js';
|
|
9
|
+
|
|
10
|
+
const makeDirectory = useTempDirectories();
|
|
11
|
+
|
|
12
|
+
describe('readBrowserCookies honours an explicit profileDir', () => {
|
|
13
|
+
it('reads cookies from the given directory, not the default profile root', async () => {
|
|
14
|
+
const temporaryDirectory = await makeDirectory('bc-profiledir-');
|
|
15
|
+
// A custom userDataDir that is NOT under the default profile root.
|
|
16
|
+
const customProfile = path.join(temporaryDirectory, 'custom', 'profile');
|
|
17
|
+
await mkdir(customProfile, { recursive: true });
|
|
18
|
+
await writeFirefoxCookies(customProfile, [
|
|
19
|
+
{ name: 'sid', value: 'abc', host: '.example.com' },
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
// homeDir points somewhere with no browser data, so a reader that ignored
|
|
23
|
+
// profileDir and re-resolved the default profile would find nothing.
|
|
24
|
+
const emptyHome = path.join(temporaryDirectory, 'empty-home');
|
|
25
|
+
await mkdir(emptyHome, { recursive: true });
|
|
26
|
+
|
|
27
|
+
const cookies = await readBrowserCookiesWithDependencies(
|
|
28
|
+
{ browser: 'firefox', profileDir: customProfile },
|
|
29
|
+
{ platform: 'linux', homeDir: emptyHome }
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
assert.equal(cookies.length, 1);
|
|
33
|
+
assert.equal(cookies[0].name, 'sid');
|
|
34
|
+
assert.equal(cookies[0].domain, '.example.com');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
listBrowserProfiles as publicListBrowserProfiles,
|
|
38
38
|
readBrowserCookies as publicReadBrowserCookies,
|
|
39
39
|
} from '../../../src/index.js';
|
|
40
|
-
import {
|
|
40
|
+
import { writeFirefoxProfile } from '../../helpers/migration-fixtures.js';
|
|
41
41
|
|
|
42
42
|
const CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600;
|
|
43
43
|
const execFile = promisify(execFileCallback);
|
|
@@ -134,16 +134,7 @@ async function createChromiumProfile({ homeDir, rows, profile = 'Default' }) {
|
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
async function createFirefoxProfile({ homeDir, rows }) {
|
|
137
|
-
|
|
138
|
-
const profileName = 'fixture.default-release';
|
|
139
|
-
const profilePath = path.join(root, profileName);
|
|
140
|
-
await mkdir(profilePath, { recursive: true });
|
|
141
|
-
await writeFile(
|
|
142
|
-
path.join(root, 'profiles.ini'),
|
|
143
|
-
`[Profile0]\nName=default-release\nIsRelative=1\nPath=${profileName}\nDefault=1\n`
|
|
144
|
-
);
|
|
145
|
-
const cookiePath = await writeFirefoxCookies(profilePath, rows);
|
|
146
|
-
return { cookiePath, profilePath, root };
|
|
137
|
+
return { profilePath: await writeFirefoxProfile(homeDir, rows) };
|
|
147
138
|
}
|
|
148
139
|
|
|
149
140
|
describe('installed browser cookie import', () => {
|