browser-commander 0.24.0 → 0.25.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.
Files changed (60) hide show
  1. package/.jscpd-baseline.json +1 -1
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +7 -4
  4. package/package.json +4 -1
  5. package/src/browser/attach/snapshot.js +4 -28
  6. package/src/browser/browser-cookie-credentials.js +15 -8
  7. package/src/browser/browser-cookies.js +36 -30
  8. package/src/browser/browser-profile-files.js +31 -0
  9. package/src/browser/browser-profiles.js +11 -13
  10. package/src/browser/browser-sources.js +39 -9
  11. package/src/browser/browser-sources.json +600 -23
  12. package/src/browser/migration/capabilities.json +117 -0
  13. package/src/browser/migration/chromium-history.sql +22 -0
  14. package/src/browser/migration/chromium-writers.js +85 -0
  15. package/src/browser/migration/cookies.js +4 -1
  16. package/src/browser/migration/data-classes.js +68 -0
  17. package/src/browser/migration/domains.js +32 -0
  18. package/src/browser/migration/firefox-history.js +106 -0
  19. package/src/browser/migration/firefox.js +39 -25
  20. package/src/browser/migration/history.js +161 -2
  21. package/src/browser/migration/index.js +229 -83
  22. package/src/browser/migration/os-crypt-keys.js +1 -5
  23. package/src/browser/migration/password-metadata.js +95 -0
  24. package/src/browser/migration/passwords.js +78 -41
  25. package/src/browser/migration/safari-import.js +100 -0
  26. package/src/browser/migration/safari.js +110 -0
  27. package/src/browser/migration/sqlite-snapshot.js +31 -65
  28. package/src/browser/migration/validation.js +48 -0
  29. package/src/browser/real-browser.js +13 -1
  30. package/src/browser/safari-access.js +45 -0
  31. package/src/browser/safari-cookies.js +39 -36
  32. package/src/browser/safari-profiles.js +83 -0
  33. package/src/browser/system-browser.js +31 -143
  34. package/src/cli/args.js +2 -0
  35. package/src/cli/commands.js +2 -0
  36. package/src/cli/high-level-methods.js +2 -0
  37. package/tests/unit/browser/attach/snapshot.test.js +30 -0
  38. package/tests/unit/browser/browser-cookies.test.js +35 -0
  39. package/tests/unit/browser/catalogue-launch.test.js +93 -0
  40. package/tests/unit/browser/cookie-sources.test.js +92 -17
  41. package/tests/unit/browser/credential-catalogue.test.js +50 -0
  42. package/tests/unit/browser/firefox-cookie-expiry.test.js +79 -0
  43. package/tests/unit/browser/migration/data-classes.test.js +89 -0
  44. package/tests/unit/browser/migration/domain-isolation.test.js +212 -0
  45. package/tests/unit/browser/migration/firefox-history.test.js +130 -0
  46. package/tests/unit/browser/migration/history-metadata.test.js +74 -0
  47. package/tests/unit/browser/migration/index.test.js +16 -1
  48. package/tests/unit/browser/migration/opera-keys.test.js +22 -0
  49. package/tests/unit/browser/migration/safari-data.test.js +143 -0
  50. package/tests/unit/browser/migration/sqlite-snapshot.test.js +56 -1
  51. package/tests/unit/browser/migration/validation.test.js +63 -0
  52. package/tests/unit/browser/migration/yandex.test.js +84 -0
  53. package/tests/unit/browser/real-browser.test.js +19 -0
  54. package/tests/unit/browser/safari-cookies.test.js +26 -1
  55. package/tests/unit/browser/safari-profiles.test.js +111 -0
  56. package/tests/unit/cli/dispatcher.test.js +4 -0
  57. package/tests/unit/cli/main.test.js +5 -0
  58. package/tests/unit/scripts/browser-support.test.js +14 -7
  59. package/tests/unit/scripts/check-pipeline-status.test.js +33 -1
  60. package/tests/unit/scripts/migration-support.test.js +31 -0
