browser-commander 0.23.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 (77) hide show
  1. package/.jscpd-baseline.json +48 -30
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +19 -7
  4. package/eslint.config.js +1 -1
  5. package/examples/react-test-app/package-lock.json +890 -0
  6. package/examples/react-test-app/package.json +4 -4
  7. package/package.json +20 -17
  8. package/src/browser/attach/snapshot.js +4 -28
  9. package/src/browser/browser-cookie-credentials.js +15 -8
  10. package/src/browser/browser-cookies.js +36 -30
  11. package/src/browser/browser-profile-files.js +31 -0
  12. package/src/browser/browser-profiles.js +11 -13
  13. package/src/browser/browser-sources.js +39 -9
  14. package/src/browser/browser-sources.json +600 -23
  15. package/src/browser/connector.js +46 -4
  16. package/src/browser/launcher.js +26 -1
  17. package/src/browser/migration/capabilities.json +117 -0
  18. package/src/browser/migration/chromium-history.sql +22 -0
  19. package/src/browser/migration/chromium-writers.js +85 -0
  20. package/src/browser/migration/cookies.js +4 -1
  21. package/src/browser/migration/data-classes.js +68 -0
  22. package/src/browser/migration/domains.js +32 -0
  23. package/src/browser/migration/firefox-history.js +106 -0
  24. package/src/browser/migration/firefox.js +39 -25
  25. package/src/browser/migration/history.js +161 -2
  26. package/src/browser/migration/index.js +229 -83
  27. package/src/browser/migration/os-crypt-keys.js +1 -5
  28. package/src/browser/migration/password-metadata.js +95 -0
  29. package/src/browser/migration/passwords.js +78 -41
  30. package/src/browser/migration/safari-import.js +100 -0
  31. package/src/browser/migration/safari.js +110 -0
  32. package/src/browser/migration/sqlite-snapshot.js +31 -65
  33. package/src/browser/migration/validation.js +48 -0
  34. package/src/browser/real-browser.js +21 -3
  35. package/src/browser/safari-access.js +45 -0
  36. package/src/browser/safari-cookies.js +39 -36
  37. package/src/browser/safari-profiles.js +83 -0
  38. package/src/browser/storage-state.js +27 -0
  39. package/src/browser/system-browser.js +31 -143
  40. package/src/browser/webdriver.js +18 -2
  41. package/src/cli/args.js +7 -0
  42. package/src/cli/commands.js +12 -6
  43. package/src/cli/handle-methods.js +19 -2
  44. package/src/cli/high-level-methods.js +22 -3
  45. package/src/cli/modules.js +5 -1
  46. package/src/cli/sessions.js +14 -2
  47. package/src/tests/index.js +1 -0
  48. package/tests/e2e/engine-matrix.e2e.test.js +107 -0
  49. package/tests/e2e/playwright.e2e.test.js +11 -17
  50. package/tests/e2e/puppeteer.e2e.test.js +13 -17
  51. package/tests/unit/browser/attach/snapshot.test.js +30 -0
  52. package/tests/unit/browser/browser-cookies.test.js +35 -0
  53. package/tests/unit/browser/catalogue-launch.test.js +93 -0
  54. package/tests/unit/browser/cookie-sources.test.js +92 -17
  55. package/tests/unit/browser/credential-catalogue.test.js +50 -0
  56. package/tests/unit/browser/firefox-cookie-expiry.test.js +79 -0
  57. package/tests/unit/browser/migration/data-classes.test.js +89 -0
  58. package/tests/unit/browser/migration/domain-isolation.test.js +212 -0
  59. package/tests/unit/browser/migration/firefox-history.test.js +130 -0
  60. package/tests/unit/browser/migration/history-metadata.test.js +74 -0
  61. package/tests/unit/browser/migration/index.test.js +16 -1
  62. package/tests/unit/browser/migration/opera-keys.test.js +22 -0
  63. package/tests/unit/browser/migration/safari-data.test.js +143 -0
  64. package/tests/unit/browser/migration/sqlite-snapshot.test.js +56 -1
  65. package/tests/unit/browser/migration/validation.test.js +63 -0
  66. package/tests/unit/browser/migration/yandex.test.js +84 -0
  67. package/tests/unit/browser/real-browser.test.js +19 -0
  68. package/tests/unit/browser/safari-cookies.test.js +26 -1
  69. package/tests/unit/browser/safari-profiles.test.js +111 -0
  70. package/tests/unit/cli/dispatcher.test.js +6 -2
  71. package/tests/unit/cli/main.test.js +5 -0
  72. package/tests/unit/cli/selenium-parity.test.js +186 -0
  73. package/tests/unit/scripts/browser-support.test.js +14 -7
  74. package/tests/unit/scripts/check-pipeline-status.test.js +33 -1
  75. package/tests/unit/scripts/migration-support.test.js +31 -0
  76. package/tests/unit/tests/index.test.js +2 -1
  77. package/tests/unit/traces/links.test.js +2 -2
