browser-commander 0.20.0 → 0.21.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 (139) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/README.md +216 -32
  3. package/bin/browser-commander.js +16 -0
  4. package/eslint.config.js +10 -0
  5. package/examples/react-test-app/src/App.jsx +24 -23
  6. package/extension/background.js +14 -0
  7. package/extension/manifest.json +12 -0
  8. package/extension/relay-handler.js +353 -0
  9. package/package.json +19 -4
  10. package/src/browser/attach/differences.js +131 -0
  11. package/src/browser/attach/extension-relay.js +402 -0
  12. package/src/browser/attach/index.js +81 -0
  13. package/src/browser/attach/snapshot-launch.js +146 -0
  14. package/src/browser/attach/snapshot.js +374 -0
  15. package/src/browser/attach/websocket.js +615 -0
  16. package/src/browser/browser-cookie-cache.js +12 -17
  17. package/src/browser/browser-cookie-credentials.js +6 -9
  18. package/src/browser/browser-cookies.js +5 -2
  19. package/src/browser/cdp-session.js +94 -0
  20. package/src/browser/connector.js +42 -4
  21. package/src/browser/debugging-port.js +155 -0
  22. package/src/browser/launch-options.js +63 -52
  23. package/src/browser/launch-restrictions.json +147 -0
  24. package/src/browser/launcher.js +329 -133
  25. package/src/browser/media.js +5 -0
  26. package/src/browser/migration/bookmarks.js +73 -0
  27. package/src/browser/migration/chromium-crypto.js +89 -0
  28. package/src/browser/migration/cookies.js +155 -0
  29. package/src/browser/migration/extensions.js +187 -0
  30. package/src/browser/migration/firefox-bookmarks.js +144 -0
  31. package/src/browser/migration/firefox-der.js +84 -0
  32. package/src/browser/migration/firefox-nss.js +281 -0
  33. package/src/browser/migration/firefox.js +334 -0
  34. package/src/browser/migration/fs-utils.js +51 -0
  35. package/src/browser/migration/history.js +103 -0
  36. package/src/browser/migration/index.js +307 -0
  37. package/src/browser/migration/os-crypt-keys.js +106 -0
  38. package/src/browser/migration/passwords.js +171 -0
  39. package/src/browser/migration/preferences.js +132 -0
  40. package/src/browser/migration/sqlite-snapshot.js +138 -0
  41. package/src/browser/open-in-user-browser.js +112 -0
  42. package/src/browser/parity.js +439 -0
  43. package/src/browser/profile-directory.js +102 -0
  44. package/src/browser/real-browser.js +513 -306
  45. package/src/browser/restrictions.js +148 -0
  46. package/src/browser/system-browser.js +232 -0
  47. package/src/browser/webdriver.js +572 -0
  48. package/src/cli/args.js +253 -0
  49. package/src/cli/commands.js +332 -0
  50. package/src/cli/dispatcher.js +99 -0
  51. package/src/cli/handle-methods.js +136 -0
  52. package/src/cli/handles.js +273 -0
  53. package/src/cli/high-level-methods.js +310 -0
  54. package/src/cli/main.js +67 -0
  55. package/src/cli/modules.js +100 -0
  56. package/src/cli/rpc-error.js +77 -0
  57. package/src/cli/script.js +77 -0
  58. package/src/cli/serve.js +144 -0
  59. package/src/cli/sessions.js +195 -0
  60. package/src/core/constants.js +7 -1
  61. package/src/core/engine-adapter.js +107 -1
  62. package/src/core/engine-detection.js +18 -3
  63. package/src/core/navigation-safety.js +4 -0
  64. package/src/core/webdriver-conversions.js +347 -0
  65. package/src/core/webdriver-events.js +229 -0
  66. package/src/core/webdriver-page.js +716 -0
  67. package/src/downloads/attach.js +6 -0
  68. package/src/elements/selectors.js +3 -2
  69. package/src/elements/visibility.js +4 -0
  70. package/src/exports.js +61 -2
  71. package/src/factory.js +19 -4
  72. package/src/fingerprint/apply.js +6 -0
  73. package/src/fingerprint/automation-parity.js +7 -8
  74. package/src/fingerprint/limitations.js +1 -1
  75. package/src/fingerprint/limitations.json +45 -2
  76. package/src/parity/harness.js +303 -0
  77. package/src/parity/probe.js +838 -0
  78. package/src/parity/version-page.js +144 -0
  79. package/src/utilities/subprocess.js +199 -0
  80. package/tests/e2e/api-coverage.e2e.test.js +174 -0
  81. package/tests/e2e/cli.e2e.test.js +160 -0
  82. package/tests/e2e/fingerprint-parity.e2e.test.js +104 -46
  83. package/tests/e2e/google-signin-probe.e2e.test.js +112 -0
  84. package/tests/e2e/measure-parity.e2e.test.js +118 -0
  85. package/tests/e2e/playwright.e2e.test.js +25 -17
  86. package/tests/e2e/puppeteer.e2e.test.js +17 -23
  87. package/tests/e2e/webdriver.e2e.test.js +239 -0
  88. package/tests/fixtures/firefox-nss-fixtures.mjs +211 -0
  89. package/tests/helpers/cli-fakes.js +214 -0
  90. package/tests/helpers/declared-api.js +80 -0
  91. package/tests/helpers/e2e-browser.js +40 -14
  92. package/tests/helpers/fake-chrome.js +124 -0
  93. package/tests/helpers/migration-fixtures.js +242 -0
  94. package/tests/helpers/temp-directory.js +24 -0
  95. package/tests/helpers/webdriver-mocks.js +183 -0
  96. package/tests/unit/browser/attach/extension-relay.test.js +360 -0
  97. package/tests/unit/browser/attach/snapshot-launch.test.js +312 -0
  98. package/tests/unit/browser/attach/snapshot.test.js +390 -0
  99. package/tests/unit/browser/attach/websocket.test.js +240 -0
  100. package/tests/unit/browser/browser-cookies.test.js +2 -32
  101. package/tests/unit/browser/cdp-session.test.js +162 -0
  102. package/tests/unit/browser/connector.test.js +23 -0
  103. package/tests/unit/browser/launch-building-blocks.test.js +238 -0
  104. package/tests/unit/browser/launch-options.test.js +41 -22
  105. package/tests/unit/browser/launcher.test.js +281 -102
  106. package/tests/unit/browser/media.test.js +13 -1
  107. package/tests/unit/browser/migration/bookmarks.test.js +69 -0
  108. package/tests/unit/browser/migration/chromium-crypto.test.js +94 -0
  109. package/tests/unit/browser/migration/cookies.test.js +94 -0
  110. package/tests/unit/browser/migration/extensions.test.js +104 -0
  111. package/tests/unit/browser/migration/firefox-nss.test.js +113 -0
  112. package/tests/unit/browser/migration/firefox.test.js +139 -0
  113. package/tests/unit/browser/migration/history.test.js +48 -0
  114. package/tests/unit/browser/migration/index.test.js +117 -0
  115. package/tests/unit/browser/migration/os-crypt-keys.test.js +95 -0
  116. package/tests/unit/browser/migration/passwords.test.js +144 -0
  117. package/tests/unit/browser/migration/preferences.test.js +77 -0
  118. package/tests/unit/browser/migration/sqlite-snapshot.test.js +75 -0
  119. package/tests/unit/browser/open-in-user-browser.test.js +123 -0
  120. package/tests/unit/browser/parity.test.js +281 -0
  121. package/tests/unit/browser/real-browser.test.js +458 -33
  122. package/tests/unit/browser/webdriver.test.js +473 -0
  123. package/tests/unit/cli/bin.test.js +69 -0
  124. package/tests/unit/cli/declared-api.test.js +49 -0
  125. package/tests/unit/cli/dispatcher.test.js +429 -0
  126. package/tests/unit/cli/handles.test.js +170 -0
  127. package/tests/unit/cli/main.test.js +409 -0
  128. package/tests/unit/cli/serve.test.js +167 -0
  129. package/tests/unit/core/webdriver-conversions.test.js +235 -0
  130. package/tests/unit/core/webdriver-page.test.js +534 -0
  131. package/tests/unit/fingerprint/automation-parity.test.js +17 -4
  132. package/tests/unit/fingerprint/limitations.test.js +1 -1
  133. package/tests/unit/parity/harness.test.js +138 -0
  134. package/tests/unit/scripts/ci-timeout-budgets.test.js +4 -0
  135. package/tests/unit/scripts/feature-parity.test.js +221 -0
  136. package/tests/unit/traces/reader.test.js +1 -0
  137. package/tests/unit/traces/recorder.test.js +1 -0
  138. package/tests/unit/utilities/command-stream-only.test.js +28 -0
  139. package/tests/unit/utilities/subprocess.test.js +80 -0