@@ -0,0 +1,45 @@
1
+ import { stat } from 'node:fs/promises';
2
+
3
+ /** A protected path remains a source, so reads can explain Full Disk Access. */
4
+ export async function findSafariFile(candidates, statFile = stat) {
5
+ for (const candidate of candidates) {
6
+ try {
7
+ if ((await statFile(candidate)).isFile()) {
8
+ return candidate;
9
+ }
10
+ } catch (error) {
11
+ if (['EPERM', 'EACCES'].includes(error.code)) {
12
+ return candidate;
13
+ }
14
+ if (!['ENOENT', 'ENOTDIR'].includes(error.code)) {
15
+ throw error;
16
+ }
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+
22
+ /** Enrich protection failures for every Safari store, preserving the cause. */
23
+ export async function withSafariAccess(
24
+ filename,
25
+ read,
26
+ environment = process.env
27
+ ) {
28
+ try {
29
+ return await read();
30
+ } catch (error) {
31
+ if (!['EPERM', 'EACCES'].includes(error.code)) {
32
+ throw error;
33
+ }
34
+ const app =
35
+ environment.__CFBundleIdentifier ||
36
+ environment.TERM_PROGRAM ||
37
+ process.execPath;
38
+ const diagnostic = new Error(
39
+ `Safari access denied (${error.code}) at ${filename}. Grant Full Disk Access to ${app}, the app running Browser Commander, then retry: x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles`,
40
+ { cause: error }
41
+ );
42
+ diagnostic.code = error.code;
43
+ throw diagnostic;
44
+ }
45
+ }
@@ -4,7 +4,9 @@
4
4
  */
5
5
  import { readFile as readFileBytes, stat } from 'node:fs/promises';
6
6
  import path from 'node:path';
7
+ import { findSafariFile, withSafariAccess } from './safari-access.js';
7
8
  import { TextDecoder } from 'node:util';
9
+ import { matchesDomains } from './migration/domains.js';
8
10
 
9
11
  const APPLE_EPOCH = 978_307_200;
10
12
  const utf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
@@ -121,7 +123,7 @@ export function countSafariCookies(data, domains) {
121
123
  const host = string(record, 16).toLowerCase();
122
124
  total += 1;
123
125
  for (const domain of Object.keys(byDomain ?? {})) {
124
- if (host.includes(domain.toLowerCase())) {
126
+ if (matchesDomains(host, [domain])) {
125
127
  byDomain[domain] += 1;
126
128
  }
127
129
  }
@@ -130,44 +132,45 @@ export function countSafariCookies(data, domains) {
130
132
  }
131
133
 
132
134
  /** 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;
135
+ export function findSafariCookieFile(profileDir, statFile = stat) {
136
+ const named =
137
+ path.basename(path.dirname(profileDir)) === 'Profiles' &&
138
+ path.basename(path.dirname(path.dirname(profileDir))) === 'Safari';
139
+ const root = named
140
+ ? path.dirname(path.dirname(path.dirname(profileDir)))
141
+ : profileDir;
142
+ const modern = named
143
+ ? [
144
+ path.join(
145
+ root,
146
+ 'WebKit/WebsiteDataStore',
147
+ path.basename(profileDir).toLowerCase(),
148
+ 'Cookies/Cookies.binarycookies'
149
+ ),
150
+ ]
151
+ : [
152
+ path.join(
153
+ root,
154
+ 'WebKit/WebsiteData/Default/Cookies/Cookies.binarycookies'
155
+ ),
156
+ path.join(
157
+ root,
158
+ 'WebKit/WebsiteDataStore/Default/Cookies/Cookies.binarycookies'
159
+ ),
160
+ ];
161
+ return findSafariFile(
162
+ [
163
+ ...modern,
164
+ path.join(profileDir, 'Cookies', 'Cookies.binarycookies'),
165
+ path.join(profileDir, 'Cookies.binarycookies'),
166
+ ],
167
+ statFile
168
+ );
152
169
  }
153
170
 
154
- export async function readSafariCookieFile(
171
+ export function readSafariCookieFile(
155
172
  filePath,
156
173
  { environment = process.env, readFile = readFileBytes } = {}
157
174
  ) {
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
- }
175
+ return withSafariAccess(filePath, () => readFile(filePath), environment);
173
176
  }
@@ -0,0 +1,83 @@
1
+ import path from 'node:path';
2
+ import { open } from 'node:fs/promises';
3
+ import { findSafariCookieFile } from './safari-cookies.js';
4
+ import { findSafariFile, withSafariAccess } from './safari-access.js';
5
+ import { openSqliteDatabase } from './browser-cookie-database.js';
6
+
7
+ /** SafariTabs supplies names; each UUID has separate Safari and WebKit stores. */
8
+ export async function listSafariProfiles(browser, root) {
9
+ const profiles = [];
10
+ const defaultFile =
11
+ (await findSafariCookieFile(root)) ??
12
+ (await findSafariFile([
13
+ path.join(root, 'Safari/History.db'),
14
+ path.join(root, 'History.db'),
15
+ path.join(root, 'Safari/Bookmarks.plist'),
16
+ path.join(root, 'Bookmarks.plist'),
17
+ ]));
18
+ if (defaultFile) {
19
+ profiles.push({
20
+ browser,
21
+ name: 'Default',
22
+ displayName: 'Default',
23
+ path: root,
24
+ isDefault: true,
25
+ });
26
+ }
27
+ const tabs = await findSafariFile([
28
+ path.join(root, 'Safari/SafariTabs.db'),
29
+ path.join(root, 'SafariTabs.db'),
30
+ ]);
31
+ if (!tabs) {
32
+ return profiles;
33
+ }
34
+ let rows;
35
+ try {
36
+ rows = await withSafariAccess(tabs, async () => {
37
+ const file = await open(tabs, 'r');
38
+ await file.close();
39
+ const db = await openSqliteDatabase(tabs, {
40
+ readOnly: true,
41
+ fileMustExist: true,
42
+ });
43
+ try {
44
+ return db
45
+ .prepare(
46
+ "SELECT DISTINCT external_uuid,title FROM bookmarks WHERE subtype=2 AND external_uuid != 'DefaultProfile' ORDER BY external_uuid"
47
+ )
48
+ .all();
49
+ } finally {
50
+ db.close();
51
+ }
52
+ });
53
+ } catch (error) {
54
+ profiles.push({
55
+ browser,
56
+ name: 'Profiles',
57
+ displayName: 'Safari profile discovery',
58
+ path: root,
59
+ isDefault: false,
60
+ error: error.message,
61
+ });
62
+ return profiles;
63
+ }
64
+ for (const row of rows) {
65
+ if (
66
+ !/^[\da-f]{8}(?:-[\da-f]{4}){3}-[\da-f]{12}$/iu.test(
67
+ row.external_uuid ?? ''
68
+ )
69
+ ) {
70
+ continue;
71
+ }
72
+ const name = row.external_uuid.toUpperCase();
73
+ const profilePath = path.join(root, 'Safari/Profiles', name);
74
+ profiles.push({
75
+ browser,
76
+ name,
77
+ displayName: row.title ?? name,
78
+ path: profilePath,
79
+ isDefault: false,
80
+ });
81
+ }
82
+ return profiles;
83
+ }
@@ -1,4 +1,10 @@
1
1
  import { constants } from 'node:fs';
2
+ import { physicalPath } from './browser-profile-files.js';
3
+ import {
4
+ BROWSER_SOURCES,
5
+ resolveBrowserRoots,
6
+ resolveBrowserExecutables,
7
+ } from './browser-sources.js';
2
8
  import { access } from 'node:fs/promises';
3
9
  import os from 'node:os';
4
10
  import path from 'node:path';
@@ -9,18 +15,16 @@ import path from 'node:path';
9
15
  * to refuse a default profile; profile migration uses it to find the source.
10
16
  */
11
17
 
12
- export const CHANNEL_EXECUTABLE_NAMES = {
13
- brave: ['brave-browser', 'brave-browser-stable', 'brave'],
14
- chrome: ['google-chrome', 'google-chrome-stable', 'chrome'],
15
- 'chrome-beta': ['google-chrome-beta'],
16
- 'chrome-canary': ['google-chrome-canary'],
17
- 'chrome-dev': ['google-chrome-unstable'],
18
- chromium: ['chromium', 'chromium-browser'],
19
- msedge: ['microsoft-edge', 'microsoft-edge-stable', 'msedge'],
20
- 'msedge-beta': ['microsoft-edge-beta'],
21
- 'msedge-canary': ['microsoft-edge-canary'],
22
- 'msedge-dev': ['microsoft-edge-dev'],
23
- };
18
+ export const CHANNEL_EXECUTABLE_NAMES = Object.freeze(
19
+ Object.fromEntries(
20
+ BROWSER_SOURCES.flatMap((browser) =>
21
+ [browser.id, ...(browser.aliases ?? [])].map((name) => [
22
+ name,
23
+ browser.executableNames ?? [],
24
+ ])
25
+ )
26
+ )
27
+ );
24
28
 
25
29
  function platformPath(platform) {
26
30
  return platform === 'win32' ? path.win32 : path;
@@ -41,72 +45,13 @@ export function knownDefaultUserDataDirs({
41
45
  homeDir = os.homedir(),
42
46
  environment = process.env,
43
47
  } = {}) {
44
- const pathApi = platformPath(platform);
45
- if (platform === 'darwin') {
46
- const applicationSupport = pathApi.join(
48
+ return BROWSER_SOURCES.flatMap((browser) =>
49
+ resolveBrowserRoots(browser.id, {
50
+ platform,
47
51
  homeDir,
48
- 'Library',
49
- 'Application Support'
50
- );
51
- return [
52
- pathApi.join(applicationSupport, 'Google', 'Chrome'),
53
- pathApi.join(applicationSupport, 'Google', 'Chrome Beta'),
54
- pathApi.join(applicationSupport, 'Google', 'Chrome Canary'),
55
- pathApi.join(applicationSupport, 'Google', 'Chrome Dev'),
56
- pathApi.join(applicationSupport, 'Chromium'),
57
- pathApi.join(applicationSupport, 'BraveSoftware', 'Brave-Browser'),
58
- pathApi.join(applicationSupport, 'BraveSoftware', 'Brave-Browser-Beta'),
59
- pathApi.join(
60
- applicationSupport,
61
- 'BraveSoftware',
62
- 'Brave-Browser-Nightly'
63
- ),
64
- pathApi.join(applicationSupport, 'Microsoft Edge'),
65
- pathApi.join(applicationSupport, 'Microsoft Edge Beta'),
66
- pathApi.join(applicationSupport, 'Microsoft Edge Canary'),
67
- pathApi.join(applicationSupport, 'Microsoft Edge Dev'),
68
- ];
69
- }
70
- if (platform === 'win32') {
71
- const localAppData =
72
- environment.LOCALAPPDATA ?? pathApi.join(homeDir, 'AppData', 'Local');
73
- return [
74
- pathApi.join(localAppData, 'Google', 'Chrome', 'User Data'),
75
- pathApi.join(localAppData, 'Google', 'Chrome Beta', 'User Data'),
76
- pathApi.join(localAppData, 'Google', 'Chrome Dev', 'User Data'),
77
- pathApi.join(localAppData, 'Google', 'Chrome SxS', 'User Data'),
78
- pathApi.join(localAppData, 'Chromium', 'User Data'),
79
- pathApi.join(localAppData, 'BraveSoftware', 'Brave-Browser', 'User Data'),
80
- pathApi.join(
81
- localAppData,
82
- 'BraveSoftware',
83
- 'Brave-Browser-Beta',
84
- 'User Data'
85
- ),
86
- pathApi.join(
87
- localAppData,
88
- 'BraveSoftware',
89
- 'Brave-Browser-Nightly',
90
- 'User Data'
91
- ),
92
- pathApi.join(localAppData, 'Microsoft', 'Edge', 'User Data'),
93
- pathApi.join(localAppData, 'Microsoft', 'Edge Beta', 'User Data'),
94
- pathApi.join(localAppData, 'Microsoft', 'Edge Dev', 'User Data'),
95
- pathApi.join(localAppData, 'Microsoft', 'Edge SxS', 'User Data'),
96
- ];
97
- }
98
- return [
99
- pathApi.join(homeDir, '.config', 'google-chrome'),
100
- pathApi.join(homeDir, '.config', 'google-chrome-beta'),
101
- pathApi.join(homeDir, '.config', 'google-chrome-unstable'),
102
- pathApi.join(homeDir, '.config', 'chromium'),
103
- pathApi.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser'),
104
- pathApi.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser-Beta'),
105
- pathApi.join(homeDir, '.config', 'BraveSoftware', 'Brave-Browser-Nightly'),
106
- pathApi.join(homeDir, '.config', 'microsoft-edge'),
107
- pathApi.join(homeDir, '.config', 'microsoft-edge-beta'),
108
- pathApi.join(homeDir, '.config', 'microsoft-edge-dev'),
109
- ];
52
+ environment,
53
+ })
54
+ );
110
55
  }
111
56
 
112
57
  /** Ensure Chrome is not asked to expose the user's default profile over CDP. */
@@ -117,12 +62,17 @@ export function assertDedicatedUserDataDir(userDataDir, platformOptions = {}) {
117
62
  const platform = platformOptions.platform ?? process.platform;
118
63
  const pathApi = platformPath(platform);
119
64
  const normalize = (value) => {
120
- const resolved = pathApi.resolve(value).replace(/[\\/]+$/, '');
65
+ let resolved = pathApi.resolve(value).replace(/[\\/]+$/, '');
66
+ if (platform === process.platform) {
67
+ resolved = physicalPath(resolved);
68
+ }
121
69
  return platform === 'win32' ? resolved.toLowerCase() : resolved;
122
70
  };
123
71
  const requested = normalize(userDataDir);
124
72
  const isDefault = knownDefaultUserDataDirs(platformOptions).some(
125
- (directory) => normalize(directory) === requested
73
+ (directory) =>
74
+ requested === normalize(directory) ||
75
+ requested.startsWith(`${normalize(directory)}${pathApi.sep}`)
126
76
  );
127
77
  if (isDefault) {
128
78
  throw new Error(
@@ -136,74 +86,12 @@ function browserInstallCandidates({
136
86
  platform = process.platform,
137
87
  environment = process.env,
138
88
  }) {
139
- const candidates = [];
140
- const names = CHANNEL_EXECUTABLE_NAMES[channel];
141
- if (!names) {
89
+ if (!CHANNEL_EXECUTABLE_NAMES[channel]?.length) {
142
90
  throw new Error(
143
91
  `Unknown browser channel: ${channel}. Expected one of ${Object.keys(CHANNEL_EXECUTABLE_NAMES).join(', ')}`
144
92
  );
145
93
  }
146
-
147
- if (platform === 'darwin') {
148
- const applications = {
149
- brave: 'Brave Browser.app/Contents/MacOS/Brave Browser',
150
- chrome: 'Google Chrome.app/Contents/MacOS/Google Chrome',
151
- 'chrome-beta': 'Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta',
152
- 'chrome-canary':
153
- 'Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary',
154
- 'chrome-dev': 'Google Chrome Dev.app/Contents/MacOS/Google Chrome Dev',
155
- chromium: 'Chromium.app/Contents/MacOS/Chromium',
156
- msedge: 'Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
157
- 'msedge-beta':
158
- 'Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta',
159
- 'msedge-canary':
160
- 'Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary',
161
- 'msedge-dev': 'Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev',
162
- };
163
- candidates.push(path.join('/Applications', applications[channel]));
164
- } else if (platform === 'win32') {
165
- const roots = [
166
- environment.PROGRAMFILES,
167
- environment['PROGRAMFILES(X86)'],
168
- environment.LOCALAPPDATA,
169
- ].filter(Boolean);
170
- const relativePaths = {
171
- brave: ['BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'],
172
- chrome: ['Google', 'Chrome', 'Application', 'chrome.exe'],
173
- 'chrome-beta': ['Google', 'Chrome Beta', 'Application', 'chrome.exe'],
174
- 'chrome-canary': ['Google', 'Chrome SxS', 'Application', 'chrome.exe'],
175
- 'chrome-dev': ['Google', 'Chrome Dev', 'Application', 'chrome.exe'],
176
- chromium: ['Chromium', 'Application', 'chrome.exe'],
177
- msedge: ['Microsoft', 'Edge', 'Application', 'msedge.exe'],
178
- 'msedge-beta': ['Microsoft', 'Edge Beta', 'Application', 'msedge.exe'],
179
- 'msedge-canary': ['Microsoft', 'Edge SxS', 'Application', 'msedge.exe'],
180
- 'msedge-dev': ['Microsoft', 'Edge Dev', 'Application', 'msedge.exe'],
181
- };
182
- for (const root of roots) {
183
- candidates.push(path.win32.join(root, ...relativePaths[channel]));
184
- }
185
- } else {
186
- for (const name of names) {
187
- candidates.push(`/usr/bin/${name}`, `/usr/local/bin/${name}`);
188
- }
189
- if (channel === 'chrome') {
190
- candidates.push('/opt/google/chrome/google-chrome');
191
- }
192
- }
193
-
194
- const pathApi = platformPath(platform);
195
- const delimiter = platform === 'win32' ? ';' : path.delimiter;
196
- for (const directory of (environment.PATH ?? '').split(delimiter)) {
197
- if (!directory) {
198
- continue;
199
- }
200
- for (const name of names) {
201
- candidates.push(
202
- pathApi.join(directory, platform === 'win32' ? `${name}.exe` : name)
203
- );
204
- }
205
- }
206
- return [...new Set(candidates)];
94
+ return resolveBrowserExecutables(channel, { platform, environment });
207
95
  }
208
96
 
209
97
  /** Resolve a genuine installed Chrome-family browser executable. */
package/src/cli/args.js CHANGED
@@ -92,6 +92,8 @@ export const COMMANDS = Object.freeze({
92
92
  profile: STRING,
93
93
  'user-data-dir': STRING,
94
94
  'target-browser': STRING,
95
+ 'password-csv': STRING,
96
+ 'include-payment-cards': FLAG,
95
97
  to: STRING,
96
98
  include: STRING,
97
99
  domain: STRINGS,
@@ -216,6 +216,8 @@ async function profileMigrate(parsed, io) {
216
216
  profile: options.profile,
217
217
  userDataDir: options.userDataDir,
218
218
  targetBrowser: options.targetBrowser,
219
+ passwordCsv: options.passwordCsv,
220
+ includePaymentCards: options.includePaymentCards,
219
221
  to: options.to,
220
222
  include: options.include
221
223
  ?.split(',')
@@ -220,6 +220,8 @@ function migrateProfile(state, params) {
220
220
  include: params.include,
221
221
  domains: params.domains,
222
222
  targetBrowser: params.targetBrowser,
223
+ passwordCsv: params.passwordCsv,
224
+ includePaymentCards: params.includePaymentCards,
223
225
  });
224
226
  }
225
227
 
@@ -34,6 +34,36 @@ function snapshotChrome(root, to) {
34
34
  return snapshotUserDataDir({ browser: 'chrome', userDataDir: root, to });
35
35
  }
36
36
 
37
+ it(
38
+ 'reports a locked database with snapshot guidance and leaves no target database',
39
+ { timeout: 5000 },
40
+ async () => {
41
+ const root = await makeTempDir();
42
+ const target = await makeTempDir();
43
+ const profile = path.join(root, 'Default');
44
+ await mkdir(profile);
45
+ const filename = await writeChromiumHistory(profile, 1);
46
+ const writer = new BetterSqlite3(filename);
47
+ writer.exec('BEGIN EXCLUSIVE');
48
+ try {
49
+ const report = await snapshotChrome(root, target);
50
+ assert.equal(report.copied.databases, 0);
51
+ assert.ok(
52
+ report.skipped.some(
53
+ ({ item, reason, detail }) =>
54
+ item === 'Default/History' &&
55
+ reason === 'unreadable' &&
56
+ detail.includes('Consistent SQLite snapshot unavailable')
57
+ )
58
+ );
59
+ await assert.rejects(stat(path.join(target, 'Default/History')));
60
+ } finally {
61
+ writer.exec('ROLLBACK');
62
+ writer.close();
63
+ }
64
+ }
65
+ );
66
+
37
67
  /** Write `content` to `root/relativePath`, creating the parent folders. */
38
68
  async function put(root, relativePath, content = relativePath) {
39
69
  const filePath = path.join(root, ...relativePath.split('/'));
@@ -12,6 +12,7 @@ import {
12
12
  mkdtemp,
13
13
  readFile,
14
14
  readdir,
15
+ rename,
15
16
  rm,
16
17
  stat,
17
18
  writeFile,
@@ -149,6 +150,40 @@ describe('installed browser cookie import', () => {
149
150
  }
150
151
  });
151
152
 
153
+ it('discovers and decrypts Yandex Cookies through its catalogue alias', async () => {
154
+ temporaryDirectory = await mkdtemp(
155
+ path.join(os.tmpdir(), 'bc-yandex-cookies-')
156
+ );
157
+ const host = '.yandex.ru';
158
+ const { root } = await createChromiumProfile({
159
+ homeDir: temporaryDirectory,
160
+ rows: [
161
+ {
162
+ host,
163
+ name: 'SID',
164
+ encryptedValue: encryptCbcCookie({
165
+ host,
166
+ value: 'yandex-session',
167
+ password: 'peanuts',
168
+ prefix: 'v10',
169
+ }),
170
+ },
171
+ ],
172
+ });
173
+ const yandexRoot = path.join(temporaryDirectory, '.config/yandex-browser');
174
+ await rename(root, yandexRoot);
175
+ const file = path.join(yandexRoot, 'Default/Network/Cookies');
176
+ const before = await readFile(file);
177
+ const cookies = await readBrowserCookiesWithDependencies(
178
+ { browser: 'yandex-browser', domainFilter: 'yandex.ru', cache: false },
179
+ { platform: 'linux', homeDir: temporaryDirectory, environment: {} }
180
+ );
181
+ assert.equal(cookies.length, 1);
182
+ assert.equal(cookies[0].domain, host);
183
+ assert.equal(cookies[0].value, 'yandex-session');
184
+ assert.deepEqual(await readFile(file), before);
185
+ });
186
+
152
187
  it('exports both public helpers', () => {
153
188
  assert.equal(publicListBrowserProfiles, listBrowserProfiles);
154
189
  assert.equal(publicReadBrowserCookies, readBrowserCookies);
@@ -0,0 +1,93 @@
1
+ import assert from 'node:assert/strict';
2
+ import { describe, it } from 'node:test';
3
+ import { mkdir, symlink } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { useTempDirectories } from '../../helpers/temp-directory.js';
6
+ import {
7
+ BROWSER_SOURCES,
8
+ resolveBrowserRoots,
9
+ } from '../../../src/browser/browser-sources.js';
10
+ import {
11
+ assertDedicatedUserDataDir,
12
+ CHANNEL_EXECUTABLE_NAMES,
13
+ } from '../../../src/browser/system-browser.js';
14
+
15
+ const temporary = useTempDirectories('bc-catalogue-protection-');
16
+
17
+ describe('catalogue launch and protection (#121)', () => {
18
+ it('protects a new profile beneath a symlink to a default root', async () => {
19
+ const homeDir = await temporary();
20
+ const options = { homeDir, environment: {} };
21
+ const root = resolveBrowserRoots('chrome', options)[0];
22
+ await mkdir(root, { recursive: true });
23
+ const alias = path.join(homeDir, 'profile-alias');
24
+ await symlink(
25
+ root,
26
+ alias,
27
+ process.platform === 'win32' ? 'junction' : 'dir'
28
+ );
29
+ assert.throws(
30
+ () =>
31
+ assertDedicatedUserDataDir(path.join(alias, 'new-profile'), options),
32
+ /dedicated.*default profile/
33
+ );
34
+ });
35
+
36
+ it('offers launch names for every supported Chromium source and alias', () => {
37
+ for (const browser of BROWSER_SOURCES.filter(
38
+ (entry) => entry.family === 'chromium'
39
+ )) {
40
+ assert.ok(CHANNEL_EXECUTABLE_NAMES[browser.id]?.length, browser.id);
41
+ for (const alias of browser.aliases ?? []) {
42
+ assert.deepEqual(
43
+ CHANNEL_EXECUTABLE_NAMES[alias],
44
+ CHANNEL_EXECUTABLE_NAMES[browser.id]
45
+ );
46
+ }
47
+ }
48
+ });
49
+
50
+ it('protects every catalogue root and descendant profile on every OS', () => {
51
+ for (const platform of ['linux', 'darwin', 'win32']) {
52
+ const options = {
53
+ platform,
54
+ homeDir: platform === 'win32' ? 'C:\\Users\\test' : '/users/test',
55
+ environment: {},
56
+ };
57
+ for (const browser of BROWSER_SOURCES) {
58
+ for (const root of resolveBrowserRoots(browser.id, options)) {
59
+ assert.throws(
60
+ () => assertDedicatedUserDataDir(root, options),
61
+ /dedicated.*default profile/,
62
+ `${browser.id}: ${root}`
63
+ );
64
+ assert.throws(
65
+ () =>
66
+ assertDedicatedUserDataDir(
67
+ `${root}${platform === 'win32' ? '\\' : '/'}Profile 1`,
68
+ options
69
+ ),
70
+ /dedicated.*default profile/
71
+ );
72
+ }
73
+ }
74
+ }
75
+ });
76
+
77
+ it('adds the requested desktop catalogue entries', () => {
78
+ for (const id of [
79
+ 'whale',
80
+ '360se',
81
+ '360chrome',
82
+ 'qq',
83
+ 'sogou',
84
+ 'duckduckgo',
85
+ 'tor',
86
+ ]) {
87
+ assert.ok(
88
+ BROWSER_SOURCES.some((entry) => entry.id === id),
89
+ id
90
+ );
91
+ }
92
+ });
93
+ });