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.
- package/.jscpd-baseline.json +1 -1
- package/CHANGELOG.md +24 -0
- package/README.md +7 -4
- package/package.json +4 -1
- package/src/browser/attach/snapshot.js +4 -28
- package/src/browser/browser-cookie-credentials.js +15 -8
- package/src/browser/browser-cookies.js +36 -30
- package/src/browser/browser-profile-files.js +31 -0
- package/src/browser/browser-profiles.js +11 -13
- package/src/browser/browser-sources.js +39 -9
- package/src/browser/browser-sources.json +600 -23
- package/src/browser/migration/capabilities.json +117 -0
- package/src/browser/migration/chromium-history.sql +22 -0
- package/src/browser/migration/chromium-writers.js +85 -0
- package/src/browser/migration/cookies.js +4 -1
- package/src/browser/migration/data-classes.js +68 -0
- package/src/browser/migration/domains.js +32 -0
- package/src/browser/migration/firefox-history.js +106 -0
- package/src/browser/migration/firefox.js +39 -25
- package/src/browser/migration/history.js +161 -2
- package/src/browser/migration/index.js +229 -83
- package/src/browser/migration/os-crypt-keys.js +1 -5
- package/src/browser/migration/password-metadata.js +95 -0
- package/src/browser/migration/passwords.js +78 -41
- package/src/browser/migration/safari-import.js +100 -0
- package/src/browser/migration/safari.js +110 -0
- package/src/browser/migration/sqlite-snapshot.js +31 -65
- package/src/browser/migration/validation.js +48 -0
- package/src/browser/real-browser.js +13 -1
- package/src/browser/safari-access.js +45 -0
- package/src/browser/safari-cookies.js +39 -36
- package/src/browser/safari-profiles.js +83 -0
- package/src/browser/system-browser.js +31 -143
- package/src/cli/args.js +2 -0
- package/src/cli/commands.js +2 -0
- package/src/cli/high-level-methods.js +2 -0
- package/tests/unit/browser/attach/snapshot.test.js +30 -0
- package/tests/unit/browser/browser-cookies.test.js +35 -0
- package/tests/unit/browser/catalogue-launch.test.js +93 -0
- package/tests/unit/browser/cookie-sources.test.js +92 -17
- package/tests/unit/browser/credential-catalogue.test.js +50 -0
- package/tests/unit/browser/firefox-cookie-expiry.test.js +79 -0
- package/tests/unit/browser/migration/data-classes.test.js +89 -0
- package/tests/unit/browser/migration/domain-isolation.test.js +212 -0
- package/tests/unit/browser/migration/firefox-history.test.js +130 -0
- package/tests/unit/browser/migration/history-metadata.test.js +74 -0
- package/tests/unit/browser/migration/index.test.js +16 -1
- package/tests/unit/browser/migration/opera-keys.test.js +22 -0
- package/tests/unit/browser/migration/safari-data.test.js +143 -0
- package/tests/unit/browser/migration/sqlite-snapshot.test.js +56 -1
- package/tests/unit/browser/migration/validation.test.js +63 -0
- package/tests/unit/browser/migration/yandex.test.js +84 -0
- package/tests/unit/browser/real-browser.test.js +19 -0
- package/tests/unit/browser/safari-cookies.test.js +26 -1
- package/tests/unit/browser/safari-profiles.test.js +111 -0
- package/tests/unit/cli/dispatcher.test.js +4 -0
- package/tests/unit/cli/main.test.js +5 -0
- package/tests/unit/scripts/browser-support.test.js +14 -7
- package/tests/unit/scripts/check-pipeline-status.test.js +33 -1
- package/tests/unit/scripts/migration-support.test.js +31 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { it } from 'node:test';
|
|
5
|
+
|
|
6
|
+
import BetterSqlite3 from 'better-sqlite3';
|
|
7
|
+
|
|
8
|
+
import { migrateProfile } from '../../../../src/browser/migration/index.js';
|
|
9
|
+
import { repoPath } from '../../../helpers/repo.js';
|
|
10
|
+
import { useTempDirectories } from '../../../helpers/temp-directory.js';
|
|
11
|
+
|
|
12
|
+
const makeTempDir = useTempDirectories('bc-ff-history-');
|
|
13
|
+
const schema = await readFile(
|
|
14
|
+
repoPath('tests/fixtures/firefox-history.sql'),
|
|
15
|
+
'utf8'
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
async function importHistory({ sql = schema, domains = [] } = {}) {
|
|
19
|
+
const source = await makeTempDir();
|
|
20
|
+
const target = path.join(await makeTempDir(), 'new-profile');
|
|
21
|
+
const filename = path.join(source, 'places.sqlite');
|
|
22
|
+
const db = new BetterSqlite3(filename);
|
|
23
|
+
try {
|
|
24
|
+
db.exec(sql);
|
|
25
|
+
} finally {
|
|
26
|
+
db.close();
|
|
27
|
+
}
|
|
28
|
+
const before = await readFile(filename);
|
|
29
|
+
const report = await migrateProfile({
|
|
30
|
+
from: { browser: 'firefox', userDataDir: source },
|
|
31
|
+
to: target,
|
|
32
|
+
include: ['history'],
|
|
33
|
+
domains,
|
|
34
|
+
});
|
|
35
|
+
assert.deepEqual(await readFile(filename), before);
|
|
36
|
+
return { report, target };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const domains of [[], ['GITHUB.COM.']]) {
|
|
40
|
+
it(`translates Firefox visits with ${domains.length ? 'exact domain filtering' : 'all domains'}`, async () => {
|
|
41
|
+
const { report, target } = await importHistory({ domains });
|
|
42
|
+
const count = domains.length ? 3 : 4;
|
|
43
|
+
assert.equal(report.migrated.history, count);
|
|
44
|
+
assert.deepEqual(report.skipped, []);
|
|
45
|
+
assert.equal(
|
|
46
|
+
report.warnings[0].reason,
|
|
47
|
+
'firefox-history-metadata-not-translated'
|
|
48
|
+
);
|
|
49
|
+
const db = new BetterSqlite3(path.join(target, 'History'), {
|
|
50
|
+
readonly: true,
|
|
51
|
+
});
|
|
52
|
+
try {
|
|
53
|
+
const rows = db
|
|
54
|
+
.prepare(
|
|
55
|
+
'SELECT url,title,visit_count,last_visit_time FROM urls ORDER BY url'
|
|
56
|
+
)
|
|
57
|
+
.safeIntegers()
|
|
58
|
+
.all();
|
|
59
|
+
assert.deepEqual(rows.slice(0, 2), [
|
|
60
|
+
{
|
|
61
|
+
url: 'https://docs.github.com/second',
|
|
62
|
+
title: '',
|
|
63
|
+
visit_count: 1n,
|
|
64
|
+
last_visit_time: 13344473600000003n,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
url: 'https://github.com/first',
|
|
68
|
+
title: 'First Ω',
|
|
69
|
+
visit_count: 2n,
|
|
70
|
+
last_visit_time: 13344473600000002n,
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
const times = db
|
|
74
|
+
.prepare('SELECT visit_time FROM visits ORDER BY visit_time')
|
|
75
|
+
.safeIntegers()
|
|
76
|
+
.all();
|
|
77
|
+
assert.deepEqual(
|
|
78
|
+
times.map(({ visit_time }) => visit_time),
|
|
79
|
+
[
|
|
80
|
+
13344473600000001n,
|
|
81
|
+
13344473600000002n,
|
|
82
|
+
13344473600000003n,
|
|
83
|
+
13344473600000004n,
|
|
84
|
+
].slice(0, count)
|
|
85
|
+
);
|
|
86
|
+
} finally {
|
|
87
|
+
db.close();
|
|
88
|
+
}
|
|
89
|
+
await assert.rejects(readFile(path.join(target, 'places.sqlite')), {
|
|
90
|
+
code: 'ENOENT',
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
it('reports a missing Firefox visit table before creating the target', async () => {
|
|
96
|
+
const { report, target } = await importHistory({
|
|
97
|
+
sql: 'CREATE TABLE moz_places (id INTEGER PRIMARY KEY, url TEXT, title TEXT)',
|
|
98
|
+
});
|
|
99
|
+
assert.equal(report.migrated.history, 0);
|
|
100
|
+
assert.equal(report.skipped[0].reason, 'source-format-unsupported');
|
|
101
|
+
assert.match(report.skipped[0].detail, /moz_historyvisits/u);
|
|
102
|
+
await assert.rejects(readFile(path.join(target, 'History')), {
|
|
103
|
+
code: 'ENOENT',
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
for (const [field, values] of [
|
|
108
|
+
[
|
|
109
|
+
'timestamp',
|
|
110
|
+
['NULL', "'invalid'", '1.5', '-11644473600000001', '9223372036854775807'],
|
|
111
|
+
],
|
|
112
|
+
['url', ['NULL', "''", "x'0102'"]],
|
|
113
|
+
]) {
|
|
114
|
+
it(`reports invalid visit ${field} without losing valid visits`, async () => {
|
|
115
|
+
for (const value of values) {
|
|
116
|
+
const extra =
|
|
117
|
+
field === 'timestamp'
|
|
118
|
+
? `INSERT INTO moz_historyvisits VALUES (5,1,${value},1)`
|
|
119
|
+
: `INSERT INTO moz_places VALUES (5,${value},'Invalid'); INSERT INTO moz_historyvisits VALUES (5,5,1700000000000005,1)`;
|
|
120
|
+
const { report } = await importHistory({
|
|
121
|
+
sql: `${schema}\n${extra}`,
|
|
122
|
+
domains: ['github.com'],
|
|
123
|
+
});
|
|
124
|
+
assert.equal(report.migrated.history, 3);
|
|
125
|
+
assert.equal(report.skipped.length, 1);
|
|
126
|
+
assert.equal(report.skipped[0].reason, `invalid-history-${field}`);
|
|
127
|
+
assert.equal(report.skipped[0].item, 'places.sqlite visit 5');
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { it } from 'node:test';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import Database from 'better-sqlite3';
|
|
6
|
+
import { useTempDirectories } from '../../../helpers/temp-directory.js';
|
|
7
|
+
import { migrateHistory } from '../../../../src/browser/migration/history.js';
|
|
8
|
+
|
|
9
|
+
const temporary = useTempDirectories('bc-history-metadata-');
|
|
10
|
+
for (const domains of [['github.com'], []]) {
|
|
11
|
+
it(`${domains.length ? 'omits with warnings' : 'preserves'} derived history metadata`, async () => {
|
|
12
|
+
const source = await temporary();
|
|
13
|
+
const target = await temporary();
|
|
14
|
+
const filename = path.join(source, 'History');
|
|
15
|
+
const db = new Database(filename);
|
|
16
|
+
db.exec(
|
|
17
|
+
await readFile(
|
|
18
|
+
new URL(
|
|
19
|
+
'../../../../../tests/fixtures/history-opaque-metadata.sql',
|
|
20
|
+
import.meta.url
|
|
21
|
+
),
|
|
22
|
+
'utf8'
|
|
23
|
+
)
|
|
24
|
+
);
|
|
25
|
+
db.close();
|
|
26
|
+
const before = await readFile(filename);
|
|
27
|
+
const report = await migrateHistory({
|
|
28
|
+
sourceProfileDir: source,
|
|
29
|
+
targetProfileDir: target,
|
|
30
|
+
domains,
|
|
31
|
+
});
|
|
32
|
+
assert.equal(report.migrated, 1);
|
|
33
|
+
const copy = new Database(path.join(target, 'History'));
|
|
34
|
+
try {
|
|
35
|
+
for (const [table, unfilteredCount] of [
|
|
36
|
+
['clusters', 2],
|
|
37
|
+
['clusters_and_visits', 3],
|
|
38
|
+
['cluster_keywords', 2],
|
|
39
|
+
['cluster_visit_duplicates', 2],
|
|
40
|
+
['future "history" metadata', 1],
|
|
41
|
+
]) {
|
|
42
|
+
const quoted = `"${table.replaceAll('"', '""')}"`;
|
|
43
|
+
const count = copy
|
|
44
|
+
.prepare(`SELECT count(*) AS n FROM ${quoted}`)
|
|
45
|
+
.get().n;
|
|
46
|
+
assert.equal(count, domains.length ? 0 : unfilteredCount, table);
|
|
47
|
+
assert.equal(
|
|
48
|
+
report.warnings.some(
|
|
49
|
+
(entry) =>
|
|
50
|
+
entry.item === table &&
|
|
51
|
+
entry.reason === 'unsupported-history-metadata'
|
|
52
|
+
),
|
|
53
|
+
Boolean(domains.length)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
assert.equal(
|
|
57
|
+
copy.prepare('SELECT count(*) AS n FROM urls').get().n,
|
|
58
|
+
domains.length ? 1 : 2
|
|
59
|
+
);
|
|
60
|
+
assert.equal(
|
|
61
|
+
copy.prepare('SELECT value FROM meta WHERE key=?').get('version').value,
|
|
62
|
+
70
|
|
63
|
+
);
|
|
64
|
+
} finally {
|
|
65
|
+
copy.close();
|
|
66
|
+
}
|
|
67
|
+
const bytes = await readFile(path.join(target, 'History'));
|
|
68
|
+
assert.equal(
|
|
69
|
+
bytes.includes(Buffer.from('unrelated-history-marker')),
|
|
70
|
+
!domains.length
|
|
71
|
+
);
|
|
72
|
+
assert.deepEqual(await readFile(filename), before);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -67,7 +67,7 @@ describe('migrateProfile', () => {
|
|
|
67
67
|
assert.equal(report.migrated.cookies, 1);
|
|
68
68
|
assert.equal(report.migrated.bookmarks, 1);
|
|
69
69
|
assert.equal(report.migrated.passwords, 1);
|
|
70
|
-
//
|
|
70
|
+
// This bookmark-only fixture has no Firefox visit table.
|
|
71
71
|
assert.equal(report.migrated.history, 0);
|
|
72
72
|
assert.equal(report.migrated.preferences, 0);
|
|
73
73
|
assert.equal(report.migrated.extensions, 0);
|
|
@@ -97,6 +97,21 @@ describe('migrateProfile', () => {
|
|
|
97
97
|
await assert.rejects(() => readFile(path.join(target, 'Login Data')));
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
+
it('reports unsupported Firefox preferences and extensions explicitly', async () => {
|
|
101
|
+
const report = await migrateProfile({
|
|
102
|
+
from: { browser: 'firefox', userDataDir: await makeTempDir() },
|
|
103
|
+
to: await makeTempDir(),
|
|
104
|
+
include: ['preferences', 'extensions'],
|
|
105
|
+
});
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
report.skipped.map(({ type, reason }) => [type, reason]),
|
|
108
|
+
[
|
|
109
|
+
['preferences', 'firefox-class-not-supported'],
|
|
110
|
+
['extensions', 'firefox-class-not-supported'],
|
|
111
|
+
]
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
100
115
|
it('skips passwords with a warning when no target key is available on Windows', async () => {
|
|
101
116
|
const source = await makeTempDir();
|
|
102
117
|
const target = await makeTempDir();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { it } from 'node:test';
|
|
6
|
+
import { localStatePathForProfile } from '../../../../src/browser/migration/os-crypt-keys.js';
|
|
7
|
+
|
|
8
|
+
it('uses Opera single-profile Local State before a parent fallback', async () => {
|
|
9
|
+
const root = await mkdtemp(path.join(os.tmpdir(), 'bc-opera-'));
|
|
10
|
+
try {
|
|
11
|
+
const profile = path.join(root, 'Opera Stable');
|
|
12
|
+
await mkdir(profile);
|
|
13
|
+
await writeFile(path.join(root, 'Local State'), '{}');
|
|
14
|
+
await writeFile(path.join(profile, 'Local State'), '{"os_crypt":{}}');
|
|
15
|
+
assert.equal(
|
|
16
|
+
localStatePathForProfile(profile),
|
|
17
|
+
path.join(profile, 'Local State')
|
|
18
|
+
);
|
|
19
|
+
} finally {
|
|
20
|
+
await rm(root, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readFile, copyFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { describe, it } from 'node:test';
|
|
5
|
+
import {
|
|
6
|
+
readSafariBookmarks,
|
|
7
|
+
readSafariHistory,
|
|
8
|
+
readSafariPasswords,
|
|
9
|
+
} from '../../../../src/browser/migration/safari.js';
|
|
10
|
+
import { migrateProfile } from '../../../../src/browser/migration/index.js';
|
|
11
|
+
import { useTempDirectories } from '../../../helpers/temp-directory.js';
|
|
12
|
+
import Database from 'better-sqlite3';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import {
|
|
15
|
+
deriveChromiumCookieKey,
|
|
16
|
+
decryptChromiumCookie,
|
|
17
|
+
} from '../../../../src/browser/browser-cookie-crypto.js';
|
|
18
|
+
|
|
19
|
+
const fixture = (name) =>
|
|
20
|
+
fileURLToPath(
|
|
21
|
+
new URL(
|
|
22
|
+
`../../../../../tests/fixtures/safari-data/${name}`,
|
|
23
|
+
import.meta.url
|
|
24
|
+
)
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
describe('Safari data sources', () => {
|
|
28
|
+
for (const format of ['binary', 'xml']) {
|
|
29
|
+
it(`preserves hierarchy and reading list from ${format} plist`, async () => {
|
|
30
|
+
const filename = fixture(`Bookmarks-${format}.plist`);
|
|
31
|
+
const before = await readFile(filename);
|
|
32
|
+
const tree = await readSafariBookmarks(filename);
|
|
33
|
+
assert.equal(tree[0].name, 'Work');
|
|
34
|
+
assert.equal(tree[0].children[0].name, 'Foundation ☃');
|
|
35
|
+
assert.equal(tree[1].children[0].readingList, true);
|
|
36
|
+
assert.deepEqual(await readFile(filename), before);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
it('preserves all matching visits and converts the Cocoa epoch', async () => {
|
|
41
|
+
const filename = fixture('History.db');
|
|
42
|
+
const before = await readFile(filename);
|
|
43
|
+
const entries = await readSafariHistory(filename, ['github.com']);
|
|
44
|
+
assert.equal(entries.length, 2);
|
|
45
|
+
assert.equal(entries[0].time, 1778307200250000);
|
|
46
|
+
assert.deepEqual(await readFile(filename), before);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('reads explicit BOM UTF-8 CSV with quoted newlines and filters domains', async () => {
|
|
50
|
+
const entries = await readSafariPasswords(fixture('Passwords.csv'), [
|
|
51
|
+
'github.com',
|
|
52
|
+
]);
|
|
53
|
+
assert.deepEqual(entries, [
|
|
54
|
+
{
|
|
55
|
+
origin: 'https://github.com/login',
|
|
56
|
+
username: 'a,b',
|
|
57
|
+
password: 'p"a\nss',
|
|
58
|
+
},
|
|
59
|
+
]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const temporary = useTempDirectories('bc-safari-data-');
|
|
63
|
+
it('rejects password CSV records with missing or extra fields without changing the source', async () => {
|
|
64
|
+
for (const record of [
|
|
65
|
+
'https://github.com,a\n',
|
|
66
|
+
'https://github.com,a,b,c\n',
|
|
67
|
+
]) {
|
|
68
|
+
const filename = path.join(await temporary(), 'Passwords.csv');
|
|
69
|
+
await writeFile(filename, `URL,Username,Password\n${record}`);
|
|
70
|
+
const before = await readFile(filename);
|
|
71
|
+
await assert.rejects(
|
|
72
|
+
readSafariPasswords(filename),
|
|
73
|
+
/Invalid Record Length/u
|
|
74
|
+
);
|
|
75
|
+
assert.deepEqual(await readFile(filename), before);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('migrates custom Safari sources into native Chromium stores with target encryption', async () => {
|
|
80
|
+
const source = await temporary();
|
|
81
|
+
const target = await temporary();
|
|
82
|
+
await copyFile(
|
|
83
|
+
fixture('Bookmarks-binary.plist'),
|
|
84
|
+
path.join(source, 'Bookmarks.plist')
|
|
85
|
+
);
|
|
86
|
+
await copyFile(fixture('History.db'), path.join(source, 'History.db'));
|
|
87
|
+
const key = deriveChromiumCookieKey('dedicated-target', 'linux');
|
|
88
|
+
const report = await migrateProfile({
|
|
89
|
+
from: { browser: 'safari', userDataDir: source },
|
|
90
|
+
to: target,
|
|
91
|
+
include: ['bookmarks', 'history', 'passwords'],
|
|
92
|
+
domains: ['github.com'],
|
|
93
|
+
passwordCsv: fixture('Passwords.csv'),
|
|
94
|
+
platform: 'linux',
|
|
95
|
+
keys: { targetKey: key, targetPrefix: 'v11' },
|
|
96
|
+
});
|
|
97
|
+
assert.equal(report.migrated.bookmarks, 2);
|
|
98
|
+
assert.equal(report.migrated.history, 2);
|
|
99
|
+
assert.equal(report.migrated.passwords, 1);
|
|
100
|
+
assert.deepEqual(report.skipped, []);
|
|
101
|
+
assert.ok(
|
|
102
|
+
report.warnings.some(
|
|
103
|
+
({ reason }) => reason === 'safari-reading-list-translated'
|
|
104
|
+
)
|
|
105
|
+
);
|
|
106
|
+
const db = new Database(path.join(target, 'Login Data'));
|
|
107
|
+
try {
|
|
108
|
+
const rows = db
|
|
109
|
+
.prepare('SELECT origin_url,password_value FROM logins')
|
|
110
|
+
.all();
|
|
111
|
+
assert.equal(rows.length, 1);
|
|
112
|
+
assert.equal(
|
|
113
|
+
decryptChromiumCookie({
|
|
114
|
+
encryptedValue: rows[0].password_value,
|
|
115
|
+
key,
|
|
116
|
+
platform: 'linux',
|
|
117
|
+
databaseVersion: 0,
|
|
118
|
+
host: 'github.com',
|
|
119
|
+
}),
|
|
120
|
+
'p"a\nss'
|
|
121
|
+
);
|
|
122
|
+
} finally {
|
|
123
|
+
db.close();
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('names the executing app and retry link for non-cookie protection failures', async () => {
|
|
128
|
+
const { withSafariAccess } =
|
|
129
|
+
await import('../../../../src/browser/safari-access.js');
|
|
130
|
+
for (const code of ['EPERM', 'EACCES']) {
|
|
131
|
+
await assert.rejects(
|
|
132
|
+
withSafariAccess(
|
|
133
|
+
'/Safari/History.db',
|
|
134
|
+
() => {
|
|
135
|
+
throw Object.assign(new Error('protected'), { code });
|
|
136
|
+
},
|
|
137
|
+
{ TERM_PROGRAM: 'FixtureTerminal' }
|
|
138
|
+
),
|
|
139
|
+
/Full Disk Access to FixtureTerminal.*Privacy_AllFiles/u
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from 'node:assert';
|
|
2
|
-
import { stat } from 'node:fs/promises';
|
|
2
|
+
import { stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
3
4
|
import { describe, it } from 'node:test';
|
|
4
5
|
|
|
5
6
|
import BetterSqlite3 from 'better-sqlite3';
|
|
@@ -58,6 +59,60 @@ describe('withDatabaseSnapshot', () => {
|
|
|
58
59
|
/does not exist/
|
|
59
60
|
);
|
|
60
61
|
});
|
|
62
|
+
|
|
63
|
+
it('does not substitute an unverified file copy when backup fails', async () => {
|
|
64
|
+
const sourcePath = path.join(await makeTempDir(), 'History');
|
|
65
|
+
await writeFile(sourcePath, 'not a SQLite database');
|
|
66
|
+
let readerCalled = false;
|
|
67
|
+
await assert.rejects(
|
|
68
|
+
withDatabaseSnapshot({
|
|
69
|
+
sourcePath,
|
|
70
|
+
read: () => {
|
|
71
|
+
readerCalled = true;
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
/Consistent SQLite snapshot unavailable.*close the source browser/iu
|
|
75
|
+
);
|
|
76
|
+
assert.equal(readerCalled, false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it(
|
|
80
|
+
'reports guidance for an exclusive lock without invoking the reader',
|
|
81
|
+
{ timeout: 5000 },
|
|
82
|
+
async () => {
|
|
83
|
+
const sourcePath = await makeDatabase(1);
|
|
84
|
+
const writer = new BetterSqlite3(sourcePath);
|
|
85
|
+
writer.exec('BEGIN EXCLUSIVE');
|
|
86
|
+
try {
|
|
87
|
+
await assert.rejects(
|
|
88
|
+
withDatabaseSnapshot({
|
|
89
|
+
sourcePath,
|
|
90
|
+
read: () => assert.fail('locked snapshot reader invoked'),
|
|
91
|
+
}),
|
|
92
|
+
/Consistent SQLite snapshot unavailable.*close the source browser/iu
|
|
93
|
+
);
|
|
94
|
+
} finally {
|
|
95
|
+
writer.exec('ROLLBACK');
|
|
96
|
+
writer.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
it('includes committed WAL records while the source connection stays open', async () => {
|
|
102
|
+
const sourcePath = await makeDatabase(1);
|
|
103
|
+
const writer = new BetterSqlite3(sourcePath);
|
|
104
|
+
writer.pragma('journal_mode = WAL');
|
|
105
|
+
writer.prepare('UPDATE urls SET title = ?').run('committed WAL title');
|
|
106
|
+
try {
|
|
107
|
+
const title = await readDatabaseSnapshot({
|
|
108
|
+
sourcePath,
|
|
109
|
+
read: (db) => db.prepare('SELECT title FROM urls').get().title,
|
|
110
|
+
});
|
|
111
|
+
assert.equal(title, 'committed WAL title');
|
|
112
|
+
} finally {
|
|
113
|
+
writer.close();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
61
116
|
});
|
|
62
117
|
|
|
63
118
|
describe('readDatabaseSnapshot', () => {
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import { describe, it } from 'node:test';
|
|
4
|
+
import { migrateProfile } from '../../../../src/browser/migration/index.js';
|
|
5
|
+
import { useTempDirectories } from '../../../helpers/temp-directory.js';
|
|
6
|
+
|
|
7
|
+
const temporary = useTempDirectories('bc-validation-');
|
|
8
|
+
|
|
9
|
+
describe('migration validation before target mutation', () => {
|
|
10
|
+
it('rejects unknown classes and invalid domain filters', async () => {
|
|
11
|
+
for (const options of [
|
|
12
|
+
{ include: ['invented'] },
|
|
13
|
+
{ domains: ['https://example.com'] },
|
|
14
|
+
]) {
|
|
15
|
+
const target = await temporary();
|
|
16
|
+
await assert.rejects(
|
|
17
|
+
migrateProfile({ from: { browser: 'chrome' }, to: target, ...options })
|
|
18
|
+
);
|
|
19
|
+
assert.deepEqual(await readdir(target), []);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('rejects unsupported target layouts before creating Chromium files', async () => {
|
|
24
|
+
const target = await temporary();
|
|
25
|
+
await assert.rejects(
|
|
26
|
+
migrateProfile({
|
|
27
|
+
from: { browser: 'chrome' },
|
|
28
|
+
to: target,
|
|
29
|
+
include: ['bookmarks'],
|
|
30
|
+
targetBrowser: 'firefox',
|
|
31
|
+
}),
|
|
32
|
+
/target/u
|
|
33
|
+
);
|
|
34
|
+
assert.deepEqual(await readdir(target), []);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('rejects detection-only sources before target mutation', async () => {
|
|
38
|
+
const source = await temporary();
|
|
39
|
+
const target = await temporary();
|
|
40
|
+
await assert.rejects(
|
|
41
|
+
migrateProfile({
|
|
42
|
+
from: { browser: 'duckduckgo', userDataDir: source },
|
|
43
|
+
to: target,
|
|
44
|
+
include: ['history'],
|
|
45
|
+
}),
|
|
46
|
+
/detection/u
|
|
47
|
+
);
|
|
48
|
+
assert.deepEqual(await readdir(target), []);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('rejects a target that overlaps the source profile', async () => {
|
|
52
|
+
const source = await temporary();
|
|
53
|
+
await assert.rejects(
|
|
54
|
+
migrateProfile({
|
|
55
|
+
from: { browser: 'opera', userDataDir: source },
|
|
56
|
+
to: source,
|
|
57
|
+
include: ['bookmarks'],
|
|
58
|
+
}),
|
|
59
|
+
/overlap/u
|
|
60
|
+
);
|
|
61
|
+
assert.deepEqual(await readdir(source), []);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { it } from 'node:test';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import Database from 'better-sqlite3';
|
|
7
|
+
import { useTempDirectories } from '../../../helpers/temp-directory.js';
|
|
8
|
+
import { migrateProfile } from '../../../../src/browser/migration/index.js';
|
|
9
|
+
import { encryptChromiumValue } from '../../../../src/browser/migration/chromium-crypto.js';
|
|
10
|
+
import { readMigratedLogins } from '../../../helpers/migration-fixtures.js';
|
|
11
|
+
|
|
12
|
+
const temporary = useTempDirectories('bc-yandex-');
|
|
13
|
+
function migrateYandexPasswords(source, to, options = {}) {
|
|
14
|
+
return migrateProfile({
|
|
15
|
+
from: { browser: 'yandex', userDataDir: source },
|
|
16
|
+
to,
|
|
17
|
+
include: ['passwords'],
|
|
18
|
+
...options,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
it('identifies Ya Passman Data before prompting for target credentials', async () => {
|
|
23
|
+
const source = await temporary();
|
|
24
|
+
const target = await temporary();
|
|
25
|
+
await mkdir(path.join(source, 'Default'));
|
|
26
|
+
const db = new Database(path.join(source, 'Default', 'Ya Passman Data'));
|
|
27
|
+
db.exec(
|
|
28
|
+
"CREATE TABLE meta(key TEXT,value BLOB); INSERT INTO meta VALUES('local_encryptor_data',x'01')"
|
|
29
|
+
);
|
|
30
|
+
db.close();
|
|
31
|
+
const report = await migrateYandexPasswords(source, target);
|
|
32
|
+
assert.equal(report.migrated.passwords, 0);
|
|
33
|
+
assert.equal(
|
|
34
|
+
report.skipped[0].reason,
|
|
35
|
+
'yandex-passman-encryption-unsupported'
|
|
36
|
+
);
|
|
37
|
+
assert.match(
|
|
38
|
+
report.skipped[0].detail,
|
|
39
|
+
/local_encryptor_data.*master password/u
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('retains supported Login Data beside unsupported Ya Passman Data', async () => {
|
|
44
|
+
const source = await temporary();
|
|
45
|
+
const target = await temporary();
|
|
46
|
+
const profile = path.join(source, 'Default');
|
|
47
|
+
await mkdir(profile);
|
|
48
|
+
const passman = path.join(profile, 'Ya Passman Data');
|
|
49
|
+
await writeFile(passman, 'unsupported-store');
|
|
50
|
+
const sourceKey = randomBytes(16);
|
|
51
|
+
const targetKey = randomBytes(16);
|
|
52
|
+
const loginData = path.join(profile, 'Login Data');
|
|
53
|
+
const db = new Database(loginData);
|
|
54
|
+
db.exec(
|
|
55
|
+
'CREATE TABLE logins(origin_url TEXT,username_value TEXT,password_value BLOB)'
|
|
56
|
+
);
|
|
57
|
+
db.prepare('INSERT INTO logins VALUES(?,?,?)').run(
|
|
58
|
+
'https://example.com',
|
|
59
|
+
'alice',
|
|
60
|
+
encryptChromiumValue({
|
|
61
|
+
plaintext: 'retained-password',
|
|
62
|
+
key: sourceKey,
|
|
63
|
+
platform: 'linux',
|
|
64
|
+
prefix: 'v11',
|
|
65
|
+
})
|
|
66
|
+
);
|
|
67
|
+
db.close();
|
|
68
|
+
const original = await readFile(loginData);
|
|
69
|
+
const report = await migrateYandexPasswords(source, target, {
|
|
70
|
+
platform: 'linux',
|
|
71
|
+
keys: { targetKey, resolveSourceKey: () => sourceKey },
|
|
72
|
+
});
|
|
73
|
+
assert.equal(report.migrated.passwords, 1);
|
|
74
|
+
assert.equal(
|
|
75
|
+
report.skipped[0].reason,
|
|
76
|
+
'yandex-passman-encryption-unsupported'
|
|
77
|
+
);
|
|
78
|
+
assert.equal(
|
|
79
|
+
readMigratedLogins(target, targetKey)[0].password,
|
|
80
|
+
'retained-password'
|
|
81
|
+
);
|
|
82
|
+
assert.deepEqual(await readFile(loginData), original);
|
|
83
|
+
assert.equal(await readFile(passman, 'utf8'), 'unsupported-store');
|
|
84
|
+
});
|
|
@@ -203,6 +203,21 @@ describe('launchAndConnectRealBrowser', () => {
|
|
|
203
203
|
}
|
|
204
204
|
});
|
|
205
205
|
|
|
206
|
+
it('rejects non-CDP catalogue browsers before resolving or spawning them', async () => {
|
|
207
|
+
for (const channel of ['firefox', 'librewolf', 'safari', 'duckduckgo']) {
|
|
208
|
+
await assert.rejects(
|
|
209
|
+
launchAndConnectRealBrowserWithDependencies(
|
|
210
|
+
{ channel },
|
|
211
|
+
{
|
|
212
|
+
resolveExecutable: () =>
|
|
213
|
+
assert.fail('non-CDP executable resolution reached'),
|
|
214
|
+
}
|
|
215
|
+
),
|
|
216
|
+
/does not support CDP/u
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
206
221
|
it('spawns, waits, connects, and returns process metadata', async () => {
|
|
207
222
|
temporaryDirectory = await makeDedicatedProfile();
|
|
208
223
|
const calls = [];
|
|
@@ -285,6 +300,8 @@ describe('launchAndConnectRealBrowser', () => {
|
|
|
285
300
|
profile: 'Default',
|
|
286
301
|
include: ['cookies', 'bookmarks'],
|
|
287
302
|
domains: ['google.com'],
|
|
303
|
+
passwordCsv: '/tmp/safari-export.csv',
|
|
304
|
+
includePaymentCards: true,
|
|
288
305
|
},
|
|
289
306
|
},
|
|
290
307
|
{
|
|
@@ -330,6 +347,8 @@ describe('launchAndConnectRealBrowser', () => {
|
|
|
330
347
|
assert.equal(migrateOptions.targetBrowser, 'chrome');
|
|
331
348
|
assert.deepEqual(migrateOptions.include, ['cookies', 'bookmarks']);
|
|
332
349
|
assert.deepEqual(migrateOptions.domains, ['google.com']);
|
|
350
|
+
assert.equal(migrateOptions.passwordCsv, '/tmp/safari-export.csv');
|
|
351
|
+
assert.equal(migrateOptions.includePaymentCards, true);
|
|
333
352
|
assert.deepEqual(migrateOptions.from, {
|
|
334
353
|
browser: 'chrome',
|
|
335
354
|
profile: 'Default',
|