@@ -0,0 +1,155 @@
1
+ import path from 'node:path';
2
+
3
+ import { pathExists } from './fs-utils.js';
4
+
5
+ import { readBrowserCookies } from '../browser-cookies.js';
6
+
7
+ /**
8
+ * Cookie migration.
9
+ *
10
+ * Cookies are read from the source profile with the existing
11
+ * {@link readBrowserCookies} (which handles the per-platform keystore and the
12
+ * v10/v11 decryption), optionally filtered by domain, and returned so the
13
+ * launcher can seed them into the dedicated profile over CDP with the existing
14
+ * `seedCookies` path. This module does not write anything into the target
15
+ * profile itself; the CDP seed is what a real, running browser accepts.
16
+ *
17
+ * ## Device Bound Session Credentials (DBSC)
18
+ *
19
+ * Google has shipped Device Bound Session Credentials: the session is bound to
20
+ * a private key held in the device's TPM or Secure Enclave, and the short
21
+ * "session token" cookies are rotated by the browser using that key. The key
22
+ * cannot leave the profile, so a copied Google session cannot be refreshed
23
+ * from another profile and expires quickly. Chrome's guidance is to report
24
+ * this rather than fail silently.
25
+ *
26
+ * References:
27
+ * - https://developer.chrome.com/docs/web-platform/device-bound-session-credentials
28
+ * - https://github.com/WICG/dbsc (the DBSC explainer)
29
+ * - https://www.helpnetsecurity.com/2026/04/10/google-chrome-device-bound-session-credentials/
30
+ *
31
+ * Detection is twofold:
32
+ *
33
+ * 1. By name+domain: the rotating bound cookies Chrome refreshes for a Google
34
+ * sign-in are `__Secure-1PSIDTS`, `__Secure-3PSIDTS` and `SIDTS` on
35
+ * `google.*`/`accounts.google.com` hosts. These are copied (so the rest of
36
+ * the session's cookies stay coherent) but reported in `skipped` with the
37
+ * reason `dbsc-bound`, because they will not survive rotation in the new
38
+ * profile.
39
+ * 2. By registration: if the source profile stores a DBSC registration
40
+ * database (`DeviceBoundSessions`, written under the profile's `Network`
41
+ * directory), a warning is added noting that any cookie covered by a bound
42
+ * session is subject to the same limitation. The registration itself is not
43
+ * copied because it is bound to the source device's key.
44
+ */
45
+
46
+ /** Rotating Google session-token cookies that DBSC binds to the device key. */
47
+ export const DBSC_BOUND_COOKIE_NAMES = Object.freeze([
48
+ '__Secure-1PSIDTS',
49
+ '__Secure-3PSIDTS',
50
+ 'SIDTS',
51
+ ]);
52
+
53
+ /** DBSC registration databases Chrome may write in the profile. */
54
+ const DBSC_REGISTRATION_FILES = [
55
+ 'Network/DeviceBoundSessions',
56
+ 'DeviceBoundSessions',
57
+ ];
58
+
59
+ function isGoogleHost(domain) {
60
+ const host = (domain ?? '').replace(/^\./, '').toLowerCase();
61
+ return (
62
+ host === 'google.com' ||
63
+ host.endsWith('.google.com') ||
64
+ /(^|\.)google\.[a-z.]+$/.test(host)
65
+ );
66
+ }
67
+
68
+ /**
69
+ * Decide whether a cookie is a DBSC-bound Google session cookie.
70
+ *
71
+ * @param {{name: string, domain: string}} cookie
72
+ * @returns {boolean}
73
+ */
74
+ export function isDbscBoundCookie(cookie) {
75
+ return (
76
+ isGoogleHost(cookie.domain) && DBSC_BOUND_COOKIE_NAMES.includes(cookie.name)
77
+ );
78
+ }
79
+
80
+ async function findDbscRegistration(profileDir) {
81
+ for (const relative of DBSC_REGISTRATION_FILES) {
82
+ const candidate = path.join(profileDir, relative);
83
+ if (await pathExists(candidate)) {
84
+ return candidate;
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Read cookies from the source browser, tag the DBSC-bound ones, and return the
92
+ * cookies to seed plus the migration report fragment.
93
+ *
94
+ * @param {Object} options
95
+ * @param {string} options.browser - chrome | edge | brave | chromium
96
+ * @param {string} [options.profile] - Source profile name
97
+ * @param {string} [options.sourceProfileDir] - Source profile directory (for DBSC detection)
98
+ * @param {string[]} [options.domains] - Optional domain filter (substring match)
99
+ * @param {Function} [options.readCookies=readBrowserCookies] - Injectable reader
100
+ * @param {Object} [options.readerOptions] - Extra options for the reader (platform, homeDir, ...)
101
+ * @returns {Promise<{cookies: Object[], migrated: number, skipped: Array, warnings: Array}>}
102
+ */
103
+ export async function migrateCookies({
104
+ browser,
105
+ profile,
106
+ sourceProfileDir,
107
+ domains,
108
+ readCookies = readBrowserCookies,
109
+ readerOptions = {},
110
+ }) {
111
+ const domainFilters =
112
+ Array.isArray(domains) && domains.length > 0 ? domains : [null];
113
+
114
+ const seen = new Map();
115
+ for (const domainFilter of domainFilters) {
116
+ const batch = await readCookies({
117
+ browser,
118
+ profile,
119
+ domainFilter: domainFilter ?? undefined,
120
+ ignoreDecryptionErrors: true,
121
+ ...readerOptions,
122
+ });
123
+ for (const cookie of batch) {
124
+ seen.set(
125
+ `${cookie.domain}\u0000${cookie.name}\u0000${cookie.path}`,
126
+ cookie
127
+ );
128
+ }
129
+ }
130
+ const cookies = [...seen.values()];
131
+
132
+ const skipped = [];
133
+ for (const cookie of cookies) {
134
+ if (isDbscBoundCookie(cookie)) {
135
+ skipped.push({
136
+ type: 'cookies',
137
+ item: `${cookie.domain} ${cookie.name}`,
138
+ reason: 'dbsc-bound',
139
+ });
140
+ }
141
+ }
142
+
143
+ const warnings = [];
144
+ if (sourceProfileDir && (await findDbscRegistration(sourceProfileDir))) {
145
+ warnings.push({
146
+ type: 'cookies',
147
+ item: 'DeviceBoundSessions',
148
+ reason: 'dbsc-registration-present',
149
+ detail:
150
+ 'The source profile has a Device Bound Session registration; cookies covered by it are bound to the source device key and will expire in the migrated profile.',
151
+ });
152
+ }
153
+
154
+ return { cookies, migrated: cookies.length, skipped, warnings };
155
+ }
@@ -0,0 +1,187 @@
1
+ import { cp, mkdir, readdir, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { pathExists, readJsonIfPresent } from './fs-utils.js';
4
+
5
+ /**
6
+ * Extension migration.
7
+ *
8
+ * A Chromium extension lives in two places in a profile:
9
+ *
10
+ * - its unpacked files under `Extensions/<id>/<version>/`, and
11
+ * - a bookkeeping entry under `extensions.settings.<id>` in `Secure
12
+ * Preferences` (which records the install location, version, granted
13
+ * permissions and enabled state).
14
+ *
15
+ * This module copies both: the extension's version directory and the
16
+ * `extensions.settings` entry.
17
+ *
18
+ * ## Secure Preferences MAC (honest limitation)
19
+ *
20
+ * `Secure Preferences` is protected by a per-entry HMAC ("MAC"). Chrome
21
+ * computes each MAC with a seed built from a hard-coded key baked into the
22
+ * browser binary combined with a per-profile/OS identifier (on Windows the seed
23
+ * is additionally tied to the machine SID via the `machine_id`). Because that
24
+ * seed differs for the dedicated target profile, a MAC copied from the source
25
+ * profile will not validate, and Chrome treats an entry with an invalid MAC as
26
+ * tampering: it disables (or silently drops) the extension on the next launch
27
+ * and shows the "settings were changed" reset bubble.
28
+ *
29
+ * We cannot forge a valid MAC without the browser's embedded key, so the copy
30
+ * is made on a best-effort basis and every migrated extension is reported with
31
+ * a warning explaining that the user will likely have to re-enable it (or
32
+ * re-install it from the Web Store, which restores a valid MAC). This is the
33
+ * honest, documented behaviour rather than a silent partial success.
34
+ *
35
+ * References:
36
+ * - https://chromium.googlesource.com/chromium/src/+/HEAD/services/preferences/tracked/README.md
37
+ * - https://www.chromium.org/developers/design-documents/preferences (tracked/protected preferences)
38
+ *
39
+ * ## Excluded extensions
40
+ *
41
+ * Policy-installed and component/built-in extensions are never copied: they are
42
+ * managed by enterprise policy or shipped with the browser, so copying their
43
+ * files into a user profile is both pointless (the browser re-adds them) and
44
+ * potentially conflicting. They are identified by the `location` value in the
45
+ * settings entry.
46
+ */
47
+
48
+ // Chrome's `Manifest::Location` values. Component/built-in and policy-managed
49
+ // extensions are excluded from a migration.
50
+ const EXCLUDED_LOCATIONS = new Set([
51
+ 5, // COMPONENT
52
+ 7, // EXTERNAL_POLICY_DOWNLOAD
53
+ 9, // EXTERNAL_POLICY
54
+ 10, // EXTERNAL_COMPONENT
55
+ ]);
56
+
57
+ const SECURE_PREFERENCES = 'Secure Preferences';
58
+
59
+ /**
60
+ * Decide which extension ids are eligible to migrate from a settings map.
61
+ *
62
+ * @param {Object} settings - `extensions.settings` from Secure Preferences
63
+ * @returns {{eligible: string[], excluded: Array<{id: string, reason: string}>}}
64
+ */
65
+ export function selectMigratableExtensions(settings) {
66
+ const eligible = [];
67
+ const excluded = [];
68
+ for (const [id, entry] of Object.entries(settings ?? {})) {
69
+ const location = Number(entry?.location);
70
+ if (EXCLUDED_LOCATIONS.has(location)) {
71
+ excluded.push({ id, reason: 'policy-or-component-extension' });
72
+ continue;
73
+ }
74
+ if (entry?.was_installed_by_default === true && !entry?.manifest) {
75
+ excluded.push({ id, reason: 'default-extension' });
76
+ continue;
77
+ }
78
+ eligible.push(id);
79
+ }
80
+ return { eligible, excluded };
81
+ }
82
+
83
+ async function copyExtensionFiles(
84
+ sourceExtensionsDir,
85
+ targetExtensionsDir,
86
+ id
87
+ ) {
88
+ const sourceDir = path.join(sourceExtensionsDir, id);
89
+ if (!(await pathExists(sourceDir))) {
90
+ return false;
91
+ }
92
+ await mkdir(targetExtensionsDir, { recursive: true });
93
+ await cp(sourceDir, path.join(targetExtensionsDir, id), {
94
+ recursive: true,
95
+ force: true,
96
+ });
97
+ return true;
98
+ }
99
+
100
+ /**
101
+ * Migrate extensions from a source profile into the target profile.
102
+ *
103
+ * @param {Object} options
104
+ * @param {string} options.sourceProfileDir
105
+ * @param {string} options.targetProfileDir
106
+ * @returns {Promise<{migrated: number, skipped: Array, warnings: Array}>}
107
+ */
108
+ export async function migrateExtensions({
109
+ sourceProfileDir,
110
+ targetProfileDir,
111
+ }) {
112
+ const skipped = [];
113
+ const warnings = [];
114
+
115
+ const sourceExtensionsDir = path.join(sourceProfileDir, 'Extensions');
116
+ if (!(await pathExists(sourceExtensionsDir))) {
117
+ return { migrated: 0, skipped: [], warnings: [] };
118
+ }
119
+
120
+ const securePrefs = await readJsonIfPresent(
121
+ path.join(sourceProfileDir, SECURE_PREFERENCES)
122
+ );
123
+ const settings = securePrefs?.extensions?.settings ?? {};
124
+
125
+ // Fall back to the on-disk directory listing when there is no settings map
126
+ // (for example a snapshot copied without Secure Preferences).
127
+ let eligible;
128
+ let excluded;
129
+ if (Object.keys(settings).length > 0) {
130
+ ({ eligible, excluded } = selectMigratableExtensions(settings));
131
+ } else {
132
+ const entries = await readdir(sourceExtensionsDir, { withFileTypes: true });
133
+ eligible = entries
134
+ .filter((entry) => entry.isDirectory() && entry.name !== 'Temp')
135
+ .map((entry) => entry.name);
136
+ excluded = [];
137
+ }
138
+
139
+ for (const { id, reason } of excluded) {
140
+ skipped.push({ type: 'extensions', item: id, reason });
141
+ }
142
+
143
+ const targetExtensionsDir = path.join(targetProfileDir, 'Extensions');
144
+ const migratedSettings = {};
145
+ let migrated = 0;
146
+ for (const id of eligible) {
147
+ const copied = await copyExtensionFiles(
148
+ sourceExtensionsDir,
149
+ targetExtensionsDir,
150
+ id
151
+ );
152
+ if (!copied) {
153
+ skipped.push({ type: 'extensions', item: id, reason: 'files-missing' });
154
+ continue;
155
+ }
156
+ if (settings[id]) {
157
+ migratedSettings[id] = settings[id];
158
+ }
159
+ migrated += 1;
160
+ }
161
+
162
+ if (migrated > 0) {
163
+ // Best-effort: write the settings entries into the target Secure
164
+ // Preferences so Chrome knows about the extensions. The MAC will not
165
+ // validate for the target profile (see the module comment), so warn.
166
+ if (Object.keys(migratedSettings).length > 0) {
167
+ const targetSecurePath = path.join(targetProfileDir, SECURE_PREFERENCES);
168
+ const targetSecure = (await readJsonIfPresent(targetSecurePath)) ?? {};
169
+ targetSecure.extensions ??= {};
170
+ targetSecure.extensions.settings = {
171
+ ...(targetSecure.extensions.settings ?? {}),
172
+ ...migratedSettings,
173
+ };
174
+ await mkdir(targetProfileDir, { recursive: true });
175
+ await writeFile(targetSecurePath, JSON.stringify(targetSecure));
176
+ }
177
+ warnings.push({
178
+ type: 'extensions',
179
+ item: 'Secure Preferences MAC',
180
+ reason: 'mac-will-not-validate',
181
+ detail:
182
+ 'Extension files and settings were copied, but Chrome computes a per-profile HMAC over Secure Preferences that cannot be reproduced for the target profile. Chrome will likely disable the migrated extensions on first launch; re-enable them or reinstall from the Web Store to restore a valid MAC.',
183
+ });
184
+ }
185
+
186
+ return { migrated, skipped, warnings };
187
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Convert a Firefox `places.sqlite` bookmark tree into Chrome's `Bookmarks`
3
+ * JSON format.
4
+ *
5
+ * Firefox keeps bookmarks in `moz_bookmarks` (a tree of folders/rows, type 1 =
6
+ * bookmark, type 2 = folder) joined to `moz_places` for the URLs. Chrome keeps
7
+ * them in a JSON document with three roots (`bookmark_bar`, `other`,
8
+ * `synced`). The Firefox toolbar maps to Chrome's bookmarks bar; the Firefox
9
+ * menu and unsorted bookmarks map to Chrome's "other bookmarks".
10
+ */
11
+
12
+ // Chrome stores timestamps as microseconds since 1601-01-01; a fixed, valid
13
+ // value is fine for imported bookmarks (Chrome only needs it to be parseable).
14
+ const CHROME_TIMESTAMP = '13300000000000000';
15
+
16
+ // Firefox root ids (stable across profiles).
17
+ const FIREFOX_ROOT = Object.freeze({
18
+ TOOLBAR: 'toolbar',
19
+ MENU: 'menu',
20
+ UNFILED: 'unfiled',
21
+ });
22
+
23
+ function buildChromeNode(node, idRef) {
24
+ if (node.type === 'url') {
25
+ idRef.value += 1;
26
+ return {
27
+ date_added: CHROME_TIMESTAMP,
28
+ id: String(idRef.value),
29
+ name: node.title ?? node.url ?? '',
30
+ type: 'url',
31
+ url: node.url,
32
+ };
33
+ }
34
+ idRef.value += 1;
35
+ const id = String(idRef.value);
36
+ return {
37
+ children: (node.children ?? []).map((child) =>
38
+ buildChromeNode(child, idRef)
39
+ ),
40
+ date_added: CHROME_TIMESTAMP,
41
+ date_modified: CHROME_TIMESTAMP,
42
+ id,
43
+ name: node.title ?? '',
44
+ type: 'folder',
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Build the parent→children tree from flat `moz_bookmarks` rows.
50
+ *
51
+ * @param {Array<{id: number, parent: number, type: number, title: string, url: (string|null)}>} rows
52
+ * @returns {Map<number, Array>}
53
+ */
54
+ function groupByParent(rows) {
55
+ const byParent = new Map();
56
+ for (const row of rows) {
57
+ if (!byParent.has(row.parent)) {
58
+ byParent.set(row.parent, []);
59
+ }
60
+ byParent.get(row.parent).push(row);
61
+ }
62
+ return byParent;
63
+ }
64
+
65
+ function collectChildren(byParent, parentId) {
66
+ const rows = byParent.get(parentId) ?? [];
67
+ return rows.map((row) => {
68
+ if (row.type === 2) {
69
+ return {
70
+ type: 'folder',
71
+ title: row.title,
72
+ children: collectChildren(byParent, row.id),
73
+ };
74
+ }
75
+ return { type: 'url', title: row.title, url: row.url };
76
+ });
77
+ }
78
+
79
+ /**
80
+ * Convert flat `moz_bookmarks` rows (joined with URLs) to a Chrome Bookmarks
81
+ * document.
82
+ *
83
+ * @param {Object} options
84
+ * @param {Array} options.rows - Rows with {id, parent, type, title, url, root}
85
+ * @returns {{document: Object, count: number}}
86
+ */
87
+ export function firefoxBookmarksToChrome({ rows }) {
88
+ const byParent = groupByParent(rows);
89
+ const rootIdByName = {};
90
+ for (const row of rows) {
91
+ if (row.root && FIREFOX_ROOT[row.root.toUpperCase()]) {
92
+ rootIdByName[row.root] = row.id;
93
+ }
94
+ }
95
+
96
+ const idRef = { value: 0 };
97
+ const makeRoot = (name, firefoxRootKey, extraRootKeys = []) => {
98
+ const children = [];
99
+ for (const key of [firefoxRootKey, ...extraRootKeys]) {
100
+ const rootId = rootIdByName[key];
101
+ if (rootId !== undefined && rootId !== null) {
102
+ for (const child of collectChildren(byParent, rootId)) {
103
+ children.push(buildChromeNode(child, idRef));
104
+ }
105
+ }
106
+ }
107
+ idRef.value += 1;
108
+ return {
109
+ children,
110
+ date_added: CHROME_TIMESTAMP,
111
+ date_modified: CHROME_TIMESTAMP,
112
+ id: String(idRef.value),
113
+ name,
114
+ type: 'folder',
115
+ };
116
+ };
117
+
118
+ const bookmarkBar = makeRoot('Bookmarks bar', FIREFOX_ROOT.TOOLBAR);
119
+ const other = makeRoot('Other bookmarks', FIREFOX_ROOT.MENU, [
120
+ FIREFOX_ROOT.UNFILED,
121
+ ]);
122
+ const synced = makeRoot('Mobile bookmarks', '__none__');
123
+
124
+ const countUrls = (node) => {
125
+ if (node.type === 'url') {
126
+ return 1;
127
+ }
128
+ return (node.children ?? []).reduce((sum, c) => sum + countUrls(c), 0);
129
+ };
130
+ const count = countUrls(bookmarkBar) + countUrls(other) + countUrls(synced);
131
+
132
+ return {
133
+ document: {
134
+ checksum: '',
135
+ roots: {
136
+ bookmark_bar: bookmarkBar,
137
+ other,
138
+ synced,
139
+ },
140
+ version: 1,
141
+ },
142
+ count,
143
+ };
144
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * A tiny DER (ASN.1) reader, just enough to walk the structures NSS writes into
3
+ * Firefox's `key4.db` and the base64 blobs in `logins.json`. It is deliberately
4
+ * minimal: it understands the tags those structures use (SEQUENCE, OCTET
5
+ * STRING, INTEGER, OBJECT IDENTIFIER) and returns a small tree, so the NSS
6
+ * decryption code can read fields by position without a full ASN.1 library
7
+ * (there is no DER dependency in this project).
8
+ */
9
+
10
+ export const DER_TAG = Object.freeze({
11
+ INTEGER: 0x02,
12
+ OCTET_STRING: 0x04,
13
+ NULL: 0x05,
14
+ OID: 0x06,
15
+ SEQUENCE: 0x30,
16
+ });
17
+
18
+ /**
19
+ * Decode one DER element at `offset`.
20
+ *
21
+ * @param {Buffer} buffer
22
+ * @param {number} [offset=0]
23
+ * @returns {{tag: number, length: number, header: number, contentStart: number, end: number, content: Buffer, children: (Array|null)}}
24
+ */
25
+ export function decodeDerElement(buffer, offset = 0) {
26
+ const tag = buffer[offset];
27
+ let cursor = offset + 1;
28
+ let length = buffer[cursor];
29
+ cursor += 1;
30
+ if (length & 0x80) {
31
+ const lengthBytes = length & 0x7f;
32
+ length = 0;
33
+ for (let i = 0; i < lengthBytes; i += 1) {
34
+ length = length * 256 + buffer[cursor];
35
+ cursor += 1;
36
+ }
37
+ }
38
+ const contentStart = cursor;
39
+ const end = contentStart + length;
40
+ const content = buffer.subarray(contentStart, end);
41
+ const constructed = (tag & 0x20) !== 0;
42
+ return {
43
+ tag,
44
+ length,
45
+ header: contentStart - offset,
46
+ contentStart,
47
+ end,
48
+ content,
49
+ children: constructed ? decodeDerSequence(content) : null,
50
+ };
51
+ }
52
+
53
+ /**
54
+ * Decode every element in a constructed value's content.
55
+ *
56
+ * @param {Buffer} content
57
+ * @returns {Array}
58
+ */
59
+ export function decodeDerSequence(content) {
60
+ const elements = [];
61
+ let offset = 0;
62
+ while (offset < content.length) {
63
+ const element = decodeDerElement(content, offset);
64
+ elements.push(element);
65
+ offset = element.end;
66
+ }
67
+ return elements;
68
+ }
69
+
70
+ /** Format an OBJECT IDENTIFIER element's bytes as a dotted string. */
71
+ export function oidToString(content) {
72
+ const first = Math.floor(content[0] / 40);
73
+ const second = content[0] % 40;
74
+ const parts = [first, second];
75
+ let value = 0;
76
+ for (let i = 1; i < content.length; i += 1) {
77
+ value = value * 128 + (content[i] & 0x7f);
78
+ if ((content[i] & 0x80) === 0) {
79
+ parts.push(value);
80
+ value = 0;
81
+ }
82
+ }
83
+ return parts.join('.');
84
+ }