@@ -7,15 +7,25 @@ import {
7
7
  browserProfileRoot,
8
8
  resolveBrowserProfile,
9
9
  } from '../browser-profiles.js';
10
+ import { migrateSafariClass } from './safari-import.js';
11
+ import {
12
+ validateMigrationOptions,
13
+ validateMigrationPaths,
14
+ } from './validation.js';
10
15
  import { migrateBookmarks } from './bookmarks.js';
16
+ import { pathExists } from './fs-utils.js';
11
17
  import { migrateCookies } from './cookies.js';
18
+ import {
19
+ ADDITIONAL_DATA_CLASSES,
20
+ reportAdditionalClasses,
21
+ } from './data-classes.js';
12
22
  import { migrateExtensions } from './extensions.js';
13
23
  import {
14
24
  migrateFirefoxBookmarks,
15
25
  migrateFirefoxPasswords,
16
26
  readFirefoxCookies,
17
- reportFirefoxHistory,
18
27
  } from './firefox.js';
28
+ import { migrateFirefoxHistory } from './firefox-history.js';
19
29
  import { migrateHistory } from './history.js';
20
30
  import { migratePasswords } from './passwords.js';
21
31
  import { migratePreferences } from './preferences.js';
@@ -36,8 +46,8 @@ import {
36
46
  *
37
47
  * Cookies are returned (not written): a running Chromium re-derives its own
38
48
  * cookie encryption, so the launcher seeds them over CDP with the existing
39
- * `seedCookies` path. Every other data class is written into the target profile
40
- * directory.
49
+ * `seedCookies` path. Supported file imports are written into the target profile;
50
+ * classes without a native writer receive explicit skipped reports.
41
51
  */
42
52
 
43
53
  export const ALL_DATA_CLASSES = Object.freeze([
@@ -47,6 +57,7 @@ export const ALL_DATA_CLASSES = Object.freeze([
47
57
  'passwords',
48
58
  'preferences',
49
59
  'extensions',
60
+ ...ADDITIONAL_DATA_CLASSES,
50
61
  ]);
51
62
 
52
63
  // Classification is driven by the shared catalogue so every Chromium variant
@@ -62,14 +73,7 @@ function isFirefoxBrowser(browser) {
62
73
  }
63
74
 
64
75
  function emptyMigrated() {
65
- return {
66
- cookies: 0,
67
- bookmarks: 0,
68
- history: 0,
69
- passwords: 0,
70
- preferences: 0,
71
- extensions: 0,
72
- };
76
+ return Object.fromEntries(ALL_DATA_CLASSES.map((type) => [type, 0]));
73
77
  }
74
78
 
75
79
  async function resolveSourceProfileDir({
@@ -114,34 +118,6 @@ function mergeReport(report, className, fragment) {
114
118
  report.warnings.push(...(fragment.warnings ?? []));
115
119
  }
116
120
 
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
-
145
121
  async function resolvePasswordKeys({
146
122
  keys,
147
123
  browser,
@@ -179,7 +155,8 @@ async function resolvePasswordKeys({
179
155
  * @param {{browser: string, profile: (string|undefined), userDataDir: (string|undefined)}} options.from
180
156
  * @param {string} options.to - Target profile directory
181
157
  * @param {string[]} [options.include=ALL_DATA_CLASSES]
182
- * @param {string[]} [options.domains] - Cookie domain filter
158
+ * @param {string[]} [options.domains] - Per-site host/subdomain filter
159
+ * @param {boolean} [options.includePaymentCards=false] - Separate explicit payment-card consent
183
160
  * @param {string} [options.platform=process.platform]
184
161
  * @param {string} [options.targetBrowser] - Launching browser channel (key derivation)
185
162
  * @param {Object} [options.keys] - Injected password keys {resolveSourceKey, targetKey, targetPrefix, primaryPassword}
@@ -198,6 +175,8 @@ export async function migrateProfile({
198
175
  homeDir = os.homedir(),
199
176
  environment = process.env,
200
177
  runCommand,
178
+ passwordCsv,
179
+ includePaymentCards = false,
201
180
  }) {
202
181
  if (!from?.browser) {
203
182
  throw new TypeError('migrateProfile requires from.browser');
@@ -205,6 +184,17 @@ export async function migrateProfile({
205
184
  if (!to) {
206
185
  throw new TypeError('migrateProfile requires a target directory (to)');
207
186
  }
187
+ validateMigrationOptions({
188
+ include,
189
+ domains,
190
+ targetBrowser,
191
+ to,
192
+ platform,
193
+ homeDir,
194
+ environment,
195
+ classes: ALL_DATA_CLASSES,
196
+ includePaymentCards,
197
+ });
208
198
  // An explicit userDataDir names the source, so only an installed-browser
209
199
  // import is steered towards the profile holding the requested domains.
210
200
  const source = await resolveImportSource({
@@ -216,6 +206,11 @@ export async function migrateProfile({
216
206
  runCommand,
217
207
  });
218
208
  const { browser } = source;
209
+ if (!['chromium', 'firefox', 'safari'].includes(browserFamily(browser))) {
210
+ throw new TypeError(
211
+ `Browser ${browser} supports detection only; its profile format is not supported for migration`
212
+ );
213
+ }
219
214
  const profile = from.profile ?? source.profile ?? 'Default';
220
215
  const isFirefox = isFirefoxBrowser(browser);
221
216
  const sourceProfileDir = await resolveSourceProfileDir({
@@ -227,6 +222,7 @@ export async function migrateProfile({
227
222
  environment,
228
223
  });
229
224
  const selected = new Set(include);
225
+ validateMigrationPaths(sourceProfileDir, to);
230
226
  const report = {
231
227
  source: { browser, profile, userDataDir: from.userDataDir ?? null },
232
228
  target: to,
@@ -238,6 +234,13 @@ export async function migrateProfile({
238
234
  if (source.warning) {
239
235
  report.warnings.push(source.warning);
240
236
  }
237
+ report.skipped.push(
238
+ ...reportAdditionalClasses({
239
+ selected,
240
+ profileDir: sourceProfileDir,
241
+ includePaymentCards,
242
+ })
243
+ );
241
244
 
242
245
  if (selected.has('cookies')) {
243
246
  if (isFirefox) {
@@ -263,8 +266,21 @@ export async function migrateProfile({
263
266
  }
264
267
 
265
268
  if (browserFamily(browser) === 'safari') {
266
- reportSafariClasses(report, selected);
267
- return report;
269
+ return migrateSafariProfile({
270
+ selected,
271
+ passwordCsv,
272
+ keys,
273
+ browser,
274
+ targetBrowser,
275
+ platform,
276
+ sourceProfileDir,
277
+ environment,
278
+ from,
279
+ to,
280
+ domains,
281
+ homeDir,
282
+ report,
283
+ });
268
284
  }
269
285
 
270
286
  if (selected.has('bookmarks')) {
@@ -288,10 +304,15 @@ export async function migrateProfile({
288
304
  report,
289
305
  'history',
290
306
  isFirefox
291
- ? await reportFirefoxHistory({ profileDir: sourceProfileDir })
307
+ ? await migrateFirefoxHistory({
308
+ profileDir: sourceProfileDir,
309
+ targetProfileDir: to,
310
+ domains,
311
+ })
292
312
  : await migrateHistory({
293
313
  sourceProfileDir,
294
314
  targetProfileDir: to,
315
+ domains,
295
316
  })
296
317
  );
297
318
  }
@@ -312,56 +333,181 @@ export async function migrateProfile({
312
333
  );
313
334
  }
314
335
 
336
+ if (isFirefox) {
337
+ for (const type of ['preferences', 'extensions']) {
338
+ if (selected.has(type)) {
339
+ report.skipped.push({
340
+ type,
341
+ item: sourceProfileDir,
342
+ reason: 'firefox-class-not-supported',
343
+ detail:
344
+ 'Firefox preferences and extensions cannot be copied into a Chromium profile.',
345
+ });
346
+ }
347
+ }
348
+ }
349
+
315
350
  if (selected.has('passwords')) {
316
- const passwordKeys = await resolvePasswordKeys({
351
+ await migratePasswordClass(report, {
317
352
  keys,
318
353
  browser,
319
- targetBrowser: targetBrowser ?? (isFirefox ? 'chrome' : browser),
354
+ targetBrowser,
320
355
  platform,
321
356
  sourceProfileDir,
322
357
  environment,
358
+ to,
359
+ isFirefox,
360
+ domains,
323
361
  });
324
- if (!passwordKeys?.targetKey) {
325
- report.skipped.push({
326
- type: 'passwords',
327
- item: 'Login Data',
328
- reason: 'target-key-unavailable',
329
- });
330
- report.warnings.push({
331
- type: 'passwords',
332
- item: 'Login Data',
333
- reason: 'target-key-unavailable',
334
- detail:
335
- 'A target encryption key was not available (on Windows the launcher must generate one and write it into the target Local State); passwords were not migrated.',
336
- });
337
- } else if (isFirefox) {
338
- mergeReport(
339
- report,
340
- 'passwords',
341
- await migrateFirefoxPasswords({
342
- profileDir: sourceProfileDir,
343
- targetProfileDir: to,
362
+ }
363
+
364
+ return report;
365
+ }
366
+
367
+ async function migrateSafariProfile({
368
+ report,
369
+ browser,
370
+ domains,
371
+ environment,
372
+ from,
373
+ homeDir,
374
+ keys,
375
+ passwordCsv,
376
+ platform,
377
+ selected,
378
+ sourceProfileDir,
379
+ targetBrowser,
380
+ to,
381
+ }) {
382
+ const passwordKeys =
383
+ selected.has('passwords') && passwordCsv
384
+ ? await resolvePasswordKeys({
385
+ keys,
386
+ browser,
387
+ targetBrowser: targetBrowser ?? 'chrome',
344
388
  platform,
345
- targetKey: passwordKeys.targetKey,
346
- targetPrefix: passwordKeys.targetPrefix,
347
- primaryPassword: passwordKeys.primaryPassword,
348
- })
349
- );
350
- } else {
351
- mergeReport(
352
- report,
353
- 'passwords',
354
- await migratePasswords({
355
389
  sourceProfileDir,
356
- targetProfileDir: to,
357
- platform,
358
- resolveSourceKey: passwordKeys.resolveSourceKey,
359
- targetKey: passwordKeys.targetKey,
360
- targetPrefix: passwordKeys.targetPrefix,
390
+ environment,
361
391
  })
362
- );
363
- }
392
+ : null;
393
+ for (const type of ALL_DATA_CLASSES.filter(
394
+ (type) =>
395
+ type !== 'cookies' &&
396
+ !ADDITIONAL_DATA_CLASSES.includes(type) &&
397
+ selected.has(type)
398
+ )) {
399
+ mergeReport(
400
+ report,
401
+ type,
402
+ await migrateSafariClass({
403
+ type,
404
+ profileDir: sourceProfileDir,
405
+ targetProfileDir: to,
406
+ domains,
407
+ passwordCsv,
408
+ passwordKeys,
409
+ platform,
410
+ legacyDir:
411
+ from.userDataDir ||
412
+ path.basename(path.dirname(sourceProfileDir)) === 'Profiles'
413
+ ? undefined
414
+ : path.join(
415
+ homeDir,
416
+ 'Library',
417
+ browser === 'safari' ? 'Safari' : 'Safari Technology Preview'
418
+ ),
419
+ })
420
+ );
421
+ }
422
+ if (report.cookies.length) {
423
+ report.warnings.push({
424
+ type: 'cookies',
425
+ item: browser,
426
+ reason: 'safari-samesite-unavailable',
427
+ detail:
428
+ 'Cookies.binarycookies does not store SameSite; imported cookies use Lax.',
429
+ });
364
430
  }
365
-
366
431
  return report;
367
432
  }
433
+
434
+ async function migratePasswordClass(
435
+ report,
436
+ {
437
+ browser,
438
+ domains,
439
+ environment,
440
+ isFirefox,
441
+ keys,
442
+ platform,
443
+ sourceProfileDir,
444
+ targetBrowser,
445
+ to,
446
+ }
447
+ ) {
448
+ if (
449
+ browser === 'yandex' &&
450
+ (await pathExists(path.join(sourceProfileDir, 'Ya Passman Data')))
451
+ ) {
452
+ report.skipped.push({
453
+ type: 'passwords',
454
+ item: 'Ya Passman Data',
455
+ reason: 'yandex-passman-encryption-unsupported',
456
+ detail:
457
+ 'Ya Passman Data uses local_encryptor_data and may require a Yandex master password; this extra encryption layer is not supported. Export passwords to a supported format instead.',
458
+ });
459
+ if (!(await pathExists(path.join(sourceProfileDir, 'Login Data')))) {
460
+ return report;
461
+ }
462
+ }
463
+ const passwordKeys = await resolvePasswordKeys({
464
+ keys,
465
+ browser,
466
+ targetBrowser: targetBrowser ?? (isFirefox ? 'chrome' : browser),
467
+ platform,
468
+ sourceProfileDir,
469
+ environment,
470
+ });
471
+ if (!passwordKeys?.targetKey) {
472
+ report.skipped.push({
473
+ type: 'passwords',
474
+ item: 'Login Data',
475
+ reason: 'target-key-unavailable',
476
+ });
477
+ report.warnings.push({
478
+ type: 'passwords',
479
+ item: 'Login Data',
480
+ reason: 'target-key-unavailable',
481
+ detail:
482
+ 'A target encryption key was not available (on Windows the launcher must generate one and write it into the target Local State); passwords were not migrated.',
483
+ });
484
+ } else if (isFirefox) {
485
+ mergeReport(
486
+ report,
487
+ 'passwords',
488
+ await migrateFirefoxPasswords({
489
+ profileDir: sourceProfileDir,
490
+ targetProfileDir: to,
491
+ platform,
492
+ targetKey: passwordKeys.targetKey,
493
+ targetPrefix: passwordKeys.targetPrefix,
494
+ primaryPassword: passwordKeys.primaryPassword,
495
+ domains,
496
+ })
497
+ );
498
+ } else {
499
+ mergeReport(
500
+ report,
501
+ 'passwords',
502
+ await migratePasswords({
503
+ sourceProfileDir,
504
+ targetProfileDir: to,
505
+ platform,
506
+ resolveSourceKey: passwordKeys.resolveSourceKey,
507
+ targetKey: passwordKeys.targetKey,
508
+ targetPrefix: passwordKeys.targetPrefix,
509
+ domains,
510
+ })
511
+ );
512
+ }
513
+ }
@@ -1,5 +1,3 @@
1
- import path from 'node:path';
2
-
3
1
  import { deriveChromiumCookieKey } from '../browser-cookie-crypto.js';
4
2
  import {
5
3
  decryptWindowsDpapi,
@@ -101,6 +99,4 @@ export async function resolveTargetKey({
101
99
  }
102
100
 
103
101
  /** Convenience: the Local State path next to a profile directory. */
104
- export function localStatePathForProfile(profileDir) {
105
- return path.join(path.dirname(profileDir), 'Local State');
106
- }
102
+ export { localStatePathForProfile } from '../browser-profile-files.js';
@@ -0,0 +1,95 @@
1
+ import { matchesDomains } from './domains.js';
2
+
3
+ /** Filter Login Data associations after pruning logins; never retain source keys. */
4
+ export async function migratePasswordMetadata({
5
+ db,
6
+ domains,
7
+ rewriteValue,
8
+ skipped,
9
+ warnings,
10
+ }) {
11
+ const tables = db
12
+ .prepare("SELECT name FROM sqlite_master WHERE type='table'")
13
+ .all()
14
+ .map((row) => row.name);
15
+ for (const table of ['password_notes', 'insecure_credentials']) {
16
+ if (tables.includes(table)) {
17
+ db.exec(
18
+ `DELETE FROM ${table} WHERE NOT EXISTS (SELECT 1 FROM logins WHERE logins.id=${table}.parent_id)`
19
+ );
20
+ }
21
+ }
22
+ if (tables.includes('password_notes')) {
23
+ const rows = db
24
+ .prepare(
25
+ 'SELECT password_notes.rowid AS rowid,logins.origin_url,password_notes.value FROM password_notes JOIN logins ON logins.id=password_notes.parent_id'
26
+ )
27
+ .all();
28
+ for (const row of rows) {
29
+ const result = await rewriteValue(row.value, row.origin_url);
30
+ if (result.reason) {
31
+ db.prepare('DELETE FROM password_notes WHERE rowid=?').run(row.rowid);
32
+ skipped.push({
33
+ type: 'passwords',
34
+ item: `password_notes/${row.rowid}`,
35
+ ...result,
36
+ });
37
+ } else {
38
+ db.prepare('UPDATE password_notes SET value=? WHERE rowid=?').run(
39
+ result.value,
40
+ row.rowid
41
+ );
42
+ }
43
+ }
44
+ }
45
+ const syncTables = [
46
+ 'sync_entities_metadata',
47
+ 'sync_model_metadata',
48
+ 'incoming_sharing_invitation_sync_entities_metadata',
49
+ 'incoming_sharing_invitation_sync_model_metadata',
50
+ ];
51
+ for (const table of syncTables) {
52
+ if (tables.includes(table)) {
53
+ resetMetadata(db, table, 'sync-metadata-reset', warnings);
54
+ }
55
+ }
56
+ if (!domains?.length) {
57
+ return;
58
+ }
59
+ if (tables.includes('stats')) {
60
+ for (const row of db
61
+ .prepare('SELECT rowid AS rowid,origin_domain FROM stats')
62
+ .all()) {
63
+ if (!matchesDomains(row.origin_domain ?? '', domains)) {
64
+ db.prepare('DELETE FROM stats WHERE rowid=?').run(row.rowid);
65
+ }
66
+ }
67
+ }
68
+ const known = new Set([
69
+ 'logins',
70
+ 'meta',
71
+ 'password_notes',
72
+ 'insecure_credentials',
73
+ 'stats',
74
+ ...syncTables,
75
+ ]);
76
+ for (const table of tables) {
77
+ if (!known.has(table) && !table.startsWith('sqlite_')) {
78
+ resetMetadata(db, table, 'unsupported-password-metadata', warnings);
79
+ }
80
+ }
81
+ }
82
+
83
+ function resetMetadata(db, table, reason, warnings) {
84
+ // Only the identifier comes from the database; quote it rather than execute it.
85
+ const quoted = `"${table.replaceAll('"', '""')}"`;
86
+ const { changes } = db.prepare(`DELETE FROM ${quoted}`).run();
87
+ if (changes) {
88
+ warnings.push({
89
+ type: 'passwords',
90
+ item: table,
91
+ reason,
92
+ detail: `${changes} copied metadata rows removed`,
93
+ });
94
+ }
95
+ }
@@ -6,6 +6,8 @@ import { decryptChromiumCookie } from '../browser-cookie-crypto.js';
6
6
  import { openSqliteDatabase } from '../browser-cookie-database.js';
7
7
  import { withDatabaseSnapshot } from './sqlite-snapshot.js';
8
8
  import { encryptChromiumValue } from './chromium-crypto.js';
9
+ import { matchesDomains } from './domains.js';
10
+ import { migratePasswordMetadata } from './password-metadata.js';
9
11
 
10
12
  /**
11
13
  * Saved-password migration.
@@ -49,6 +51,44 @@ function hostFromOrigin(originUrl) {
49
51
  }
50
52
  }
51
53
 
54
+ async function rewriteSecret(
55
+ value,
56
+ origin,
57
+ { platform, resolveSourceKey, targetKey, targetPrefix }
58
+ ) {
59
+ const encryptedValue = toBuffer(value);
60
+ if (!encryptedValue.length) {
61
+ return { value: encryptedValue };
62
+ }
63
+ const prefix = encryptedValue.subarray(0, 3).toString('ascii');
64
+ if (prefix === 'v20') {
65
+ return { reason: 'app-bound-v20' };
66
+ }
67
+ if (prefix !== 'v10' && prefix !== 'v11') {
68
+ return { reason: 'unsupported-encryption' };
69
+ }
70
+ let plaintext;
71
+ try {
72
+ plaintext = decryptChromiumCookie({
73
+ encryptedValue,
74
+ host: hostFromOrigin(origin),
75
+ databaseVersion: 0,
76
+ platform,
77
+ key: await resolveSourceKey(prefix),
78
+ });
79
+ } catch (error) {
80
+ return { reason: 'decrypt-failed', detail: error.message };
81
+ }
82
+ return {
83
+ value: encryptChromiumValue({
84
+ plaintext,
85
+ key: targetKey,
86
+ platform,
87
+ prefix: targetPrefix,
88
+ }),
89
+ };
90
+ }
91
+
52
92
  /**
53
93
  * Migrate saved passwords into the target profile's `Login Data`.
54
94
  *
@@ -69,6 +109,7 @@ export async function migratePasswords({
69
109
  resolveSourceKey,
70
110
  targetKey,
71
111
  targetPrefix = platform === 'win32' ? 'v10' : 'v11',
112
+ domains,
72
113
  }) {
73
114
  const sourcePath = path.join(sourceProfileDir, 'Login Data');
74
115
  if (!(await pathExists(sourcePath))) {
@@ -112,56 +153,52 @@ export async function migratePasswords({
112
153
  const update = db.prepare(
113
154
  'UPDATE logins SET password_value = ? WHERE rowid = ?'
114
155
  );
156
+ const remove = db.prepare('DELETE FROM logins WHERE rowid = ?');
157
+ const skip = (row, reason, detail) => {
158
+ remove.run(row.rowid);
159
+ skipped.push({
160
+ type: 'passwords',
161
+ item: row.origin_url ?? '(unknown)',
162
+ reason,
163
+ ...(detail ? { detail } : {}),
164
+ });
165
+ };
115
166
  for (const row of rows) {
116
- const encryptedValue = toBuffer(row.password_value);
117
- if (encryptedValue.length === 0) {
167
+ if (!matchesDomains(row.origin_url ?? '', domains)) {
168
+ remove.run(row.rowid);
118
169
  continue;
119
170
  }
120
- const prefix = encryptedValue.subarray(0, 3).toString('ascii');
121
- if (prefix === 'v20') {
122
- skipped.push({
123
- type: 'passwords',
124
- item: row.origin_url ?? '(unknown)',
125
- reason: 'app-bound-v20',
126
- });
127
- continue;
128
- }
129
- if (prefix !== 'v10' && prefix !== 'v11') {
130
- skipped.push({
131
- type: 'passwords',
132
- item: row.origin_url ?? '(unknown)',
133
- reason: 'unsupported-encryption',
134
- });
135
- continue;
136
- }
137
- let plaintext;
138
- try {
139
- const key = await resolveSourceKey(prefix);
140
- plaintext = decryptChromiumCookie({
141
- encryptedValue,
142
- host: hostFromOrigin(row.origin_url),
143
- databaseVersion: 0,
144
- platform,
145
- key,
146
- });
147
- } catch (error) {
148
- skipped.push({
149
- type: 'passwords',
150
- item: row.origin_url ?? '(unknown)',
151
- reason: 'decrypt-failed',
152
- detail: error.message,
153
- });
171
+ const encryptedValue = toBuffer(row.password_value);
172
+ if (encryptedValue.length === 0) {
154
173
  continue;
155
174
  }
156
- const reencrypted = encryptChromiumValue({
157
- plaintext,
158
- key: targetKey,
175
+ const result = await rewriteSecret(encryptedValue, row.origin_url, {
159
176
  platform,
160
- prefix: targetPrefix,
177
+ resolveSourceKey,
178
+ targetKey,
179
+ targetPrefix,
161
180
  });
162
- update.run(reencrypted, row.rowid);
181
+ if (result.reason) {
182
+ skip(row, result.reason, result.detail);
183
+ continue;
184
+ }
185
+ update.run(result.value, row.rowid);
163
186
  migrated += 1;
164
187
  }
188
+ await migratePasswordMetadata({
189
+ db,
190
+ domains,
191
+ skipped,
192
+ warnings,
193
+ rewriteValue: (value, origin) =>
194
+ rewriteSecret(value, origin, {
195
+ platform,
196
+ resolveSourceKey,
197
+ targetKey,
198
+ targetPrefix,
199
+ }),
200
+ });
201
+ db.exec('VACUUM');
165
202
  } finally {
166
203
  db.close();
167
204
  }