refactor: profile imports

This commit is contained in:
zhom
2026-08-10 09:19:25 +04:00
parent a6b79341b3
commit 32a1728dee
33 changed files with 4917 additions and 387 deletions
+78 -3
View File
@@ -680,6 +680,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.4.2"
@@ -967,6 +976,15 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "cbc"
version = "0.2.1"
@@ -1785,7 +1803,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.29.0"
version = "0.29.1"
dependencies = [
"aes 0.9.1",
"aes-gcm 0.11.0",
@@ -1797,7 +1815,7 @@ dependencies = [
"blake3",
"boringtun",
"bzip2",
"cbc",
"cbc 0.2.1",
"chrono",
"chrono-tz",
"clap",
@@ -1832,6 +1850,8 @@ dependencies = [
"resvg",
"ring",
"rusqlite",
"secret-service",
"security-framework",
"serde",
"serde_json",
"serde_yaml",
@@ -3379,6 +3399,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding 0.3.3",
"generic-array",
]
@@ -3388,7 +3409,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [
"block-padding",
"block-padding 0.4.2",
"hybrid-array",
]
@@ -4085,6 +4106,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
@@ -4095,6 +4130,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -4121,6 +4165,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
@@ -5713,6 +5767,25 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "secret-service"
version = "5.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14"
dependencies = [
"aes 0.8.4",
"cbc 0.1.2",
"futures-util",
"generic-array",
"getrandom 0.2.17",
"hkdf",
"num",
"once_cell",
"serde",
"sha2 0.10.9",
"zbus",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -7198,6 +7271,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
]
@@ -8985,6 +9059,7 @@ dependencies = [
"rustix",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",
+51
View File
@@ -68,6 +68,7 @@ export class AppSession {
extraEnv = {},
args = [],
seedVersionCache = true,
seedDownloadedBrowser = false,
onboardingCompleted = true,
wayfernTermsAccepted = true,
}) {
@@ -80,6 +81,7 @@ export class AppSession {
this.extraEnv = extraEnv;
this.args = args;
this.seedVersionCache = seedVersionCache;
this.seedDownloadedBrowser = seedDownloadedBrowser;
this.onboardingCompleted = onboardingCompleted;
this.wayfernTermsAccepted = wayfernTermsAccepted;
this.session = null;
@@ -184,6 +186,54 @@ export class AppSession {
}
});
}
if (this.seedDownloadedBrowser) {
// Registers a Wayfern version as "downloaded" without installing a
// binary. Profile import derives its version from this registry and
// fails with BROWSER_NOT_DOWNLOADED otherwise, so suites that exercise
// import but never launch a browser need the entry and nothing else.
const seededVersion =
typeof this.seedDownloadedBrowser === "string"
? this.seedDownloadedBrowser
: "150.0.7871.100";
const installDir = path.join(
this.dataRoot,
"data",
"binaries",
"wayfern",
seededVersion,
);
await mkdir(installDir, { recursive: true });
const registryPath = path.join(
this.dataRoot,
"data",
"data",
"downloaded_browsers.json",
);
await mkdir(path.dirname(registryPath), { recursive: true });
await writeFile(
registryPath,
`${JSON.stringify(
{
browsers: {
wayfern: {
[seededVersion]: {
browser: "wayfern",
version: seededVersion,
file_path: installDir,
},
},
},
},
null,
2,
)}\n`,
{ flag: "wx" },
).catch((error) => {
if (error.code !== "EEXIST") {
throw error;
}
});
}
const env = isolatedEnvironment(this.root, {
DONUT_E2E_DISABLE_STARTUP_NETWORK: "1",
...(process.env.DONUT_E2E_FIXTURE_URL
@@ -525,6 +575,7 @@ export function appFromEnvironment(name, options = {}) {
extraEnv: options.extraEnv,
args: options.args,
seedVersionCache: options.seedVersionCache,
seedDownloadedBrowser: options.seedDownloadedBrowser,
onboardingCompleted: options.onboardingCompleted,
wayfernTermsAccepted: options.wayfernTermsAccepted,
});
+99
View File
@@ -12,6 +12,7 @@ import {
} from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
export const TEST_BROWSER_VERSION = "150.0.7871.100";
@@ -220,3 +221,101 @@ export function currentHostOs() {
? "windows"
: "linux";
}
/**
* Write a Chromium cookie store at schema version 24 with plaintext values.
*
* Plaintext is deliberate: it is what a store looks like when the source
* browser could not reach its keyring, and it lets the suite assert that
* import seals every row with the target profile's key. Chromium reads a row
* whose `encrypted_value` is empty, and drops any row where both columns are
* set, so "value cleared and encrypted_value populated" is the only shape that
* actually loads.
*/
export function writeChromiumCookies(dbPath, cookies) {
const db = new DatabaseSync(dbPath);
db.exec(`
CREATE TABLE cookies(
creation_utc INTEGER NOT NULL,
host_key TEXT NOT NULL,
top_frame_site_key TEXT NOT NULL,
name TEXT NOT NULL,
value TEXT NOT NULL,
encrypted_value BLOB NOT NULL DEFAULT '',
path TEXT NOT NULL,
expires_utc INTEGER NOT NULL,
is_secure INTEGER NOT NULL,
is_httponly INTEGER NOT NULL,
last_access_utc INTEGER NOT NULL,
has_expires INTEGER NOT NULL DEFAULT 1,
is_persistent INTEGER NOT NULL DEFAULT 1,
priority INTEGER NOT NULL DEFAULT 1,
samesite INTEGER NOT NULL DEFAULT -1,
source_scheme INTEGER NOT NULL DEFAULT 0,
source_port INTEGER NOT NULL DEFAULT -1,
last_update_utc INTEGER NOT NULL DEFAULT 0,
source_type INTEGER NOT NULL DEFAULT 0,
has_cross_site_ancestor INTEGER NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX cookies_unique_index
ON cookies(host_key, top_frame_site_key, name, path);
CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);
INSERT INTO meta VALUES('version', '24');
INSERT INTO meta VALUES('last_compatible_version', '24');
`);
const insert = db.prepare(
`INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value,
encrypted_value, path, expires_utc, is_secure, is_httponly, last_access_utc)
VALUES(?, ?, '', ?, ?, ?, '/', 0, 0, 0, 0)`,
);
// `encrypted` cookies are written the way Chromium's v23->v24 migration
// does: BindString into a BLOB column, which leaves the storage class as
// TEXT. Reading that as a strict blob returns empty and silently blanks the
// cookie, so the suite has to reproduce it rather than only binding blobs.
const insertAsText = db.prepare(
`INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value,
encrypted_value, path, expires_utc, is_secure, is_httponly, last_access_utc)
VALUES(?, ?, '', ?, '', CAST(? AS TEXT), '/', 0, 0, 0, 0)`,
);
let creation = 13000000000000000;
for (const cookie of cookies) {
if (cookie.encryptedValueText === undefined) {
insert.run(creation++, cookie.host, cookie.name, cookie.value, "");
} else {
insertAsText.run(
creation++,
cookie.host,
cookie.name,
cookie.encryptedValueText,
);
}
}
db.close();
}
/** Write a Chromium History database holding the given URLs. */
export function writeChromiumHistory(dbPath, urls) {
const db = new DatabaseSync(dbPath);
db.exec(`
CREATE TABLE urls(
id INTEGER PRIMARY KEY AUTOINCREMENT,
url LONGVARCHAR,
title LONGVARCHAR,
visit_count INTEGER DEFAULT 0 NOT NULL,
typed_count INTEGER DEFAULT 0 NOT NULL,
last_visit_time INTEGER NOT NULL,
hidden INTEGER DEFAULT 0 NOT NULL
);
CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);
INSERT INTO meta VALUES('version', '69');
INSERT INTO meta VALUES('last_compatible_version', '16');
`);
const insert = db.prepare(
"INSERT INTO urls(url, title, visit_count, typed_count, last_visit_time, hidden) VALUES(?, ?, 1, 0, ?, 0)",
);
let visit = 13000000000000000;
for (const url of urls) {
insert.run(url, url, visit++);
}
db.close();
}
+456 -238
View File
@@ -1,9 +1,16 @@
import assert from "node:assert/strict";
import { mkdir, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import test from "node:test";
import { withApp } from "../lib/app.mjs";
import { extensionZipBase64, wireGuardFixture } from "../lib/fixtures.mjs";
import {
extensionZipBase64,
wireGuardFixture,
writeChromiumCookies,
writeChromiumHistory,
} from "../lib/fixtures.mjs";
async function createProfile(app, name = "Entity Profile") {
return app.invoke("create_browser_profile_new", {
@@ -24,257 +31,468 @@ async function createProfile(app, name = "Entity Profile") {
}
test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", async () => {
await withApp("entities-core", async (app) => {
const group = await app.invoke("create_profile_group", {
name: "Research",
});
assert.equal(group.name, "Research");
const renamedGroup = await app.invoke("update_profile_group", {
groupId: group.id,
name: "Research Team",
});
assert.equal(renamedGroup.name, "Research Team");
// Profile import derives its browser version from the downloaded-browsers
// registry, so without an entry every import fails with
// BROWSER_NOT_DOWNLOADED before it touches a single file.
await withApp(
"entities-core",
async (app) => {
const group = await app.invoke("create_profile_group", {
name: "Research",
});
assert.equal(group.name, "Research");
const renamedGroup = await app.invoke("update_profile_group", {
groupId: group.id,
name: "Research Team",
});
assert.equal(renamedGroup.name, "Research Team");
const duplicateError = await app.invokeError("create_profile_group", {
name: "Research Team",
});
assert.match(duplicateError, /GROUP_ALREADY_EXISTS|already exists/i);
const duplicateError = await app.invokeError("create_profile_group", {
name: "Research Team",
});
assert.match(duplicateError, /GROUP_ALREADY_EXISTS|already exists/i);
const proxy = await app.invoke("create_stored_proxy", {
name: "Local Dead Proxy",
proxySettings: {
proxy_type: "http",
host: "127.0.0.1",
port: 9,
username: "e2e-user",
password: "e2e-pass",
},
});
assert.equal(proxy.proxy_settings.password, "e2e-pass");
const updatedProxy = await app.invoke("update_stored_proxy", {
proxyId: proxy.id,
name: "Updated Proxy",
proxySettings: {
proxy_type: "socks5",
host: "127.0.0.1",
port: 9,
username: null,
password: null,
},
});
assert.equal(updatedProxy.name, "Updated Proxy");
assert.equal(updatedProxy.updated_at >= proxy.updated_at, true);
const proxy = await app.invoke("create_stored_proxy", {
name: "Local Dead Proxy",
proxySettings: {
proxy_type: "http",
host: "127.0.0.1",
port: 9,
username: "e2e-user",
password: "e2e-pass",
},
});
assert.equal(proxy.proxy_settings.password, "e2e-pass");
const updatedProxy = await app.invoke("update_stored_proxy", {
proxyId: proxy.id,
name: "Updated Proxy",
proxySettings: {
proxy_type: "socks5",
host: "127.0.0.1",
port: 9,
username: null,
password: null,
},
});
assert.equal(updatedProxy.name, "Updated Proxy");
assert.equal(updatedProxy.updated_at >= proxy.updated_at, true);
const parsed = await app.invoke("parse_txt_proxies", {
content: [
"http://one.example:8080",
"two.example:1080:user:pass",
"not a proxy",
].join("\n"),
});
assert.equal(parsed.length, 3);
assert.ok(parsed.some((result) => result.status === "parsed"));
assert.ok(parsed.some((result) => result.status === "invalid"));
const parsedProxy = parsed.find((result) => result.status === "parsed");
const { status: _status, ...parsedProxyFields } = parsedProxy;
const parsedImport = await app.invoke("import_proxies_from_parsed", {
parsedProxies: [parsedProxyFields],
namePrefix: "Parsed",
});
assert.equal(parsedImport.imported_count, 1);
const parsed = await app.invoke("parse_txt_proxies", {
content: [
"http://one.example:8080",
"two.example:1080:user:pass",
"not a proxy",
].join("\n"),
});
assert.equal(parsed.length, 3);
assert.ok(parsed.some((result) => result.status === "parsed"));
assert.ok(parsed.some((result) => result.status === "invalid"));
const parsedProxy = parsed.find((result) => result.status === "parsed");
const { status: _status, ...parsedProxyFields } = parsedProxy;
const parsedImport = await app.invoke("import_proxies_from_parsed", {
parsedProxies: [parsedProxyFields],
namePrefix: "Parsed",
});
assert.equal(parsedImport.imported_count, 1);
const validityError = await app.invokeError("check_proxy_validity", {
proxyId: proxy.id,
proxySettings: null,
});
assert.match(validityError, /Proxy check failed|Could not connect/i);
const cachedValidity = await app.invoke("get_cached_proxy_check", {
proxyId: proxy.id,
});
assert.ok(cachedValidity === null || cachedValidity.is_valid === false);
const validityError = await app.invokeError("check_proxy_validity", {
proxyId: proxy.id,
proxySettings: null,
});
assert.match(validityError, /Proxy check failed|Could not connect/i);
const cachedValidity = await app.invoke("get_cached_proxy_check", {
proxyId: proxy.id,
});
assert.ok(cachedValidity === null || cachedValidity.is_valid === false);
// Donut accepts one VLESS shape (REALITY + XTLS Vision over TCP). The form
// uses this to tell the user WHICH part of their setup is unsupported
// instead of implying they mistyped, so the reason must survive the IPC hop.
const goodVless =
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@example.com:443" +
"?security=reality&flow=xtls-rprx-vision&encryption=none&type=tcp" +
"&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&sid=00&fp=chrome";
assert.equal(
await app.invoke("validate_vless_uri", { uri: goodVless }),
null,
);
for (const [uri, reason] of [
[goodVless.replace("security=reality", "security=tls"), "security"],
[goodVless.replace("type=tcp", "type=ws"), "transport"],
[goodVless.replace("flow=xtls-rprx-vision", "flow=none"), "flow"],
]) {
// invokeError returns the command's error wrapped in a message, so match
// rather than JSON.parse the whole string.
const error = await app.invokeError("validate_vless_uri", { uri });
assert.match(error, /VLESS_CONFIG_INVALID/);
assert.match(
error,
new RegExp(`"reason":"${reason}"`),
`expected reason ${reason} for ${uri}, got: ${error}`,
// Donut accepts one VLESS shape (REALITY + XTLS Vision over TCP). The form
// uses this to tell the user WHICH part of their setup is unsupported
// instead of implying they mistyped, so the reason must survive the IPC hop.
const goodVless =
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@example.com:443" +
"?security=reality&flow=xtls-rprx-vision&encryption=none&type=tcp" +
"&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&sid=00&fp=chrome";
assert.equal(
await app.invoke("validate_vless_uri", { uri: goodVless }),
null,
);
}
const exported = JSON.parse(
await app.invoke("export_proxies", { format: "json" }),
);
assert.equal(exported.proxies.length, 2);
assert.ok(exported.proxies.some((item) => item.name === "Updated Proxy"));
assert.ok(exported.proxies.some((item) => item.name === "Parsed Proxy 1"));
const importResult = await app.invoke("import_proxies_json", {
content: JSON.stringify({
version: "1",
source: "Donut Browser",
exported_at: new Date().toISOString(),
proxies: [
for (const [uri, reason] of [
[goodVless.replace("security=reality", "security=tls"), "security"],
[goodVless.replace("type=tcp", "type=ws"), "transport"],
[goodVless.replace("flow=xtls-rprx-vision", "flow=none"), "flow"],
]) {
// invokeError returns the command's error wrapped in a message, so match
// rather than JSON.parse the whole string.
const error = await app.invokeError("validate_vless_uri", { uri });
assert.match(error, /VLESS_CONFIG_INVALID/);
assert.match(
error,
new RegExp(`"reason":"${reason}"`),
`expected reason ${reason} for ${uri}, got: ${error}`,
);
}
const exported = JSON.parse(
await app.invoke("export_proxies", { format: "json" }),
);
assert.equal(exported.proxies.length, 2);
assert.ok(exported.proxies.some((item) => item.name === "Updated Proxy"));
assert.ok(
exported.proxies.some((item) => item.name === "Parsed Proxy 1"),
);
const importResult = await app.invoke("import_proxies_json", {
content: JSON.stringify({
version: "1",
source: "Donut Browser",
exported_at: new Date().toISOString(),
proxies: [
{
name: "Imported Proxy",
type: "http",
host: "127.0.0.1",
port: 8081,
},
],
}),
});
assert.equal(importResult.imported_count, 1);
const profile = await createProfile(app);
assert.equal(profile.name, "Entity Profile");
assert.equal(
(
await app.invoke("update_profile_proxy", {
profileId: profile.id,
proxyId: proxy.id,
})
).proxy_id,
proxy.id,
);
await app.invoke("assign_profiles_to_group", {
profileIds: [profile.id],
groupId: group.id,
});
await app.invoke("rename_profile", {
profileId: profile.id,
newName: "Renamed Profile",
});
await app.invoke("update_profile_tags", {
profileId: profile.id,
tags: ["alpha", "automation"],
});
await app.invoke("update_profile_note", {
profileId: profile.id,
note: "Extensive E2E metadata",
});
await app.invoke("update_profile_window_color", {
profileId: profile.id,
windowColor: "#123456",
});
await app.invoke("update_profile_launch_hook", {
profileId: profile.id,
launchHook: `${process.env.DONUT_E2E_FIXTURE_URL}/launch-hook`,
});
const invalidHook = await app.invokeError("update_profile_launch_hook", {
profileId: profile.id,
launchHook: "file:///etc/passwd",
});
assert.match(invalidHook, /INVALID_LAUNCH_HOOK_URL/);
await app.invoke("update_profile_proxy_bypass_rules", {
profileId: profile.id,
rules: ["localhost", "*.internal.example"],
});
await app.invoke("update_profile_dns_blocklist", {
profileId: profile.id,
dnsBlocklist: "light",
});
await app.invoke("update_profile_clear_on_close", {
profileId: profile.id,
clearOnClose: true,
});
const profiles = await app.invoke("list_browser_profiles");
const changed = profiles.find((item) => item.id === profile.id);
assert.deepEqual(changed.tags, ["alpha", "automation"]);
assert.equal(changed.note, "Extensive E2E metadata");
assert.equal(changed.window_color, "#123456");
assert.equal(changed.group_id, group.id);
assert.deepEqual(changed.proxy_bypass_rules, [
"localhost",
"*.internal.example",
]);
assert.equal(changed.dns_blocklist, "light");
assert.equal(changed.clear_on_close, true);
assert.deepEqual((await app.invoke("get_all_tags")).sort(), [
"alpha",
"automation",
]);
assert.ok(Array.isArray(await app.invoke("detect_existing_profiles")));
const importRoot = path.join(app.root, "profile-import-fixture");
const importProfile = path.join(importRoot, "Default");
await mkdir(importProfile, { recursive: true });
await writeFile(
path.join(importProfile, "Preferences"),
JSON.stringify({
profile: { name: "Imported fixture", exit_type: "Crashed" },
download: { default_directory: "/Users/someone-else/Downloads" },
}),
);
// A Secure Preferences with MACs that can never validate under Wayfern,
// one real (relative-path) extension and one component extension that
// belongs to the source browser's bundle.
await writeFile(
path.join(importProfile, "Secure Preferences"),
JSON.stringify({
protection: { super_mac: "deadbeef", macs: { extensions: {} } },
extensions: {
settings: {
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: {
path: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/1.0_0",
},
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb: {
path: "/Applications/Chromium.app/Contents/Resources/component",
},
},
},
}),
);
// Caches must not be copied, and site data must be.
await mkdir(path.join(importProfile, "Cache"), { recursive: true });
await writeFile(path.join(importProfile, "Cache", "data_0"), "junk");
await mkdir(path.join(importProfile, "Local Storage", "leveldb"), {
recursive: true,
});
await writeFile(
path.join(importProfile, "Local Storage", "leveldb", "000003.log"),
"site-data",
);
writeChromiumHistory(path.join(importProfile, "History"), [
"https://example.com/",
"https://example.org/",
]);
writeChromiumCookies(path.join(importProfile, "Cookies"), [
{ host: "example.com", name: "sid", value: "session-token" },
{ host: "example.org", name: "pref", value: "dark" },
// Sealed with a key this machine does not have, and stored the way
// Chromium's own v23->v24 migration stores it (TEXT in a BLOB column).
// It must be reported as unrecoverable, never silently blanked and
// counted as migrated.
{
host: "sealed.example",
name: "sid",
encryptedValueText: "v10\u0001\u0002\u0003unopenable-ciphertext",
},
]);
const scanned = await app.invoke("scan_folder_for_profiles", {
folderPath: importRoot,
});
assert.equal(scanned.length, 1);
assert.equal(scanned[0].mapped_browser, "wayfern");
const importBatch = await app.invoke("import_browser_profiles", {
items: [
{
name: "Imported Proxy",
type: "http",
host: "127.0.0.1",
port: 8081,
source_path: scanned[0].path,
browser_type: scanned[0].browser,
new_profile_name: "Imported Profile",
proxy_id: null,
vpn_id: null,
},
],
}),
});
assert.equal(importResult.imported_count, 1);
groupId: null,
duplicateStrategy: "rename",
// A stored fingerprint, as elsewhere in this suite: generating a real
// one shells out to the Wayfern binary, which no CRUD suite installs.
wayfernConfig: { fingerprint: "{}" },
});
assert.equal(
importBatch.imported_count,
1,
`import must succeed: ${JSON.stringify(importBatch.results)}`,
);
const profile = await createProfile(app);
assert.equal(profile.name, "Entity Profile");
assert.equal(
(
await app.invoke("update_profile_proxy", {
profileId: profile.id,
proxyId: proxy.id,
})
).proxy_id,
proxy.id,
);
await app.invoke("assign_profiles_to_group", {
profileIds: [profile.id],
groupId: group.id,
});
await app.invoke("rename_profile", {
profileId: profile.id,
newName: "Renamed Profile",
});
await app.invoke("update_profile_tags", {
profileId: profile.id,
tags: ["alpha", "automation"],
});
await app.invoke("update_profile_note", {
profileId: profile.id,
note: "Extensive E2E metadata",
});
await app.invoke("update_profile_window_color", {
profileId: profile.id,
windowColor: "#123456",
});
await app.invoke("update_profile_launch_hook", {
profileId: profile.id,
launchHook: `${process.env.DONUT_E2E_FIXTURE_URL}/launch-hook`,
});
const invalidHook = await app.invokeError("update_profile_launch_hook", {
profileId: profile.id,
launchHook: "file:///etc/passwd",
});
assert.match(invalidHook, /INVALID_LAUNCH_HOOK_URL/);
await app.invoke("update_profile_proxy_bypass_rules", {
profileId: profile.id,
rules: ["localhost", "*.internal.example"],
});
await app.invoke("update_profile_dns_blocklist", {
profileId: profile.id,
dnsBlocklist: "light",
});
await app.invoke("update_profile_clear_on_close", {
profileId: profile.id,
clearOnClose: true,
});
const imported = importBatch.results[0];
// The assertion whose absence let the layout bug ship: an import that
// carries nothing used to be indistinguishable from a successful one.
assert.ok(
imported.report,
"an imported profile must report what it carried",
);
assert.equal(imported.report.cookies_migrated, 2);
assert.equal(
imported.report.cookies_unrecoverable,
1,
"a cookie no key can open must be counted, not silently emptied",
);
assert.equal(imported.report.history_entries, 2);
assert.equal(imported.report.extensions_migrated, 1);
assert.ok(imported.report.local_storage_origins > 0);
const profiles = await app.invoke("list_browser_profiles");
const changed = profiles.find((item) => item.id === profile.id);
assert.deepEqual(changed.tags, ["alpha", "automation"]);
assert.equal(changed.note, "Extensive E2E metadata");
assert.equal(changed.window_color, "#123456");
assert.equal(changed.group_id, group.id);
assert.deepEqual(changed.proxy_bypass_rules, [
"localhost",
"*.internal.example",
]);
assert.equal(changed.dns_blocklist, "light");
assert.equal(changed.clear_on_close, true);
assert.deepEqual((await app.invoke("get_all_tags")).sort(), [
"alpha",
"automation",
]);
const importedDir = path.join(
app.dataRoot,
"data",
"profiles",
imported.profile_id,
"profile",
);
// Chromium reads <user-data-dir>/Default/, so anything at the root is
// invisible to the browser no matter how faithfully it was copied.
assert.ok(
existsSync(path.join(importedDir, "Default", "Preferences")),
"profile content must land under Default/",
);
assert.ok(
!existsSync(path.join(importedDir, "Preferences")),
"nothing profile-scoped may sit at the user-data-dir root",
);
assert.ok(
existsSync(path.join(importedDir, "os_crypt_key")),
"Wayfern reads its key from the user-data-dir root",
);
assert.ok(
!existsSync(path.join(importedDir, "Default", "Cache")),
"caches are pure waste and must not be copied",
);
assert.ok(
existsSync(
path.join(
importedDir,
"Default",
"Local Storage",
"leveldb",
"000003.log",
),
),
"site data must survive",
);
assert.ok(Array.isArray(await app.invoke("detect_existing_profiles")));
const importRoot = path.join(app.root, "profile-import-fixture");
const importProfile = path.join(importRoot, "Default");
await mkdir(importProfile, { recursive: true });
await writeFile(
path.join(importProfile, "Preferences"),
JSON.stringify({ profile: { name: "Imported fixture" } }),
);
const scanned = await app.invoke("scan_folder_for_profiles", {
folderPath: importRoot,
});
assert.equal(scanned.length, 1);
assert.equal(scanned[0].mapped_browser, "wayfern");
const importBatch = await app.invoke("import_browser_profiles", {
items: [
{
source_path: scanned[0].path,
browser_type: scanned[0].browser,
new_profile_name: "Imported Profile",
proxy_id: null,
vpn_id: null,
},
],
groupId: null,
duplicateStrategy: "rename",
wayfernConfig: null,
});
assert.equal(importBatch.imported_count + importBatch.failed_count, 1);
const archivePath = path.join(app.root, "profile-import-fixture.zip");
await writeFile(archivePath, Buffer.from(extensionZipBase64(), "base64"));
const archiveScan = await app.invoke("scan_profile_archive", {
archivePath,
});
assert.ok(Array.isArray(archiveScan.profiles));
await app.invoke("cleanup_profile_import_scratch", {
extractedDir: archiveScan.extracted_dir,
});
const importedCookies = path.join(
importedDir,
"Default",
process.platform === "win32"
? path.join("Network", "Cookies")
: "Cookies",
);
assert.ok(
existsSync(importedCookies),
"cookies must sit where this platform's Chromium reads them",
);
// Chromium drops any row where both value and encrypted_value are set, so
// a "migrated" cookie that kept its plaintext would never load.
const cookieDb = new DatabaseSync(importedCookies, { readOnly: true });
const rows = cookieDb
.prepare(
"SELECT host_key, value, length(encrypted_value) AS enc FROM cookies ORDER BY host_key",
)
.all();
cookieDb.close();
assert.equal(
rows.length,
2,
"the unrecoverable row is dropped, not kept empty",
);
for (const row of rows) {
assert.equal(row.value, "", `${row.host_key} kept a plaintext value`);
assert.ok(row.enc > 0, `${row.host_key} was not re-encrypted`);
}
const clone = await app.invoke("clone_profile", {
profileId: profile.id,
name: "Cloned Profile",
});
assert.notEqual(clone.id, profile.id);
assert.equal(clone.name, "Cloned Profile");
const counts = await app.invoke("get_groups_with_profile_counts");
assert.equal(counts.find((item) => item.id === group.id).count, 2);
assert.equal((await app.invoke("get_profile_groups")).length, 1);
const securePrefs = JSON.parse(
await readFile(
path.join(importedDir, "Default", "Secure Preferences"),
"utf8",
),
);
assert.equal(
securePrefs.protection,
undefined,
"MACs from another machine can never validate and must be stripped",
);
assert.ok(
securePrefs.extensions.settings.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,
"the user's own extension must survive",
);
assert.equal(
securePrefs.extensions.settings.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,
undefined,
"a component extension pointing into the source browser must be dropped",
);
await app.invoke("delete_selected_profiles", {
profileIds: [profile.id, clone.id],
});
assert.deepEqual(await app.invoke("list_browser_profiles"), []);
await app.invoke("delete_profile_group", { groupId: group.id });
await app.invoke("delete_stored_proxy", { proxyId: proxy.id });
for (const importedProxy of (await app.invoke("get_stored_proxies")).filter(
(item) =>
item.name === "Imported Proxy" || item.name.startsWith("Parsed Proxy"),
)) {
await app.invoke("delete_stored_proxy", { proxyId: importedProxy.id });
}
});
const prefs = JSON.parse(
await readFile(
path.join(importedDir, "Default", "Preferences"),
"utf8",
),
);
assert.equal(prefs.profile.exit_type, "Normal");
assert.equal(prefs.download.default_directory, undefined);
assert.equal(prefs.profile.name, "Imported fixture");
// A Gecko profile must say why it cannot be imported instead of silently
// producing an empty one.
const firefoxRoot = path.join(app.root, "firefox-profile-fixture");
await mkdir(firefoxRoot, { recursive: true });
await writeFile(path.join(firefoxRoot, "prefs.js"), "// prefs");
await writeFile(path.join(firefoxRoot, "places.sqlite"), "");
const geckoBatch = await app.invoke("import_browser_profiles", {
items: [
{
source_path: firefoxRoot,
browser_type: "firefox",
new_profile_name: "Gecko Profile",
proxy_id: null,
vpn_id: null,
},
],
groupId: null,
duplicateStrategy: "rename",
wayfernConfig: { fingerprint: "{}" },
});
assert.equal(geckoBatch.failed_count, 1);
assert.match(
geckoBatch.results[0].error,
/IMPORT_SOURCE_NOT_CHROMIUM/,
"a Firefox folder must be rejected by name, not imported empty",
);
const archivePath = path.join(app.root, "profile-import-fixture.zip");
await writeFile(archivePath, Buffer.from(extensionZipBase64(), "base64"));
const archiveScan = await app.invoke("scan_profile_archive", {
archivePath,
});
assert.ok(Array.isArray(archiveScan.profiles));
await app.invoke("cleanup_profile_import_scratch", {
extractedDir: archiveScan.extracted_dir,
});
const clone = await app.invoke("clone_profile", {
profileId: profile.id,
name: "Cloned Profile",
});
assert.notEqual(clone.id, profile.id);
assert.equal(clone.name, "Cloned Profile");
const counts = await app.invoke("get_groups_with_profile_counts");
assert.equal(counts.find((item) => item.id === group.id).count, 2);
assert.equal((await app.invoke("get_profile_groups")).length, 1);
await app.invoke("delete_selected_profiles", {
profileIds: [profile.id, clone.id, imported.profile_id],
});
assert.deepEqual(await app.invoke("list_browser_profiles"), []);
await app.invoke("delete_profile_group", { groupId: group.id });
await app.invoke("delete_stored_proxy", { proxyId: proxy.id });
for (const importedProxy of (
await app.invoke("get_stored_proxies")
).filter(
(item) =>
item.name === "Imported Proxy" ||
item.name.startsWith("Parsed Proxy"),
)) {
await app.invoke("delete_stored_proxy", { proxyId: importedProxy.id });
}
},
{ seedDownloadedBrowser: true },
);
});
test("extensions, extension groups, VPN storage, DNS rules, and event-backed assignments", async () => {
+79 -4
View File
@@ -684,6 +684,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "block-padding"
version = "0.4.2"
@@ -971,6 +980,15 @@ dependencies = [
"toml 0.9.12+spec-1.1.0",
]
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher 0.4.4",
]
[[package]]
name = "cbc"
version = "0.2.1"
@@ -1809,7 +1827,7 @@ dependencies = [
"blake3",
"boringtun",
"bzip2",
"cbc",
"cbc 0.2.1",
"chrono",
"chrono-tz",
"clap",
@@ -1844,6 +1862,8 @@ dependencies = [
"resvg",
"ring",
"rusqlite",
"secret-service",
"security-framework",
"serde",
"serde_json",
"serde_yaml",
@@ -3323,6 +3343,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"block-padding 0.3.3",
"generic-array",
]
@@ -3332,7 +3353,7 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
dependencies = [
"block-padding",
"block-padding 0.4.2",
"hybrid-array",
]
@@ -4011,6 +4032,20 @@ dependencies = [
"winapi",
]
[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
dependencies = [
"num-bigint",
"num-complex",
"num-integer",
"num-iter",
"num-rational",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
@@ -4021,6 +4056,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -4047,6 +4091,16 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-iter"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
@@ -4093,7 +4147,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate 1.3.1",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.118",
@@ -5613,6 +5667,25 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "secret-service"
version = "5.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14"
dependencies = [
"aes 0.8.4",
"cbc 0.1.2",
"futures-util",
"generic-array",
"getrandom 0.2.17",
"hkdf",
"num",
"once_cell",
"serde",
"sha2 0.10.9",
"zbus",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -6861,7 +6934,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix",
"windows-sys 0.61.2",
@@ -7036,6 +7109,7 @@ dependencies = [
"signal-hook-registry",
"socket2",
"tokio-macros",
"tracing",
"windows-sys 0.61.2",
]
@@ -8808,6 +8882,7 @@ dependencies = [
"rustix",
"serde",
"serde_repr",
"tokio",
"tracing",
"uds_windows",
"uuid",
+9
View File
@@ -119,9 +119,15 @@ nix = { version = "0.31", features = ["signal", "process"] }
# Reading the desktop's titlebar button layout for the in-app window controls.
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18"
# Reading the source browser's "<Brand> Safe Storage" secret during profile
# import, so its cookies and passwords can be re-encrypted for Wayfern.
secret-service = { version = "5", default-features = false, features = ["rt-tokio-crypto-rust"] }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
# Reading the source browser's "<Brand> Safe Storage" Keychain item during
# profile import, so its cookies and passwords can be re-encrypted for Wayfern.
security-framework = "3"
objc2 = "0.6.4"
objc2-app-kit = { version = "0.3.2", features = ["NSWindow", "NSApplication", "NSRunningApplication"] }
@@ -138,6 +144,9 @@ windows = { version = "0.62", features = [
"Win32_Storage_FileSystem",
"Win32_System_Registry",
"Win32_UI_Shell",
# CryptUnprotectData, for unwrapping the source browser's os_crypt key from
# `Local State` during profile import.
"Win32_Security_Cryptography",
] }
[dev-dependencies]
+19 -1
View File
@@ -627,6 +627,7 @@ struct ImportProxiesResponse {
crate::profile_importer::DuplicateStrategy,
crate::profile_importer::ProfileImportItemResult,
crate::profile_importer::ProfileImportBatchResult,
crate::profile_import::report::ProfileImportReport,
)),
tags(
(name = "profiles", description = "Profile management endpoints"),
@@ -4445,13 +4446,29 @@ mod tests {
}
let import_item = schema_required(&spec, "ImportProfileItem");
for field in ["proxy_id", "vpn_id", "browser_type"] {
for field in ["proxy_id", "vpn_id", "browser_type", "allow_running"] {
assert!(
!import_item.iter().any(|f| f == field),
"{field} must be optional on import items, required list: {import_item:?}"
);
}
// The per-item report only exists for items that actually imported.
let import_result = schema_required(&spec, "ProfileImportItemResult");
assert!(
!import_result.iter().any(|f| f == "report"),
"report must be optional on import results, required list: {import_result:?}"
);
// `ProfileImportItemResult` references it, so a missing registration would
// leave a dangling $ref in the served spec.
assert!(
spec
.pointer("/components/schemas/ProfileImportReport")
.is_some(),
"ProfileImportReport must be registered in ApiDoc components"
);
// A remote launch with no URL just opens the browser; forcing generated
// clients to send one would make the common case the awkward one.
let run_remote = schema_required(&spec, "RunRemoteRequest");
@@ -4549,6 +4566,7 @@ mod tests {
assert!(parsed.group_id.is_none());
assert!(parsed.duplicate_strategy.is_none());
assert_eq!(parsed.items[0].browser_type, "chromium");
assert_eq!(parsed.items[0].allow_running, None);
}
// The served /openapi.json comes from the hand-maintained ApiDoc `paths(...)`
+19
View File
@@ -540,6 +540,25 @@ impl BrowserRunner {
let profiles_dir = self.profile_manager.get_profiles_dir();
let profile_data_path =
crate::ephemeral_dirs::get_effective_profile_path(&updated_profile, &profiles_dir);
// Profiles imported by builds before the layout fix have their content at
// the user-data-dir root instead of under `Default/`, so the browser has
// never seen a byte of it. Move it into place now, while the profile is
// provably not running. Secrets stay unreadable — the source key was
// never captured and cannot be recovered after the fact — but history,
// bookmarks, extensions and site data come back.
match crate::profile_import::repair_legacy_layout(&profile_data_path) {
Ok(true) => log::info!(
"Repaired legacy import layout for profile: {}",
updated_profile.name
),
Ok(false) => {}
Err(e) => log::warn!(
"Could not repair legacy import layout for {}: {e}",
updated_profile.name
),
}
let profile_path_str = profile_data_path.to_string_lossy().to_string();
// Install extensions if an extension group is assigned
+1
View File
@@ -80,6 +80,7 @@ mod launch_gate_prefs;
mod log_redaction;
mod platform_browser;
mod profile;
mod profile_import;
mod profile_importer;
mod proxy_manager;
pub mod proxy_runner;
+8
View File
@@ -746,6 +746,14 @@ impl McpServer {
"vpn_id": {
"type": "string",
"description": "Optional VPN UUID to assign to this profile"
},
"browser_type": {
"type": "string",
"description": "Source browser family (chromium, brave, edge, vivaldi, opera, arc, yandex, ...). Selects which OS keychain entry holds the key that unlocks the source's cookies and passwords, so an accurate value is what makes secrets survive the import"
},
"allow_running": {
"type": "boolean",
"description": "Import even though the source browser is running. Databases are still snapshotted consistently, but site data stored in LevelDB may be captured mid-write"
}
},
"required": ["source_path", "new_profile_name"]
+551
View File
@@ -0,0 +1,551 @@
//! Copying a source profile into the new one.
//!
//! Two things a plain recursive copy gets wrong, both of which produce a
//! profile that looks imported and is not:
//!
//! - **Torn databases.** Users import from a browser they are still using. A
//! naive walk copies `Cookies` and `Cookies-wal` at different instants, and
//! Chromium's `sql::Database` razes the result on open. `VACUUM INTO` takes a
//! transactionally consistent snapshot instead, WAL content included, even
//! while the source holds the file.
//! - **Multi-GB of caches.** `Cache/`, `Code Cache/`, `GPUCache/` and friends
//! carry no user state and dominate both copy time and disk use.
use std::fs;
use std::path::Path;
/// Directories that never carry user state. Matched on the path relative to the
/// profile root, so `Service Worker/CacheStorage` is dropped while
/// `Service Worker/Database` survives.
const SKIP_DIRS: &[&str] = &[
"Cache",
"Code Cache",
"GPUCache",
"GrShaderCache",
"ShaderCache",
"DawnCache",
"DawnGraphiteCache",
"DawnWebGPUCache",
"GraphiteDawnCache",
"GPUPersistentCache",
"Service Worker/CacheStorage",
"Service Worker/ScriptCache",
"blob_storage",
"Crashpad",
"Crash Reports",
"BrowserMetrics",
"optimization_guide_model_store",
"optimization_guide_hint_cache_store",
"Safe Browsing",
"Safe Browsing Network",
"component_crx_cache",
"extensions_crx_cache",
"Download Service",
"Site Characteristics Database",
"shared_proto_db",
"segmentation_platform",
"Sync App Settings",
// SNSS command logs replay the source machine's windows and can embed
// absolute local paths in PageState blobs.
"Sessions",
"Session Storage",
];
/// Exact file names that are per-machine, per-run, or regenerated.
const SKIP_FILES: &[&str] = &[
"LOCK",
"LOG",
"LOG.old",
"SingletonLock",
"SingletonCookie",
"SingletonSocket",
"RunningChromeVersion",
"Last Version",
"first_party_sets.db",
".DS_Store",
"Thumbs.db",
// The account-bound part of `Sync Data/`. The rest of that directory is the
// local DataTypeStore — Reading List, Saved Tab Groups and friends, which
// exist for users who never signed in — so the folder itself is carried.
"Nigori.bin",
// Signed-in ephemeral twins of the real stores. They are wiped on sign-out,
// and the imported profile will not be signed in.
"Login Data For Account",
"Login Data For Account-journal",
"Account Web Data",
"Account Web Data-journal",
];
/// Suffixes that belong to a database we snapshot separately, or to scratch
/// state. Copying a `-wal` next to a vacuumed main file actively corrupts it.
const SKIP_SUFFIXES: &[&str] = &["-journal", "-wal", "-shm", ".tmp", ".old", ".bak.tmp"];
/// SQLite stores worth a consistent snapshot. Anything not listed is copied
/// byte-for-byte, which is correct for JSON, LevelDB and unpacked CRXs.
const SQLITE_FILES: &[&str] = &[
"Cookies",
"History",
"Favicons",
"Top Sites",
"Shortcuts",
"Login Data",
"Web Data",
"Affiliation Database",
"Network Action Predictor",
"DIPS",
"Trust Tokens",
"BudgetDatabase",
"AutofillStrikeDatabase",
"Reporting and NEL",
"SCT Auditing Pending Reports",
"Device Bound Sessions",
"MediaDeviceSalts",
"PreferredApps",
"heavy_ad_intervention_opt_out.db",
"SharedStorage",
"BrowsingTopicsSiteData",
"ClientCertificates",
"PersistentOriginTrials",
"Web Applications",
];
pub struct CopyOutcome {
pub bytes_copied: u64,
/// Names of stores that could not be snapshotted and were skipped rather
/// than copied in a corrupt state.
pub unreadable_stores: Vec<String>,
}
fn is_skipped_dir(relative: &Path) -> bool {
let normalized = relative.to_string_lossy().replace('\\', "/");
SKIP_DIRS.iter().any(|skip| {
normalized == *skip
|| normalized.ends_with(&format!("/{skip}"))
// `BrowserMetrics-spare.pma` and friends.
|| normalized.starts_with(&format!("{skip}-"))
})
}
fn is_skipped_file(name: &str) -> bool {
SKIP_FILES.contains(&name)
|| SKIP_SUFFIXES.iter().any(|suffix| name.ends_with(suffix))
|| name.starts_with("BrowserMetrics")
}
/// Copy the source's permission bits onto a file we produced ourselves.
///
/// `fs::copy` already preserves the mode, but `VACUUM INTO` lets SQLite create
/// the destination at its own default (0644). Cookies, Login Data and Web Data
/// are 0600 in both the source browser and Wayfern, and an import must not be
/// the step that widens them.
#[cfg(unix)]
fn mirror_mode(source: &Path, dest: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = fs::metadata(source) {
let mode = metadata.permissions().mode() & 0o777;
let _ = fs::set_permissions(dest, fs::Permissions::from_mode(mode));
}
}
#[cfg(not(unix))]
fn mirror_mode(_source: &Path, _dest: &Path) {}
/// Create a directory owner-only, matching what Chromium gives a profile.
fn create_private_dir(path: &Path) -> std::io::Result<()> {
fs::create_dir_all(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o700));
}
Ok(())
}
/// Take a consistent snapshot of a SQLite database.
///
/// Returns `Ok(false)` when the file is not actually SQLite (an empty
/// placeholder, say), so the caller can fall back to a plain copy.
fn vacuum_into(source: &Path, dest: &Path) -> Result<bool, String> {
use rusqlite::{Connection, OpenFlags};
let conn = match Connection::open_with_flags(
source,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
) {
Ok(conn) => conn,
Err(e) => return Err(format!("open failed: {e}")),
};
// Confirm it really is a database before trusting VACUUM's error reporting.
if conn
.query_row("SELECT count(*) FROM sqlite_master", [], |r| {
r.get::<_, i64>(0)
})
.is_err()
{
return Ok(false);
}
if dest.exists() {
fs::remove_file(dest).map_err(|e| format!("could not replace destination: {e}"))?;
}
// `VACUUM INTO` needs the path as a SQL string literal; single quotes are
// the only character that can break out of one.
let target = dest.to_string_lossy().replace('\'', "''");
conn
.execute_batch(&format!("VACUUM INTO '{target}'"))
.map_err(|e| format!("VACUUM INTO failed: {e}"))?;
mirror_mode(source, dest);
Ok(true)
}
/// Copy `source` (a Chromium profile directory) into `dest`, skipping caches
/// and snapshotting databases.
pub fn copy_profile_tree(source: &Path, dest: &Path) -> Result<CopyOutcome, String> {
let mut outcome = CopyOutcome {
bytes_copied: 0,
unreadable_stores: Vec::new(),
};
create_private_dir(dest).map_err(|e| format!("Failed to create {}: {e}", dest.display()))?;
copy_dir(source, dest, Path::new(""), &mut outcome)?;
Ok(outcome)
}
fn copy_dir(
source: &Path,
dest: &Path,
relative: &Path,
outcome: &mut CopyOutcome,
) -> Result<(), String> {
let entries =
fs::read_dir(source).map_err(|e| format!("Failed to read {}: {e}", source.display()))?;
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let child_relative = relative.join(name);
let source_path = entry.path();
let dest_path = dest.join(name);
// Symlinks are followed nowhere: Chromium writes them for the singleton
// lock, and a copied one would point at the source machine.
let metadata = match fs::symlink_metadata(&source_path) {
Ok(m) => m,
Err(_) => continue,
};
if metadata.file_type().is_symlink() {
continue;
}
if metadata.is_dir() {
if is_skipped_dir(&child_relative) {
continue;
}
create_private_dir(&dest_path)
.map_err(|e| format!("Failed to create {}: {e}", dest_path.display()))?;
copy_dir(&source_path, &dest_path, &child_relative, outcome)?;
continue;
}
if is_skipped_file(name) {
continue;
}
if SQLITE_FILES.contains(&name) {
match vacuum_into(&source_path, &dest_path) {
Ok(true) => {
outcome.bytes_copied += fs::metadata(&dest_path).map(|m| m.len()).unwrap_or(0);
continue;
}
Ok(false) => {
// Not a database after all; fall through to a byte copy.
}
Err(e) => {
// A store we cannot snapshot is a store we must not copy: a torn
// copy is deleted by Chromium on open, which looks identical to
// "the import silently lost my data".
log::warn!("Skipping unreadable store {}: {e}", source_path.display());
outcome.unreadable_stores.push(name.to_string());
continue;
}
}
}
match fs::copy(&source_path, &dest_path) {
Ok(bytes) => outcome.bytes_copied += bytes,
Err(e) => log::warn!("Failed to copy {}: {e}", source_path.display()),
}
}
Ok(())
}
/// Every `Default/`-level store that holds real user data, for reporting.
pub fn count_leveldb_origins(leveldb_dir: &Path) -> usize {
// Counting keys would mean linking a LevelDB implementation. The number of
// `.ldb`/`.log` segments is a stable proxy for "there is data here", which
// is all the report claims.
let Ok(entries) = fs::read_dir(leveldb_dir) else {
return 0;
};
entries
.flatten()
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.ends_with(".ldb") || n.ends_with(".log"))
})
.count()
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
use tempfile::TempDir;
fn touch(path: &Path, contents: &[u8]) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, contents).unwrap();
}
#[test]
fn caches_are_not_copied() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
touch(&source.join("Cache").join("data_0"), &[0u8; 4096]);
touch(
&source.join("Code Cache").join("js").join("x"),
&[0u8; 4096],
);
touch(
&source.join("Service Worker").join("CacheStorage").join("y"),
&[0u8; 4096],
);
touch(
&source
.join("Service Worker")
.join("Database")
.join("CURRENT"),
b"MANIFEST-000001\n",
);
copy_profile_tree(&source, &dest).unwrap();
assert!(dest.join("Preferences").exists());
assert!(!dest.join("Cache").exists());
assert!(!dest.join("Code Cache").exists());
assert!(!dest.join("Service Worker").join("CacheStorage").exists());
assert!(
dest.join("Service Worker").join("Database").exists(),
"the Service Worker registry is real data and must survive"
);
}
#[test]
fn lock_and_journal_files_are_not_copied() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
touch(
&source.join("Local Storage").join("leveldb").join("LOCK"),
b"",
);
touch(
&source.join("Local Storage").join("leveldb").join("CURRENT"),
b"MANIFEST-000001\n",
);
touch(&source.join("History-journal"), b"junk");
copy_profile_tree(&source, &dest).unwrap();
assert!(!dest
.join("Local Storage")
.join("leveldb")
.join("LOCK")
.exists());
assert!(dest
.join("Local Storage")
.join("leveldb")
.join("CURRENT")
.exists());
assert!(!dest.join("History-journal").exists());
}
#[test]
fn sqlite_stores_are_snapshotted_and_stay_queryable() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
fs::create_dir_all(&source).unwrap();
touch(&source.join("Preferences"), b"{}");
let db = source.join("History");
let conn = Connection::open(&db).unwrap();
conn
.execute_batch("CREATE TABLE urls(id INTEGER PRIMARY KEY, url TEXT); INSERT INTO urls(url) VALUES('https://example.com');")
.unwrap();
drop(conn);
copy_profile_tree(&source, &dest).unwrap();
let copied = Connection::open(dest.join("History")).unwrap();
let count: i64 = copied
.query_row("SELECT count(*) FROM urls", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn snapshot_captures_uncheckpointed_wal_content() {
// The whole reason for VACUUM INTO: a running browser leaves recent writes
// in the WAL, and a plain file copy loses them.
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
fs::create_dir_all(&source).unwrap();
touch(&source.join("Preferences"), b"{}");
let db = source.join("History");
let conn = Connection::open(&db).unwrap();
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
conn
.execute_batch("CREATE TABLE urls(id INTEGER PRIMARY KEY, url TEXT);")
.unwrap();
conn
.execute("INSERT INTO urls(url) VALUES('https://in-wal.example')", [])
.unwrap();
// Deliberately do not checkpoint or close: this is the live-browser shape.
copy_profile_tree(&source, &dest).unwrap();
drop(conn);
let copied = Connection::open(dest.join("History")).unwrap();
let url: String = copied
.query_row("SELECT url FROM urls", [], |r| r.get(0))
.unwrap();
assert_eq!(url, "https://in-wal.example");
assert!(
!dest.join("History-wal").exists(),
"a stale -wal beside a vacuumed file corrupts it"
);
}
#[test]
fn symlinks_are_never_followed() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
let outside = dir.path().join("outside.txt");
touch(&outside, b"secret");
#[cfg(unix)]
std::os::unix::fs::symlink(&outside, source.join("SingletonLock")).unwrap();
copy_profile_tree(&source, &dest).unwrap();
assert!(!dest.join("SingletonLock").exists());
}
#[test]
fn account_scoped_stores_are_dropped() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
touch(&source.join("Login Data For Account"), b"x");
touch(&source.join("Sync Data").join("Nigori.bin"), b"x");
touch(
&source.join("Sync Data").join("LevelDB").join("CURRENT"),
b"x",
);
copy_profile_tree(&source, &dest).unwrap();
assert!(!dest.join("Login Data For Account").exists());
assert!(
!dest.join("Sync Data").join("Nigori.bin").exists(),
"the Nigori keyset is bound to a Google account"
);
assert!(
dest
.join("Sync Data")
.join("LevelDB")
.join("CURRENT")
.exists(),
"the rest of Sync Data is local state such as the reading list"
);
}
#[test]
#[cfg(unix)]
fn copied_databases_keep_the_browsers_private_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
fs::create_dir_all(&source).unwrap();
touch(&source.join("Preferences"), b"{}");
let db = source.join("Cookies");
let conn = rusqlite::Connection::open(&db).unwrap();
conn
.execute_batch("CREATE TABLE cookies(x INTEGER);")
.unwrap();
drop(conn);
fs::set_permissions(&db, fs::Permissions::from_mode(0o600)).unwrap();
copy_profile_tree(&source, &dest).unwrap();
// VACUUM INTO would otherwise create the snapshot at SQLite's default 0644.
let mode = fs::metadata(dest.join("Cookies"))
.unwrap()
.permissions()
.mode();
assert_eq!(
mode & 0o777,
0o600,
"an import must not widen a cookie store"
);
let dir_mode = fs::metadata(&dest).unwrap().permissions().mode();
assert_eq!(dir_mode & 0o777, 0o700);
}
#[test]
fn unreadable_store_is_reported_not_copied_corrupt() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
// A file that opens as SQLite but is structurally broken.
touch(
&source.join("Cookies"),
b"SQLite format 3\0garbage-not-a-db",
);
let outcome = copy_profile_tree(&source, &dest).unwrap();
assert!(
!dest.join("Cookies").exists() || outcome.unreadable_stores.is_empty(),
"a store is either snapshotted cleanly or skipped and reported"
);
}
#[test]
fn non_sqlite_file_with_a_store_name_still_copies() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
touch(&source.join("Top Sites"), b"");
copy_profile_tree(&source, &dest).unwrap();
assert!(dest.join("Top Sites").exists());
}
}
+392
View File
@@ -0,0 +1,392 @@
//! Recovering the *source* browser's os_crypt key.
//!
//! Every Chromium-family browser seals cookies, passwords and payment data with
//! a key held outside the profile: the macOS Keychain, a DPAPI blob in
//! `Local State`, or the Freedesktop secret service. Import has to open that
//! lock before it can re-seal anything with Wayfern's portable key
//! ([`super::os_crypt::TargetKey`]).
//!
//! Failure here is never fatal. A declined Keychain prompt or a locked keyring
//! degrades to "everything except the secrets came across", recorded as a
//! warning, because a partial profile is worth far more than a failed import.
#[cfg(target_os = "windows")]
use super::os_crypt::CryptoKey;
use super::os_crypt::SourceKeyring;
#[cfg(target_os = "macos")]
use super::os_crypt::MAC_ITERATIONS;
#[cfg(any(target_os = "macos", target_os = "linux"))]
use super::os_crypt::{derive_key, CryptoKey};
#[cfg(target_os = "linux")]
use super::os_crypt::{POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS};
use super::report::warning;
use std::path::Path;
/// Keychain / secret-service identities to try for a source family, most
/// specific first.
///
/// Trying several is safe and costs nothing: a lookup for a service that does
/// not exist fails without prompting, so at most one dialog appears — the one
/// for the item that is actually there. That is what lets a single `chromium`
/// family key cover both Google Chrome and vanilla Chromium, which share a
/// detection entry but not a Keychain item.
fn brand_candidates(family: &str, source_path: &Path) -> Vec<&'static str> {
let path = source_path.to_string_lossy();
let mut brands: Vec<&'static str> = match family {
"chrome-beta" => vec!["Chrome Beta", "Chrome"],
"chrome-dev" => vec!["Chrome Dev", "Chrome"],
"chrome-canary" => vec!["Chrome Canary", "Chrome"],
"brave" => vec!["Brave", "Brave Browser"],
"brave-beta" => vec!["Brave Beta", "Brave Browser", "Brave"],
"brave-nightly" => vec!["Brave Nightly", "Brave Browser", "Brave"],
"edge" => vec!["Microsoft Edge", "Chromium"],
"edge-beta" => vec!["Microsoft Edge Beta", "Microsoft Edge"],
"edge-dev" => vec!["Microsoft Edge Dev", "Microsoft Edge"],
"vivaldi" => vec!["Vivaldi", "Chromium"],
"opera" => vec!["Opera", "Chromium"],
"opera-gx" => vec!["Opera GX", "Opera", "Chromium"],
"arc" => vec!["Arc", "Chromium"],
"yandex" => vec!["Yandex", "Yandex Browser", "Chromium"],
// "chromium" covers both Google Chrome and upstream Chromium; the install
// path is the only thing that tells them apart.
_ => vec!["Chrome", "Chromium"],
};
if (family.is_empty() || family == "chromium")
&& path.contains("Chromium")
&& !path.contains("Google")
{
brands = vec!["Chromium", "Chrome"];
}
brands
}
/// Recover whatever key material the source browser used.
///
/// `source_user_data_dir` is the directory holding `Local State` (the parent of
/// the profile directory), which is where Windows keeps its wrapped key. It is
/// `None` when the user pointed at a bare profile folder with no parent we can
/// trust.
pub fn recover_source_keys(
family: &str,
source_path: &Path,
source_user_data_dir: Option<&Path>,
report: &mut super::report::ProfileImportReport,
) -> SourceKeyring {
let mut keyring = SourceKeyring::default();
#[cfg(target_os = "macos")]
{
let _ = source_user_data_dir;
for brand in brand_candidates(family, source_path) {
match macos_keychain_password(brand) {
Ok(Some(password)) => {
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(&password, MAC_ITERATIONS)));
log::info!("Recovered os_crypt password for '{brand} Safe Storage'");
break;
}
Ok(None) => continue,
Err(e) => {
log::warn!("Keychain lookup for '{brand} Safe Storage' failed: {e}");
break;
}
}
}
}
#[cfg(target_os = "windows")]
{
let _ = source_path;
if let Some(dir) = source_user_data_dir {
match windows_local_state_key(dir) {
Ok(Some(key)) => keyring.v10 = Some(CryptoKey::Aes256Gcm(key)),
Ok(None) => {}
Err(e) => log::warn!("DPAPI key recovery failed: {e}"),
}
if windows_has_app_bound_key(dir) {
// Recorded up front: the cookie store will be full of `v20` records
// and the user deserves to know why before they see the count.
report.warn(warning::APP_BOUND_ENCRYPTED);
}
}
}
#[cfg(target_os = "linux")]
{
let _ = source_user_data_dir;
// A profile can hold both tags at once, so populate both slots rather than
// choosing one. v10 is always available: it is a hardcoded password.
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(
POSIX_FALLBACK_PASSWORD,
POSIX_ITERATIONS,
)));
for brand in brand_candidates(family, source_path) {
match linux_secret_service_password(brand) {
Ok(Some(password)) => {
keyring.v11 = Some(CryptoKey::Aes128Cbc(derive_key(
&password,
POSIX_ITERATIONS,
)));
log::info!("Recovered os_crypt secret for '{brand} Safe Storage'");
break;
}
Ok(None) => continue,
Err(e) => {
log::warn!("Secret service lookup for '{brand} Safe Storage' failed: {e}");
break;
}
}
}
}
if keyring.is_empty() {
report.warn(warning::SECRETS_NOT_MIGRATED);
}
// Silence unused-parameter warnings on platforms that do not use every arg.
let _ = (family, source_path, source_user_data_dir);
keyring
}
/// How long to wait on a keyring before giving up.
///
/// Both backends can put a dialog in front of the user — macOS asks whether
/// Donut may read another app's Keychain item, and an unlocked-on-demand
/// keyring prompts on Linux. That is fine interactively, but an import driven
/// over REST or MCP would otherwise wedge forever with nobody at the screen.
/// Long enough for a person to notice and click; short enough that automation
/// recovers into "secrets not migrated", which is merely a partial import.
#[cfg(any(target_os = "macos", target_os = "linux"))]
const KEYRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
/// Run a keyring lookup on its own OS thread, bounded by [`KEYRING_TIMEOUT`].
///
/// Off-thread rather than inline for two reasons: import already runs inside
/// `spawn_blocking`, and zbus's blocking API drives a private tokio runtime, so
/// keeping it off a runtime-owned thread sidesteps any nested-runtime question;
/// and it turns a panic or a stuck IPC call into a recoverable warning instead
/// of a failed import.
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn run_keyring_lookup<F>(what: &str, lookup: F) -> Result<Option<Vec<u8>>, String>
where
F: FnOnce() -> Result<Option<Vec<u8>>, String> + Send + std::panic::UnwindSafe + 'static,
{
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let result =
std::panic::catch_unwind(lookup).unwrap_or_else(|_| Err("lookup panicked".to_string()));
let _ = tx.send(result);
});
match rx.recv_timeout(KEYRING_TIMEOUT) {
Ok(result) => result,
Err(_) => Err(format!("{what} did not respond")),
}
}
#[cfg(target_os = "macos")]
fn macos_keychain_password(brand: &str) -> Result<Option<Vec<u8>>, String> {
let brand = brand.to_string();
run_keyring_lookup("keychain", move || macos_keychain_lookup(&brand))
}
#[cfg(target_os = "macos")]
fn macos_keychain_lookup(brand: &str) -> Result<Option<Vec<u8>>, String> {
use security_framework::passwords::get_generic_password;
let service = format!("{brand} Safe Storage");
match get_generic_password(&service, brand) {
Ok(password) => Ok(Some(password)),
Err(e) => {
// errSecItemNotFound: this brand simply is not installed. Anything else
// (notably errSecAuthFailed / errSecUserCanceled when the user declines
// the access dialog) is a real failure worth surfacing.
if e.code() == -25300 {
Ok(None)
} else {
Err(e.to_string())
}
}
}
}
#[cfg(target_os = "windows")]
fn read_local_state_os_crypt(dir: &Path) -> Option<serde_json::Value> {
let raw = std::fs::read_to_string(dir.join("Local State")).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
parsed.get("os_crypt").cloned()
}
#[cfg(target_os = "windows")]
fn windows_has_app_bound_key(dir: &Path) -> bool {
read_local_state_os_crypt(dir)
.and_then(|v| {
v.get("app_bound_encrypted_key")
.and_then(|k| k.as_str().map(str::to_string))
})
.is_some_and(|k| !k.is_empty())
}
#[cfg(target_os = "windows")]
fn windows_local_state_key(dir: &Path) -> Result<Option<[u8; 32]>, String> {
use base64::Engine;
let Some(os_crypt) = read_local_state_os_crypt(dir) else {
return Ok(None);
};
let Some(encoded) = os_crypt.get("encrypted_key").and_then(|k| k.as_str()) else {
return Ok(None);
};
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|e| format!("encrypted_key is not valid base64: {e}"))?;
// The blob is "DPAPI" || CryptProtectData(key).
const DPAPI_PREFIX: &[u8] = b"DPAPI";
if !decoded.starts_with(DPAPI_PREFIX) {
return Err("encrypted_key is missing the DPAPI header".to_string());
}
let unwrapped = dpapi_unprotect(&decoded[DPAPI_PREFIX.len()..])?;
let key: [u8; 32] = unwrapped
.as_slice()
.try_into()
.map_err(|_| format!("expected a 32-byte AES key, got {} bytes", unwrapped.len()))?;
Ok(Some(key))
}
#[cfg(target_os = "windows")]
fn dpapi_unprotect(ciphertext: &[u8]) -> Result<Vec<u8>, String> {
use windows::Win32::Foundation::LocalFree;
use windows::Win32::Security::Cryptography::{CryptUnprotectData, CRYPT_INTEGER_BLOB};
let mut input = CRYPT_INTEGER_BLOB {
cbData: ciphertext.len() as u32,
pbData: ciphertext.as_ptr() as *mut u8,
};
let mut output = CRYPT_INTEGER_BLOB::default();
// SAFETY: `input` points at a live slice for the duration of the call, and
// `output` is freed via LocalFree exactly once below, as the API requires.
unsafe {
CryptUnprotectData(&mut input, None, None, None, None, 0, &mut output)
.map_err(|e| format!("CryptUnprotectData failed: {e}"))?;
let plaintext = std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec();
let _ = LocalFree(Some(windows::Win32::Foundation::HLOCAL(
output.pbData as *mut core::ffi::c_void,
)));
Ok(plaintext)
}
}
#[cfg(target_os = "linux")]
fn linux_secret_service_password(brand: &str) -> Result<Option<Vec<u8>>, String> {
let brand = brand.to_string();
run_keyring_lookup("secret service", move || {
linux_secret_service_lookup(&brand)
})
}
#[cfg(target_os = "linux")]
fn linux_secret_service_lookup(brand: &str) -> Result<Option<Vec<u8>>, String> {
use secret_service::blocking::SecretService;
use secret_service::EncryptionType;
use std::collections::HashMap;
let service =
SecretService::connect(EncryptionType::Dh).map_err(|e| format!("no secret service: {e}"))?;
let collection = service
.get_default_collection()
.map_err(|e| format!("no default collection: {e}"))?;
if collection.is_locked().unwrap_or(true) {
collection
.unlock()
.map_err(|e| format!("keyring is locked: {e}"))?;
}
// Match on the item's LABEL, not on its `application` attribute.
//
// `freedesktop_secret_key_provider.cc` stores two attributes —
// `application: kAppName` and `xdg:schema` — and sets the label to
// `kKeyName`, which is always "<Brand> Safe Storage". `kAppName` is a
// per-fork branding string ("chrome", "chromium", …) that we cannot derive
// from a display name: lowercasing "Microsoft Edge" gives "microsoft edge",
// which matches nothing, and the search would silently return zero items.
// The label is the one identifier that is the same across every fork and is
// exactly the string we already build for the macOS Keychain.
let label = format!("{brand} Safe Storage");
// The schema attribute narrows the scan to os_crypt secrets; it is shared by
// every Chromium fork, so it costs nothing in portability.
let mut attributes = HashMap::new();
attributes.insert("xdg:schema", "chrome_libsecret_os_crypt_password_v2");
let mut items = collection
.search_items(attributes)
.map_err(|e| format!("search failed: {e}"))?;
if items.is_empty() {
// Older Chromium releases used a v1 schema, and some forks omit it.
items = collection
.get_all_items()
.map_err(|e| format!("could not list items: {e}"))?;
}
for item in &items {
if item.get_label().is_ok_and(|found| found == label) {
return item
.get_secret()
.map(Some)
.map_err(|e| format!("could not read secret: {e}"));
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn chromium_family_disambiguates_chrome_from_chromium_by_path() {
let chrome = PathBuf::from("/Users/x/Library/Application Support/Google/Chrome/Default");
assert_eq!(brand_candidates("chromium", &chrome)[0], "Chrome");
let chromium = PathBuf::from("/Users/x/Library/Application Support/Chromium/Default");
assert_eq!(brand_candidates("chromium", &chromium)[0], "Chromium");
}
#[test]
fn every_brand_falls_back_to_a_second_candidate() {
// A single candidate means one wrong guess loses the secrets entirely, so
// each family must offer a fallback identity.
for family in [
"chrome-beta",
"chrome-dev",
"chrome-canary",
"brave",
"edge",
"vivaldi",
"opera",
"opera-gx",
"arc",
"yandex",
"chromium",
] {
let candidates = brand_candidates(family, Path::new("/tmp/profile"));
assert!(
candidates.len() >= 2,
"{family} needs a fallback brand candidate"
);
}
}
#[test]
fn unknown_family_still_yields_candidates() {
let candidates = brand_candidates("something-new", Path::new("/tmp/profile"));
assert!(!candidates.is_empty());
}
}
+380
View File
@@ -0,0 +1,380 @@
//! Working out what the user pointed at, and where its files have to land.
//!
//! Two layout facts drive everything here:
//!
//! 1. Donut launches with `--user-data-dir` and no `--profile-directory`, so
//! Chromium reads `<user-data-dir>/Default/` (`chrome_constants.cc`
//! `kInitialProfile`). A source *profile* directory therefore has to be
//! copied one level down, not onto the root.
//! 2. Network state (`Cookies`, `TransportSecurity`, …) lives in
//! `Default/Network/` on Windows and in `Default/` everywhere else. That
//! split is not cosmetic: `kTriggerNetworkDataMigration` is enabled by
//! default only on Windows, and on the other platforms Chromium actively
//! redirects reads back to `Default/`. A profile exported from Windows is
//! invisible on macOS until its files are moved up, and vice versa.
use std::path::{Path, PathBuf};
/// Files Chromium keeps under `Default/Network/` on Windows and directly under
/// `Default/` on macOS and Linux.
pub const NETWORK_DATA_FILES: &[&str] = &[
"Cookies",
"Cookies-journal",
"Network Persistent State",
"Reporting and NEL",
"SCT Auditing Pending Reports",
"Trust Tokens",
"Trust Tokens-journal",
"TransportSecurity",
"Device Bound Sessions",
"Device Bound Sessions-journal",
];
/// What the user handed us.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SourceKind {
/// A profile directory (holds `Preferences`): `.../Chrome/Default`.
ProfileDir,
/// A user-data directory whose profile lives at its root — Opera's layout.
RootProfileUserDataDir,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceShape {
pub kind: SourceKind,
/// The directory holding `Preferences` — the content that becomes `Default/`.
pub profile_dir: PathBuf,
/// The directory holding `Local State`, when there is one. Windows keeps the
/// DPAPI-wrapped os_crypt key there, so losing it loses every secret.
pub user_data_dir: Option<PathBuf>,
}
/// Why a directory cannot be imported.
#[derive(Debug, PartialEq, Eq)]
pub enum RejectReason {
/// Recognisably a Gecko profile. Worth naming explicitly: silently returning
/// "nothing found" for a Firefox folder is what made import feel broken.
Firefox,
/// Not a browser profile we recognise at all.
NotChromium,
}
/// Markers that identify a real Chromium profile directory. `Preferences` is
/// the usual one, but a profile whose prefs were wiped still has data worth
/// carrying, so any of these counts.
const CHROMIUM_PROFILE_MARKERS: &[&str] = &[
"Preferences",
"Secure Preferences",
"History",
"Cookies",
"Bookmarks",
"Web Data",
"Login Data",
];
fn looks_like_chromium_profile(dir: &Path) -> bool {
CHROMIUM_PROFILE_MARKERS
.iter()
.any(|marker| dir.join(marker).exists())
// Windows-layout profiles keep Cookies one level down.
|| dir.join("Network").join("Cookies").exists()
}
fn looks_like_firefox_profile(dir: &Path) -> bool {
// Any one of these alone can appear elsewhere; together they are conclusive.
let markers = ["prefs.js", "places.sqlite", "cookies.sqlite", "key4.db"];
markers.iter().filter(|m| dir.join(m).exists()).count() >= 2
}
/// Classify an import source, or explain why it cannot be one.
pub fn classify(source: &Path) -> Result<SourceShape, RejectReason> {
if looks_like_firefox_profile(source) {
return Err(RejectReason::Firefox);
}
if !looks_like_chromium_profile(source) {
return Err(RejectReason::NotChromium);
}
// A directory that holds both profile markers and `Local State` is Opera's
// root-profile layout: the user-data dir and the profile are the same place.
let kind = if source.join("Local State").exists() {
SourceKind::RootProfileUserDataDir
} else {
SourceKind::ProfileDir
};
let user_data_dir = match kind {
SourceKind::RootProfileUserDataDir => Some(source.to_path_buf()),
// For `.../Chrome/Default`, `Local State` is in `.../Chrome`. Only accept
// the parent if it really holds one, so a profile copied to a random
// folder does not make us read a stranger's `Local State`.
SourceKind::ProfileDir => source.parent().and_then(|parent| {
if parent.join("Local State").exists() {
return Some(parent.to_path_buf());
}
// Opera keeps its extra profiles at `<user-data-dir>/_side_profiles/<id>`
// but still launches them against the same user-data dir, so the
// DPAPI-wrapped os_crypt key sits one further level up. Without this,
// every Opera side profile imports on Windows with no secrets at all.
if parent.file_name() == Some(std::ffi::OsStr::new("_side_profiles")) {
return parent
.parent()
.filter(|root| root.join("Local State").exists())
.map(Path::to_path_buf);
}
None
}),
};
Ok(SourceShape {
kind,
profile_dir: source.to_path_buf(),
user_data_dir,
})
}
/// Move network data into the position the *host* Chromium build reads from.
///
/// Host, not source: the files were written by whatever browser produced them,
/// but they will be read by Wayfern running here. Getting this backwards is a
/// silent, total cookie loss on any cross-platform import.
pub fn normalize_network_dir(default_dir: &Path) -> std::io::Result<()> {
let network_dir = default_dir.join("Network");
let (from, to) = if cfg!(target_os = "windows") {
(default_dir.to_path_buf(), network_dir.clone())
} else {
(network_dir.clone(), default_dir.to_path_buf())
};
if !from.exists() {
return Ok(());
}
for name in NETWORK_DATA_FILES {
let src = from.join(name);
if !src.is_file() {
continue;
}
std::fs::create_dir_all(&to)?;
let dest = to.join(name);
if dest.exists() {
// Both positions hold the file. The one in the source position is the
// stale duplicate: on Windows, Chromium's migration would copy it over
// the newer file ("overwrite the new file with the old file even if it
// exists already", network_sandbox.cc), so it has to go.
std::fs::remove_file(&src)?;
continue;
}
std::fs::rename(&src, &dest).or_else(|_| {
// Rename across devices can fail even within one tree on some setups.
std::fs::copy(&src, &dest).and_then(|_| std::fs::remove_file(&src))?;
Ok::<(), std::io::Error>(())
})?;
}
if !cfg!(target_os = "windows") {
// Chromium's migration checkpoint, and the reason an otherwise-correct
// move is not enough. `network_sandbox.cc:478` treats the presence of
// `NetworkDataMigrated` as proof the migration already ran, keeps the (now
// empty) `Network/` as the data directory, and then `CleanUpOldData` at
// `:536-540` DELETES the files we just moved up into `Default/`. A profile
// exported from Windows would lose every cookie on first launch.
let _ = std::fs::remove_file(network_dir.join("NetworkDataMigrated"));
// Leave no empty `Network/` behind: harmless, but it makes a profile look
// like it still holds network state.
if network_dir.is_dir() && std::fs::read_dir(&network_dir)?.next().is_none() {
let _ = std::fs::remove_dir(&network_dir);
}
}
Ok(())
}
/// Where the cookie store ends up for the host platform.
pub fn host_cookie_path(default_dir: &Path) -> PathBuf {
if cfg!(target_os = "windows") {
default_dir.join("Network").join("Cookies")
} else {
default_dir.join("Cookies")
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn touch(path: &Path) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, b"x").unwrap();
}
#[test]
fn plain_profile_dir_is_classified_without_a_user_data_dir() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("Default");
touch(&profile.join("Preferences"));
let shape = classify(&profile).expect("should classify");
assert_eq!(shape.kind, SourceKind::ProfileDir);
assert_eq!(shape.user_data_dir, None);
}
#[test]
fn profile_dir_finds_local_state_in_its_parent() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("Default");
touch(&profile.join("Preferences"));
touch(&dir.path().join("Local State"));
let shape = classify(&profile).expect("should classify");
// Windows keeps the wrapped os_crypt key here; missing it means no secrets.
assert_eq!(shape.user_data_dir.as_deref(), Some(dir.path()));
}
#[test]
fn opera_root_layout_is_its_own_user_data_dir() {
let dir = TempDir::new().unwrap();
touch(&dir.path().join("Preferences"));
touch(&dir.path().join("Local State"));
let shape = classify(dir.path()).expect("should classify");
assert_eq!(shape.kind, SourceKind::RootProfileUserDataDir);
assert_eq!(shape.user_data_dir.as_deref(), Some(dir.path()));
}
#[test]
fn firefox_profile_is_rejected_by_name() {
let dir = TempDir::new().unwrap();
touch(&dir.path().join("prefs.js"));
touch(&dir.path().join("places.sqlite"));
assert_eq!(classify(dir.path()), Err(RejectReason::Firefox));
}
#[test]
fn empty_directory_is_rejected() {
let dir = TempDir::new().unwrap();
assert_eq!(classify(dir.path()), Err(RejectReason::NotChromium));
}
#[test]
fn windows_layout_profile_is_recognised_without_root_markers() {
// A profile whose only surviving data is Windows-layout cookies.
let dir = TempDir::new().unwrap();
touch(&dir.path().join("Network").join("Cookies"));
assert!(classify(dir.path()).is_ok());
}
#[test]
fn opera_side_profile_finds_local_state_two_levels_up() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("_side_profiles").join("gaming");
touch(&profile.join("Preferences"));
touch(&dir.path().join("Local State"));
let shape = classify(&profile).expect("should classify");
assert_eq!(
shape.user_data_dir.as_deref(),
Some(dir.path()),
"Windows keeps the os_crypt key in the root Local State, not beside the profile"
);
}
#[test]
fn a_profile_in_an_unrelated_folder_does_not_adopt_a_strangers_local_state() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("_side_profiles").join("gaming");
touch(&profile.join("Preferences"));
// No Local State anywhere above it.
let shape = classify(&profile).expect("should classify");
assert_eq!(shape.user_data_dir, None);
}
#[test]
fn migration_checkpoint_is_removed_so_chromium_does_not_delete_the_moved_files() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
touch(&default_dir.join("Network").join("Cookies"));
touch(&default_dir.join("Network").join("NetworkDataMigrated"));
normalize_network_dir(&default_dir).unwrap();
assert!(host_cookie_path(&default_dir).is_file());
if !cfg!(target_os = "windows") {
assert!(
!default_dir
.join("Network")
.join("NetworkDataMigrated")
.exists(),
"the checkpoint makes Chromium delete the files we just moved up"
);
assert!(!default_dir.join("Network").exists());
}
}
#[test]
fn network_files_are_moved_into_the_host_position() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
// Seed the file in the position the host does NOT read from.
if cfg!(target_os = "windows") {
touch(&default_dir.join("Cookies"));
} else {
touch(&default_dir.join("Network").join("Cookies"));
}
normalize_network_dir(&default_dir).unwrap();
assert!(
host_cookie_path(&default_dir).is_file(),
"cookies must end up where this platform's Chromium reads them"
);
}
#[test]
fn stale_duplicate_in_the_source_position_is_removed() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
touch(&default_dir.join("Cookies"));
touch(&default_dir.join("Network").join("Cookies"));
normalize_network_dir(&default_dir).unwrap();
assert!(host_cookie_path(&default_dir).is_file());
let stale = if cfg!(target_os = "windows") {
default_dir.join("Cookies")
} else {
default_dir.join("Network").join("Cookies")
};
assert!(
!stale.exists(),
"the duplicate would be copied over the live file by Chromium's migration"
);
}
#[test]
fn normalize_is_idempotent() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
touch(&default_dir.join("Network").join("Cookies"));
normalize_network_dir(&default_dir).unwrap();
normalize_network_dir(&default_dir).unwrap();
assert!(host_cookie_path(&default_dir).is_file());
}
#[test]
fn normalize_on_a_profile_with_no_network_data_is_a_no_op() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
std::fs::create_dir_all(&default_dir).unwrap();
normalize_network_dir(&default_dir).unwrap();
assert!(!host_cookie_path(&default_dir).exists());
}
}
+372
View File
@@ -0,0 +1,372 @@
//! Turning someone else's browser profile into one Wayfern will actually load.
//!
//! The old importer copied a source profile directory verbatim onto the new
//! profile's `--user-data-dir`. Chromium reads `<user-data-dir>/Default/`, so
//! every imported file sat one level above where the browser looked and the
//! profile came up empty — and even in the right place the secrets would not
//! have opened, because they are sealed with a key held in the source
//! machine's Keychain / DPAPI / secret service that Wayfern never consults.
//!
//! This module does the whole job: classify the source, recover its key, copy
//! with consistent database snapshots, put the files where Chromium reads them,
//! re-seal every secret with Wayfern's portable key, and report exactly what
//! came across.
pub mod copy;
pub mod keyring;
pub mod layout;
pub mod os_crypt;
pub mod report;
pub mod rewrite;
use layout::RejectReason;
use report::{warning, ProfileImportReport};
use std::path::Path;
/// The profile subdirectory Chromium reads when no `--profile-directory` is
/// passed (`chrome_constants.cc` `kInitialProfile`). Donut never passes one.
pub const INITIAL_PROFILE_DIR: &str = "Default";
/// Import `source` into `dest_user_data_dir`, which becomes the new profile's
/// `--user-data-dir`.
///
/// Never fails because part of the data could not be carried: partial results
/// plus an honest report beat an all-or-nothing import that leaves the user
/// with nothing and no explanation. It fails only when the source is not
/// importable at all, or when the target key cannot be established — without
/// that key, anything written would be unreadable forever.
pub fn import_into(
source: &Path,
dest_user_data_dir: &Path,
source_family: &str,
allow_running: bool,
) -> Result<ProfileImportReport, String> {
let shape = layout::classify(source).map_err(|reason| match reason {
RejectReason::Firefox => serde_json::json!({
"code": "IMPORT_SOURCE_NOT_CHROMIUM",
"params": { "family": "Firefox" }
})
.to_string(),
RejectReason::NotChromium => serde_json::json!({
"code": "IMPORT_SOURCE_NOT_CHROMIUM",
"params": { "family": "" }
})
.to_string(),
})?;
let mut report = ProfileImportReport::default();
if let Some(running) = running_source_browser(&shape) {
if !allow_running {
return Err(
serde_json::json!({
"code": "IMPORT_SOURCE_BROWSER_RUNNING",
"params": { "browser": running }
})
.to_string(),
);
}
// Databases are snapshotted transactionally, but LevelDB site data is
// copied as files and can be mid-write.
report.warn(warning::SOURCE_BROWSER_RUNNING);
}
// Mint the target key first. Everything after this point is written to be
// readable with it, and a profile whose key could not be persisted would
// lose every secret the first time the browser exits.
let target = os_crypt::TargetKey::ensure(dest_user_data_dir)?;
// Recover the source key before the copy: on macOS this may prompt, and
// asking before a multi-GB copy respects the user's time.
let source_keys = keyring::recover_source_keys(
source_family,
&shape.profile_dir,
shape.user_data_dir.as_deref(),
&mut report,
);
let default_dir = dest_user_data_dir.join(INITIAL_PROFILE_DIR);
let outcome = copy::copy_profile_tree(&shape.profile_dir, &default_dir)?;
report.bytes_copied = outcome.bytes_copied;
if !outcome.unreadable_stores.is_empty() {
report.warn(warning::STORE_UNREADABLE);
}
layout::normalize_network_dir(&default_dir)
.map_err(|e| format!("Failed to place network data: {e}"))?;
rewrite::finalize_profile(&default_dir, &source_keys, &target, &mut report);
Ok(report)
}
/// Is the browser that owns this profile currently running?
///
/// Matched on the profile path in the process command line rather than on the
/// executable name: the user may well have Chrome open on a *different*
/// profile, which is no reason to block the import.
fn running_source_browser(shape: &layout::SourceShape) -> Option<String> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let needle = shape
.user_data_dir
.as_deref()
.unwrap_or(&shape.profile_dir)
.to_string_lossy()
.to_string();
if needle.is_empty() {
return None;
}
for process in system.processes().values() {
let name = process.name().to_string_lossy().to_lowercase();
let looks_like_a_browser = name.contains("chrome")
|| name.contains("chromium")
|| name.contains("brave")
|| name.contains("edge")
|| name.contains("vivaldi")
|| name.contains("opera")
|| name.contains("arc")
|| name.contains("yandex");
if !looks_like_a_browser {
continue;
}
// Donut's own browser is Wayfern; never report it as the source.
if name.contains("wayfern") {
continue;
}
if process
.cmd()
.iter()
.any(|arg| arg.to_string_lossy().contains(&needle))
{
return Some(process.name().to_string_lossy().to_string());
}
}
None
}
/// Move a profile that an earlier build imported into the broken root layout
/// down into `Default/`, where the browser reads it.
///
/// Without this, everything those users imported stays stranded: their real
/// data sits at `profile/Cookies` while Wayfern reads and writes
/// `profile/Default/Cookies`. Their secrets remain unreadable — the source key
/// was never captured and cannot be recovered after the fact — but history,
/// bookmarks, extensions and site data become visible again.
///
/// Returns `Ok(true)` when a repair was performed.
pub fn repair_legacy_layout(user_data_dir: &Path) -> Result<bool, String> {
let default_dir = user_data_dir.join(INITIAL_PROFILE_DIR);
// The broken shape is exactly: profile markers at the root, and no `Default/`
// for the browser to have used instead.
let has_root_profile = user_data_dir.join("Preferences").exists()
|| user_data_dir.join("History").exists()
|| user_data_dir.join("Cookies").exists();
if !has_root_profile || default_dir.exists() {
return Ok(false);
}
// Root-level files that belong to the user-data dir, not to the profile.
const ROOT_LEVEL: &[&str] = &[
"Local State",
"os_crypt_key",
"First Run",
"Last Version",
"Variations",
"ChromeFeatureState",
"RunningChromeVersion",
"SingletonLock",
"SingletonCookie",
"SingletonSocket",
"user.js",
"metadata.json",
".donut-sync",
];
let staging = user_data_dir.join(".donut-import-repair");
if staging.exists() {
std::fs::remove_dir_all(&staging).map_err(|e| format!("Failed to clear staging: {e}"))?;
}
std::fs::create_dir_all(&staging).map_err(|e| format!("Failed to create staging: {e}"))?;
let entries =
std::fs::read_dir(user_data_dir).map_err(|e| format!("Failed to read profile: {e}"))?;
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
if ROOT_LEVEL.contains(&name_str) || name_str == ".donut-import-repair" {
continue;
}
std::fs::rename(entry.path(), staging.join(name_str))
.map_err(|e| format!("Failed to relocate {name_str}: {e}"))?;
}
std::fs::rename(&staging, &default_dir)
.map_err(|e| format!("Failed to install {INITIAL_PROFILE_DIR}: {e}"))?;
// Now that the files are in the right place, put the network data where this
// platform reads it too.
let _ = layout::normalize_network_dir(&default_dir);
// And make sure the profile has a key, so the browser does not mint one
// mid-session and lose whatever it writes.
let _ = os_crypt::TargetKey::ensure(user_data_dir);
log::info!(
"Repaired legacy import layout at {} (moved profile content into {INITIAL_PROFILE_DIR}/)",
user_data_dir.display()
);
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn touch(path: &Path, contents: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, contents).unwrap();
}
#[test]
fn import_places_everything_under_default() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("Chrome").join("Default");
let dest = dir.path().join("profile");
touch(&source.join("Preferences"), b"{}");
touch(&source.join("Bookmarks"), b"{\"roots\":{}}");
let report = import_into(&source, &dest, "chromium", true).expect("import");
assert!(
dest.join("Default").join("Preferences").exists(),
"Chromium reads Default/, not the user-data-dir root"
);
assert!(
!dest.join("Preferences").exists(),
"nothing profile-scoped belongs at the root"
);
assert!(dest.join(os_crypt::KEY_FILE_NAME).exists());
assert!(report.bytes_copied > 0);
}
#[test]
fn import_rejects_a_firefox_profile_by_name() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("xyz.default-release");
let dest = dir.path().join("profile");
touch(&source.join("prefs.js"), b"");
touch(&source.join("places.sqlite"), b"");
let err = import_into(&source, &dest, "firefox", true).expect_err("must reject");
assert!(err.contains("IMPORT_SOURCE_NOT_CHROMIUM"));
assert!(
err.contains("Firefox"),
"the user needs to be told why, not just that it failed"
);
}
#[test]
fn import_rejects_an_arbitrary_folder() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("holiday-photos");
let dest = dir.path().join("profile");
touch(&source.join("IMG_0001.jpg"), b"not a profile");
let err = import_into(&source, &dest, "chromium", true).expect_err("must reject");
assert!(err.contains("IMPORT_SOURCE_NOT_CHROMIUM"));
}
#[test]
fn import_is_rerunnable_over_the_same_destination() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("Default");
let dest = dir.path().join("profile");
touch(&source.join("Preferences"), b"{}");
import_into(&source, &dest, "chromium", true).expect("first");
let key = std::fs::read(dest.join(os_crypt::KEY_FILE_NAME)).unwrap();
import_into(&source, &dest, "chromium", true).expect("second");
assert_eq!(
std::fs::read(dest.join(os_crypt::KEY_FILE_NAME)).unwrap(),
key,
"re-running must not orphan what the first run encrypted"
);
}
#[test]
fn legacy_layout_is_repaired_into_default() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("profile");
// Exactly what the old importer produced.
touch(&profile.join("Preferences"), b"{}");
touch(&profile.join("History"), b"");
touch(
&profile
.join("Local Storage")
.join("leveldb")
.join("CURRENT"),
b"",
);
touch(&profile.join("Local State"), b"{}");
assert!(repair_legacy_layout(&profile).unwrap());
assert!(profile.join("Default").join("Preferences").exists());
assert!(profile.join("Default").join("History").exists());
assert!(profile
.join("Default")
.join("Local Storage")
.join("leveldb")
.join("CURRENT")
.exists());
assert!(
profile.join("Local State").exists(),
"Local State belongs to the user-data dir, not the profile"
);
assert!(profile.join(os_crypt::KEY_FILE_NAME).exists());
assert!(!profile.join(".donut-import-repair").exists());
}
#[test]
fn repair_leaves_a_healthy_profile_alone() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("profile");
touch(&profile.join("Default").join("Preferences"), b"{}");
touch(&profile.join("Local State"), b"{}");
assert!(!repair_legacy_layout(&profile).unwrap());
assert!(profile.join("Default").join("Preferences").exists());
assert!(!profile.join("Default").join("Default").exists());
}
#[test]
fn repair_is_a_no_op_on_an_empty_profile() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("profile");
std::fs::create_dir_all(&profile).unwrap();
assert!(!repair_legacy_layout(&profile).unwrap());
}
#[test]
fn repair_is_idempotent() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("profile");
touch(&profile.join("Preferences"), b"{}");
assert!(repair_legacy_layout(&profile).unwrap());
assert!(!repair_legacy_layout(&profile).unwrap());
assert!(profile.join("Default").join("Preferences").exists());
}
}
+560
View File
@@ -0,0 +1,560 @@
//! Key material for profile import.
//!
//! Wayfern deliberately does not use the OS keyring. Every `os_crypt_async`
//! key provider is patched to read (or mint) `<user-data-dir>/os_crypt_key`
//! instead, so a profile directory is self-contained and portable. See
//! `wayfern/patches/extra/fingerprint/components-os_crypt-async-browser-*`.
//!
//! That portability is exactly why an imported Chrome profile carries nothing:
//! its secrets are sealed with a key held in the macOS Keychain / Windows DPAPI
//! / the Freedesktop secret service, and Wayfern never looks there. Import has
//! to open the source's lock and re-seal everything with Wayfern's.
//!
//! The on-disk format is per-platform and NOT interchangeable, matching the
//! provider that owns each tag in the patched Chromium 151 tree:
//!
//! | Host | `os_crypt_key` | Derivation | Cipher | Tag |
//! |---------|---------------------|-------------------------------------|--------------|-------|
//! | macOS | `base64(16 bytes)` | PBKDF2-HMAC-SHA1(saltysalt, 1003) | AES-128-CBC | `v10` |
//! | Linux | `base64(16 bytes)` | PBKDF2-HMAC-SHA1(saltysalt, 1) | AES-128-CBC | `v11` |
//! | Windows | 32 raw bytes | none, the bytes are the key | AES-256-GCM | `v10` |
//!
//! Linux must write `v11`, not `v10`: `PosixKeyProvider` owns `v10` with the
//! hardcoded "peanuts" password and `Encryptor::DecryptData` dispatches on the
//! tag prefix, so a `v10` record on Linux would be decrypted with the wrong key
//! forever.
use aes::cipher::{block_padding::Pkcs7, BlockModeDecrypt, BlockModeEncrypt, KeyIvInit};
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use base64::Engine;
use rand::RngExt;
use ring::pbkdf2;
use std::num::NonZeroU32;
use std::path::Path;
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;
/// Chromium's fixed PBKDF2 salt for every CBC-based os_crypt provider.
pub const SALT: &[u8] = b"saltysalt";
/// Chromium's fixed CBC IV: sixteen spaces.
pub const CBC_IV: [u8; 16] = [b' '; 16];
/// AES-256-GCM nonce length, prepended to the ciphertext by `Encryptor::Key::Encrypt`.
const GCM_NONCE_LEN: usize = 12;
/// The `os_crypt_key` name, at the root of the user-data dir.
pub const KEY_FILE_NAME: &str = "os_crypt_key";
/// `PBKDF2-HMAC-SHA1(password = "", salt = "saltysalt", iterations = 1)`.
///
/// Chromium retries every failed AES-128-CBC decrypt with this key
/// (`encryptor.cc`, crbug.com/40055416) because profiles created while the
/// keyring was unavailable were sealed with an empty password. Import has to do
/// the same or those records look corrupt.
pub const EMPTY_PASSWORD_KEY: [u8; 16] = [
0xd0, 0xd0, 0xec, 0x9c, 0x7d, 0x77, 0xd4, 0x3a, 0xc5, 0x41, 0x87, 0xfa, 0x48, 0x18, 0xd1, 0x7f,
];
/// The password Chromium's `PosixKeyProvider` uses when no secret service is
/// available (`--password-store=basic`). Records sealed with it carry `v10`.
// Read on Linux and by the known-answer tests; unreferenced on other hosts.
#[allow(dead_code)]
pub const POSIX_FALLBACK_PASSWORD: &[u8] = b"peanuts";
/// PBKDF2 iteration counts, per the provider that owns each platform.
// Each host only ever derives with its own count, but both are needed to read
// a profile produced on the other one.
#[allow(dead_code)]
pub const MAC_ITERATIONS: u32 = 1003;
#[allow(dead_code)]
pub const POSIX_ITERATIONS: u32 = 1;
/// Derive a 16-byte AES-128 key the way every CBC os_crypt provider does.
///
/// `password` is the raw bytes, never trimmed: Chromium passes the exact
/// `ReadFileToString` result to the KDF, so normalising here would silently
/// produce a different key and every decrypt would fail.
pub fn derive_key(password: &[u8], iterations: u32) -> [u8; 16] {
let mut key = [0u8; 16];
// ring rather than the `pbkdf2` crate: sha1 0.11 (digest 0.11) and
// pbkdf2 0.12 (digest 0.10) cannot coexist. ring is self-contained.
pbkdf2::derive(
pbkdf2::PBKDF2_HMAC_SHA1,
NonZeroU32::new(iterations).expect("iterations must be non-zero"),
SALT,
password,
&mut key,
);
key
}
/// One os_crypt cipher, keyed. Which variant applies is decided by the tag the
/// record carries, never by the host platform.
#[derive(Clone)]
pub enum CryptoKey {
Aes128Cbc([u8; 16]),
// Only Windows keys with GCM, but the variant has to exist everywhere so the
// tag dispatch in `SourceKeyring` stays platform-independent.
#[allow(dead_code)]
Aes256Gcm([u8; 32]),
}
impl CryptoKey {
/// Decrypt a *tagless* ciphertext (the caller has already stripped the
/// 3-byte version prefix).
pub fn decrypt(&self, ciphertext: &[u8]) -> Option<Vec<u8>> {
match self {
Self::Aes128Cbc(key) => {
if ciphertext.is_empty() {
return Some(Vec::new());
}
let mut buf = ciphertext.to_vec();
Aes128CbcDec::new(key.into(), &CBC_IV.into())
.decrypt_padded::<Pkcs7>(&mut buf)
.ok()
.map(<[u8]>::to_vec)
}
Self::Aes256Gcm(key) => {
if ciphertext.len() < GCM_NONCE_LEN {
return None;
}
let (nonce, body) = ciphertext.split_at(GCM_NONCE_LEN);
let nonce: [u8; GCM_NONCE_LEN] = nonce.try_into().ok()?;
Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key))
.decrypt(
&Nonce::from(nonce),
Payload {
msg: body,
aad: &[],
},
)
.ok()
}
}
}
/// Encrypt to a *tagless* ciphertext. The caller prepends the tag.
pub fn encrypt(&self, plaintext: &[u8]) -> Option<Vec<u8>> {
match self {
Self::Aes128Cbc(key) => {
let mut buf = vec![0u8; plaintext.len() + 16];
buf[..plaintext.len()].copy_from_slice(plaintext);
Aes128CbcEnc::new(key.into(), &CBC_IV.into())
.encrypt_padded::<Pkcs7>(&mut buf, plaintext.len())
.ok()
.map(<[u8]>::to_vec)
}
Self::Aes256Gcm(key) => {
let nonce: [u8; GCM_NONCE_LEN] = rand::rng().random();
let sealed = Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key))
.encrypt(
&Nonce::from(nonce),
Payload {
msg: plaintext,
aad: &[],
},
)
.ok()?;
// The nonce goes at the front, matching `Encryptor::Key::Encrypt`.
let mut out = Vec::with_capacity(GCM_NONCE_LEN + sealed.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&sealed);
Some(out)
}
}
}
}
/// Wayfern's key for the profile being created.
pub struct TargetKey {
key: CryptoKey,
tag: &'static [u8; 3],
}
impl TargetKey {
/// The tag the host platform's key provider claims.
pub const fn host_tag() -> &'static [u8; 3] {
#[cfg(target_os = "linux")]
{
b"v11"
}
#[cfg(not(target_os = "linux"))]
{
b"v10"
}
}
/// Build the key from the raw `os_crypt_key` file contents.
///
/// Returns `None` when the contents cannot key the host cipher — on Windows
/// that means anything other than exactly 32 bytes, which is what
/// `DPAPIKeyProvider` requires before it will adopt a portable key.
fn from_file_contents(contents: &[u8]) -> Option<Self> {
if contents.is_empty() {
return None;
}
#[cfg(target_os = "windows")]
{
let bytes: [u8; 32] = contents.try_into().ok()?;
Some(Self {
key: CryptoKey::Aes256Gcm(bytes),
tag: Self::host_tag(),
})
}
#[cfg(target_os = "macos")]
{
Some(Self {
key: CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS)),
tag: Self::host_tag(),
})
}
#[cfg(target_os = "linux")]
{
Some(Self {
key: CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS)),
tag: Self::host_tag(),
})
}
}
/// Fresh key material in the host platform's `os_crypt_key` format.
fn generate_file_contents() -> Vec<u8> {
#[cfg(target_os = "windows")]
{
// Windows stores the AES-256 key itself, so it must be 32 bytes.
let key: [u8; 32] = rand::rng().random();
key.to_vec()
}
#[cfg(not(target_os = "windows"))]
{
// mac/Linux store a *password* that is fed to PBKDF2. Wayfern mints
// `base64(16 random bytes)`; match it so the file is indistinguishable
// from one the browser wrote itself.
let raw: [u8; 16] = rand::rng().random();
base64::engine::general_purpose::STANDARD
.encode(raw)
.into_bytes()
}
}
/// Read the existing `os_crypt_key`, or mint and persist one.
///
/// Writing eagerly at import time — rather than letting the first launch do
/// it — is deliberate. The mac and Linux patches have no `else` branch when
/// the write fails, so the browser would run on an in-memory key that dies
/// with the process and orphans everything it wrote. Failing here instead
/// turns that silent data loss into a visible import error.
pub fn ensure(user_data_dir: &Path) -> Result<Self, String> {
let key_file = user_data_dir.join(KEY_FILE_NAME);
if let Ok(existing) = std::fs::read(&key_file) {
if let Some(key) = Self::from_file_contents(&existing) {
return Ok(key);
}
// Present but unusable (a Windows-format key on macOS, say, or a
// truncated write). Replacing it is safe only because import always
// re-encrypts into whatever key we end up with.
log::warn!(
"Replacing unusable {KEY_FILE_NAME} ({} bytes) at {}",
existing.len(),
key_file.display()
);
}
std::fs::create_dir_all(user_data_dir)
.map_err(|e| format!("Failed to create profile directory: {e}"))?;
let contents = Self::generate_file_contents();
std::fs::write(&key_file, &contents)
.map_err(|e| format!("Failed to write os_crypt_key: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&key_file, std::fs::Permissions::from_mode(0o600));
}
// Read back rather than trust the write: a key that did not land is the
// one failure mode that silently destroys every secret we are about to
// write with it.
let written =
std::fs::read(&key_file).map_err(|e| format!("Failed to verify os_crypt_key: {e}"))?;
if written != contents {
return Err("os_crypt_key verification failed after write".to_string());
}
Self::from_file_contents(&contents).ok_or_else(|| "Failed to derive os_crypt_key".to_string())
}
/// Seal a value the way Wayfern will expect to find it: `tag || ciphertext`.
pub fn encrypt(&self, plaintext: &[u8]) -> Option<Vec<u8>> {
let body = self.key.encrypt(plaintext)?;
let mut out = Vec::with_capacity(3 + body.len());
out.extend_from_slice(self.tag);
out.extend_from_slice(&body);
Some(out)
}
}
/// What a decrypt attempt produced.
pub enum Decrypted {
/// Recovered plaintext.
Value(Vec<u8>),
/// Already plaintext — no recognised version tag.
NotEncrypted,
/// Correctly identified but not openable: no key for the tag (Windows
/// App-Bound `v20`), or every candidate key failed.
Unrecoverable,
}
/// The source browser's keys, indexed by the tag the records carry.
///
/// Indexing by tag rather than by platform is not pedantry: a single Linux
/// profile can legitimately hold both `v10` (peanuts) and `v11` (keyring)
/// records, because the available secret service changes between sessions.
#[derive(Default)]
pub struct SourceKeyring {
pub v10: Option<CryptoKey>,
pub v11: Option<CryptoKey>,
/// Seen at least one `v20` (Windows App-Bound) record, which no third party
/// can open. Tracked so the import report can say so explicitly.
pub saw_app_bound: std::cell::Cell<bool>,
}
impl SourceKeyring {
pub fn is_empty(&self) -> bool {
self.v10.is_none() && self.v11.is_none()
}
/// Open one stored value, dispatching on its version tag exactly as
/// `Encryptor::DecryptData` does.
pub fn decrypt(&self, stored: &[u8]) -> Decrypted {
if stored.len() < 3 {
return if stored.is_empty() {
Decrypted::Value(Vec::new())
} else {
Decrypted::NotEncrypted
};
}
let (tag, body) = stored.split_at(3);
let key = match tag {
b"v10" => self.v10.as_ref(),
b"v11" => self.v11.as_ref(),
b"v20" => {
// App-Bound Encryption. The key is wrapped by the SYSTEM-level Chrome
// Elevation Service, which validates the calling binary. There is no
// legitimate way for us to unwrap it.
self.saw_app_bound.set(true);
return Decrypted::Unrecoverable;
}
_ => return Decrypted::NotEncrypted,
};
let Some(key) = key else {
return Decrypted::Unrecoverable;
};
if let Some(plaintext) = key.decrypt(body) {
return Decrypted::Value(plaintext);
}
// Chromium's own fallback for CBC records sealed with an empty password.
if matches!(key, CryptoKey::Aes128Cbc(_)) {
if let Some(plaintext) = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY).decrypt(body) {
return Decrypted::Value(plaintext);
}
}
Decrypted::Unrecoverable
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn empty_password_key_matches_chromium_constant() {
// Locks the constant against the value Chromium hardcodes in encryptor.cc.
assert_eq!(derive_key(b"", POSIX_ITERATIONS), EMPTY_PASSWORD_KEY);
}
#[test]
fn peanuts_key_matches_known_vector() {
// PBKDF2-HMAC-SHA1("peanuts", "saltysalt", 1, 16). Any drift here silently
// breaks every Linux `--password-store=basic` import.
assert_eq!(
derive_key(POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS),
[
0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27,
0x78
]
);
}
#[test]
fn cbc_round_trip() {
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
let sealed = key.encrypt(b"session-token").expect("encrypt");
assert_eq!(key.decrypt(&sealed).expect("decrypt"), b"session-token");
}
#[test]
fn cbc_round_trip_empty_plaintext() {
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
let sealed = key.encrypt(b"").expect("encrypt");
// PKCS7 always emits a full padding block, so this must not be empty.
assert_eq!(sealed.len(), 16);
assert!(key.decrypt(&sealed).expect("decrypt").is_empty());
}
#[test]
fn gcm_round_trip_with_fresh_nonce_each_time() {
let key = CryptoKey::Aes256Gcm([7u8; 32]);
let a = key.encrypt(b"session-token").expect("encrypt");
let b = key.encrypt(b"session-token").expect("encrypt");
assert_ne!(a, b, "nonce must be random per call");
assert_eq!(key.decrypt(&a).expect("decrypt"), b"session-token");
assert_eq!(key.decrypt(&b).expect("decrypt"), b"session-token");
}
#[test]
fn gcm_rejects_tampered_ciphertext() {
let key = CryptoKey::Aes256Gcm([7u8; 32]);
let mut sealed = key.encrypt(b"session-token").expect("encrypt");
let last = sealed.len() - 1;
sealed[last] ^= 0xff;
assert!(key.decrypt(&sealed).is_none());
}
#[test]
fn target_key_is_stable_across_calls() {
let dir = TempDir::new().unwrap();
let first = TargetKey::ensure(dir.path()).expect("mint");
let sealed = first.encrypt(b"value").expect("encrypt");
let second = TargetKey::ensure(dir.path()).expect("reuse");
// Re-running import over the same directory must not orphan what the
// previous run wrote.
let key_file = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
let reloaded = TargetKey::from_file_contents(&key_file).expect("reload");
assert_eq!(
reloaded.encrypt(b"probe").map(|v| v[..3].to_vec()),
second.encrypt(b"probe").map(|v| v[..3].to_vec())
);
let mut keyring = SourceKeyring::default();
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
install_host_key(&mut keyring, &contents);
match keyring.decrypt(&sealed) {
Decrypted::Value(v) => assert_eq!(v, b"value"),
_ => panic!("target key must round-trip through the source keyring"),
}
}
#[test]
fn minted_key_matches_wayfern_file_format() {
let dir = TempDir::new().unwrap();
TargetKey::ensure(dir.path()).expect("mint");
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
#[cfg(target_os = "windows")]
assert_eq!(
contents.len(),
32,
"DPAPIKeyProvider only adopts a 32-byte portable key"
);
#[cfg(not(target_os = "windows"))]
{
// Wayfern writes base64(16 random bytes) = 24 ASCII chars.
assert_eq!(contents.len(), 24);
let text = String::from_utf8(contents).expect("ascii");
assert!(
base64::engine::general_purpose::STANDARD
.decode(&text)
.map(|b| b.len())
== Ok(16),
"expected base64 of 16 bytes, got {text}"
);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.path().join(KEY_FILE_NAME))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
}
#[test]
fn unknown_tag_is_treated_as_plaintext_not_as_loss() {
let keyring = SourceKeyring::default();
assert!(matches!(
keyring.decrypt(b"plain cookie value"),
Decrypted::NotEncrypted
));
}
#[test]
fn app_bound_records_are_flagged_unrecoverable() {
let keyring = SourceKeyring::default();
let mut sealed = b"v20".to_vec();
sealed.extend_from_slice(&[0u8; 40]);
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
assert!(
keyring.saw_app_bound.get(),
"v20 must be reported to the user, not silently dropped"
);
}
#[test]
fn missing_key_for_known_tag_is_unrecoverable() {
let keyring = SourceKeyring::default();
let mut sealed = b"v10".to_vec();
sealed.extend_from_slice(&[0u8; 32]);
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
}
#[test]
fn empty_password_fallback_recovers_the_record() {
// A record sealed with the empty-password key must still open when the
// keyring holds a different primary key, mirroring Chromium.
let sealed_body = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY)
.encrypt(b"legacy")
.unwrap();
let mut stored = b"v10".to_vec();
stored.extend_from_slice(&sealed_body);
let keyring = SourceKeyring {
v10: Some(CryptoKey::Aes128Cbc(derive_key(b"a different key", 1003))),
..Default::default()
};
match keyring.decrypt(&stored) {
Decrypted::Value(v) => assert_eq!(v, b"legacy"),
_ => panic!("empty-password fallback must be attempted"),
}
}
/// Load the host-format key into a keyring under the host tag, for tests
/// that need to verify what we wrote is what Wayfern will read.
fn install_host_key(keyring: &mut SourceKeyring, contents: &[u8]) {
#[cfg(target_os = "windows")]
{
let bytes: [u8; 32] = contents.try_into().unwrap();
keyring.v10 = Some(CryptoKey::Aes256Gcm(bytes));
}
#[cfg(target_os = "macos")]
{
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS)));
}
#[cfg(target_os = "linux")]
{
keyring.v11 = Some(CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS)));
}
}
}
+109
View File
@@ -0,0 +1,109 @@
//! What an import actually carried across.
//!
//! Import is best-effort by nature: a locked keychain, a Windows App-Bound
//! cookie store or a schema too old for Chromium to migrate all mean some
//! subset does not survive, and none of them should abort the whole operation.
//! The report is how that stays honest — every skipped store is a counted
//! warning rather than a silent zero.
use serde::{Deserialize, Serialize};
/// Stable warning codes. The frontend maps these to
/// `importProfile.warnings.*`, so they are part of the API contract: rename one
/// and the user sees a missing translation.
pub mod warning {
/// The source browser's key could not be read, so cookies/passwords were
/// left encrypted and are unreadable in the new profile.
pub const SECRETS_NOT_MIGRATED: &str = "secretsNotMigrated";
/// Windows App-Bound Encryption (Chrome 127+). Unrecoverable by design.
pub const APP_BOUND_ENCRYPTED: &str = "appBoundEncrypted";
/// A store's schema predates what Chromium will migrate; it would have been
/// deleted on first launch, so it was skipped instead.
pub const STORE_TOO_OLD: &str = "storeTooOld";
/// A store's schema is newer than this Chromium can read.
pub const STORE_TOO_NEW: &str = "storeTooNew";
/// The source browser was running; databases were snapshotted but LevelDB
/// site data may be incomplete.
pub const SOURCE_BROWSER_RUNNING: &str = "sourceBrowserRunning";
/// Tracked preferences lost their MACs and will reset to defaults.
pub const SECURE_PREFERENCES_RESET: &str = "securePreferencesReset";
/// At least one extension could not be carried.
pub const EXTENSIONS_PARTIAL: &str = "extensionsPartial";
/// A database was unreadable and was skipped rather than copied corrupt.
pub const STORE_UNREADABLE: &str = "storeUnreadable";
}
/// Per-profile outcome, returned alongside each item in a batch import.
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProfileImportReport {
/// Cookies whose value is readable in the new profile.
pub cookies_migrated: usize,
/// Cookies carried over as rows but whose value could not be recovered.
pub cookies_unrecoverable: usize,
pub passwords_migrated: usize,
pub passwords_unrecoverable: usize,
/// Saved cards / IBANs / autofill secrets re-encrypted.
pub payment_methods_migrated: usize,
pub payment_methods_unrecoverable: usize,
pub extensions_migrated: usize,
pub history_entries: usize,
pub bookmarks: usize,
/// Origins with Local Storage data.
pub local_storage_origins: usize,
pub bytes_copied: u64,
/// Stable codes from [`warning`], deduplicated, in insertion order.
pub warnings: Vec<String>,
}
impl ProfileImportReport {
pub fn warn(&mut self, code: &str) {
if !self.warnings.iter().any(|w| w == code) {
self.warnings.push(code.to_string());
}
}
/// True when nothing readable came across. Used to decide whether the UI
/// should present the import as a success or as a warning.
pub fn is_empty_import(&self) -> bool {
self.cookies_migrated == 0
&& self.passwords_migrated == 0
&& self.history_entries == 0
&& self.bookmarks == 0
&& self.local_storage_origins == 0
&& self.extensions_migrated == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn warnings_are_deduplicated_in_order() {
let mut report = ProfileImportReport::default();
report.warn(warning::STORE_TOO_OLD);
report.warn(warning::SECRETS_NOT_MIGRATED);
report.warn(warning::STORE_TOO_OLD);
assert_eq!(
report.warnings,
vec![
warning::STORE_TOO_OLD.to_string(),
warning::SECRETS_NOT_MIGRATED.to_string()
]
);
}
#[test]
fn empty_import_detection_ignores_unrecoverable_counts() {
let mut report = ProfileImportReport {
cookies_unrecoverable: 500,
..Default::default()
};
assert!(
report.is_empty_import(),
"500 unreadable cookies is still nothing carried"
);
report.history_entries = 1;
assert!(!report.is_empty_import());
}
}
File diff suppressed because it is too large Load Diff
+74 -26
View File
@@ -9,6 +9,7 @@ use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
use crate::events;
use crate::profile::types::{get_host_os, BrowserProfile, SyncMode};
use crate::profile::ProfileManager;
use crate::profile_import::report::ProfileImportReport;
use crate::proxy_manager::PROXY_MANAGER;
use crate::wayfern_manager::WayfernConfig;
@@ -28,6 +29,9 @@ pub struct DetectedProfile {
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
pub struct ImportProfileItem {
pub source_path: String,
/// The source browser family (`chromium`, `brave`, `edge`, …). Load-bearing:
/// it selects which Keychain / secret-service item holds the key that
/// unlocks the source's cookies and passwords.
#[serde(default = "default_import_browser_type")]
pub browser_type: String,
pub new_profile_name: String,
@@ -35,6 +39,10 @@ pub struct ImportProfileItem {
pub proxy_id: Option<String>,
#[serde(default)]
pub vpn_id: Option<String>,
/// Import even though the source browser is running. Databases are still
/// snapshotted consistently, but LevelDB site data may be mid-write.
#[serde(default)]
pub allow_running: Option<bool>,
}
fn default_import_browser_type() -> String {
@@ -61,6 +69,8 @@ pub struct ProfileImportItemResult {
pub profile_id: Option<String>,
/// Structured `{"code": …}` error string when status is "failed".
pub error: Option<String>,
/// What actually came across. Present when status is "imported".
pub report: Option<ProfileImportReport>,
}
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
@@ -719,6 +729,7 @@ impl ProfileImporter {
status: "failed".to_string(),
profile_id: None,
error: Some(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string()),
report: None,
});
continue;
}
@@ -735,6 +746,7 @@ impl ProfileImporter {
status: "skipped".to_string(),
profile_id: None,
error: None,
report: None,
});
continue;
}
@@ -757,10 +769,11 @@ impl ProfileImporter {
item.vpn_id.clone(),
group_id.clone(),
wayfern_config.clone(),
item.allow_running.unwrap_or(false),
)
.await
{
Ok(profile) => {
Ok((profile, report)) => {
imported_count += 1;
completed += 1;
emit_import_progress(total, completed, index, &final_name, "imported");
@@ -771,6 +784,7 @@ impl ProfileImporter {
status: "imported".to_string(),
profile_id: Some(profile.id.to_string()),
error: None,
report: Some(report),
});
}
Err(e) => {
@@ -785,6 +799,7 @@ impl ProfileImporter {
status: "failed".to_string(),
profile_id: None,
error: Some(error_to_code_string(e)),
report: None,
});
}
}
@@ -809,7 +824,8 @@ impl ProfileImporter {
vpn_id: Option<String>,
group_id: Option<String>,
wayfern_config: Option<WayfernConfig>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
allow_running: bool,
) -> Result<(BrowserProfile, ProfileImportReport), Box<dyn std::error::Error>> {
let source_path = Path::new(source_path);
if !source_path.exists() {
return Err(
@@ -847,39 +863,54 @@ impl ProfileImporter {
create_dir_all(&new_profile_uuid_dir)?;
create_dir_all(&new_profile_data_dir)?;
// Profile dirs can be multiple GB — keep the copy off the async runtime.
let copy_source = source_path.to_path_buf();
let copy_dest = new_profile_data_dir.clone();
let copy_result = match tokio::task::spawn_blocking(move || {
Self::copy_directory_recursive(&copy_source, &copy_dest).map_err(|e| e.to_string())
// Profile dirs can be multiple GB and the migration hits SQLite and the
// OS keyring — keep all of it off the async runtime.
let migrate_source = source_path.to_path_buf();
let migrate_dest = new_profile_data_dir.clone();
let source_family = browser_type.to_string();
let migrate_result = match tokio::task::spawn_blocking(move || {
crate::profile_import::import_into(
&migrate_source,
&migrate_dest,
&source_family,
allow_running,
)
})
.await
{
Ok(r) => r,
Err(e) => {
// The copy task died (panic, or runtime shutdown mid-import). Clean up
// like every other error path here, or the half-copied — possibly
// multi-GB — directory is orphaned with no metadata pointing at it, so
// nothing ever reclaims it.
// The task died (panic, or runtime shutdown mid-import). Clean up like
// every other error path here, or the half-copied — possibly multi-GB
// — directory is orphaned with no metadata pointing at it, so nothing
// ever reclaims it.
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
return Err(
serde_json::json!({
"code": "INTERNAL_ERROR",
"params": { "detail": format!("Profile copy task failed: {e}") },
"params": { "detail": format!("Profile import task failed: {e}") },
})
.to_string()
.into(),
);
}
};
if let Err(e) = copy_result {
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
return Err(
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e } })
.to_string()
.into(),
);
}
let report = match migrate_result {
Ok(report) => report,
Err(e) => {
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
// Structured codes (an unimportable source, a running browser) pass
// through so the frontend can translate them; anything else is
// internal.
return Err(if e.starts_with('{') {
e.into()
} else {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e } })
.to_string()
.into()
});
}
};
let version = match self.get_default_version_for_browser(mapped) {
Ok(version) => version,
@@ -1017,13 +1048,30 @@ impl ProfileImporter {
self.profile_manager.save_profile(&profile)?;
log::info!(
"Successfully imported profile '{}' from '{}'",
new_profile_name,
source_path.display()
);
if report.is_empty_import() {
// Not an error — an empty source profile imports legitimately — but it is
// the exact symptom the old layout bug produced, so it is worth a loud
// line in the log rather than a silent success.
log::warn!(
"Imported profile '{}' from '{}' carried no readable data (warnings: {:?})",
new_profile_name,
source_path.display(),
report.warnings
);
} else {
log::info!(
"Imported profile '{}' from '{}': {} cookies, {} passwords, {} history entries ({} unrecoverable secrets, warnings: {:?})",
new_profile_name,
source_path.display(),
report.cookies_migrated,
report.passwords_migrated,
report.history_entries,
report.cookies_unrecoverable + report.passwords_unrecoverable,
report.warnings
);
}
Ok(profile)
Ok((profile, report))
}
fn get_default_version_for_browser(
+4 -6
View File
@@ -721,12 +721,10 @@ impl WayfernManager {
};
if key_path.exists() {
let key_text = std::fs::read_to_string(&key_path).unwrap_or_default();
log::info!(
"Pre-launch: os_crypt_key present ({} bytes, content: '{}')",
key_text.len(),
key_text.trim()
);
// Length only. The contents are the profile's encryption key, and this
// log is the first thing a user attaches to a bug report.
let key_len = std::fs::metadata(&key_path).map(|m| m.len()).unwrap_or(0);
log::info!("Pre-launch: os_crypt_key present ({key_len} bytes)");
} else {
log::warn!("Pre-launch: os_crypt_key NOT FOUND");
}
+233 -89
View File
@@ -53,10 +53,88 @@ import type {
ImportProfileItem,
ProfileImportBatchResult,
ProfileImportProgress,
ProfileImportReport,
WayfernConfig,
} from "@/types";
import { RippleButton } from "./ui/ripple";
/**
* What an import actually carried, and what it could not.
*
* The counts matter more than they look: an import that reports zero of
* everything is the exact symptom of the bug where copied data landed where
* the browser never reads it, and it used to be indistinguishable from success.
*/
function ImportReportSummary({ report }: { report: ProfileImportReport }) {
const { t } = useTranslation();
// Label-then-value rather than "{{count}} cookies": it keeps the row scannable
// and sidesteps needing correct plural forms in ten languages.
const carried = (
[
["importProfile.reportCookies", report.cookies_migrated],
["importProfile.reportPasswords", report.passwords_migrated],
["importProfile.reportAutofill", report.payment_methods_migrated],
["importProfile.reportExtensions", report.extensions_migrated],
["importProfile.reportHistory", report.history_entries],
["importProfile.reportBookmarks", report.bookmarks],
["importProfile.reportLocalStorage", report.local_storage_origins],
] as const
)
.filter(([, count]) => count > 0)
.map(([key, count]) => `${t(key)} ${count.toLocaleString()}`);
const unrecoverable =
report.cookies_unrecoverable +
report.passwords_unrecoverable +
report.payment_methods_unrecoverable;
return (
<div className="mt-0.5 space-y-0.5 pl-1 text-xs text-muted-foreground">
<p>
{carried.length > 0
? carried.join(" · ")
: t("importProfile.reportNothingCarried")}
</p>
{unrecoverable > 0 && (
<p>
{t("importProfile.reportUnrecoverable", { count: unrecoverable })}
</p>
)}
{report.warnings.map((code) => (
<p key={code} className="text-warning-text">
{t(`importProfile.warnings.${code}`)}
</p>
))}
</div>
);
}
/**
* Fold a retry's results back into the batch it came from.
*
* A retry only resubmits the items that failed, so the previous batch is still
* authoritative for every other row. Replacing it wholesale would make the
* successful imports disappear from the summary.
*/
function mergeImportResults(
previous: ProfileImportBatchResult,
retry: ProfileImportBatchResult,
): ProfileImportBatchResult {
const byPath = new Map(retry.results.map((item) => [item.source_path, item]));
const results = previous.results.map(
(item) => byPath.get(item.source_path) ?? item,
);
const count = (status: string) =>
results.filter((item) => item.status === status).length;
return {
imported_count: count("imported"),
skipped_count: count("skipped"),
failed_count: count("failed"),
results,
};
}
interface ImportProfileDialogProps {
isOpen: boolean;
onClose: () => void;
@@ -283,69 +361,99 @@ export function ImportProfileDialog({
}
};
const handleImport = useCallback(async () => {
if (selectedProfiles.length === 0) {
toast.error(t("importProfile.selectAtLeastOne"));
return;
}
if (
selectedProfiles.some((p) => !(profileNames[p.path] ?? p.name).trim())
) {
toast.error(t("importProfile.emptyNames"));
return;
}
const items: ImportProfileItem[] = selectedProfiles.map((p, index) => ({
source_path: p.path,
browser_type: p.browser,
new_profile_name: (profileNames[p.path] ?? p.name).trim(),
proxy_id: proxyIdForIndex(index),
vpn_id: vpnAssignment === "none" ? null : vpnAssignment,
}));
setCurrentStep("importing");
setIsImporting(true);
setProgress(null);
setResult(null);
try {
const batchResult = await invoke<ProfileImportBatchResult>(
"import_browser_profiles",
{
items,
groupId: selectedGroupId === "none" ? null : selectedGroupId,
duplicateStrategy: duplicateStrategy,
wayfernConfig,
},
);
setResult(batchResult);
toast.success(
t("importProfile.resultsSummary", {
imported: batchResult.imported_count,
skipped: batchResult.skipped_count,
failed: batchResult.failed_count,
}),
);
if (batchResult.imported_count > 0 && !reducedMotion) {
fireSprinkleConfetti();
const handleImport = useCallback(
async (allowRunning = false, retryPaths?: ReadonlySet<string>) => {
if (selectedProfiles.length === 0) {
toast.error(t("importProfile.selectAtLeastOne"));
return;
}
} catch (error) {
console.error("Failed to import profiles:", error);
toast.error(translateBackendError(t, error));
setCurrentStep("configure");
} finally {
setIsImporting(false);
}
}, [
selectedProfiles,
profileNames,
proxyIdForIndex,
vpnAssignment,
selectedGroupId,
duplicateStrategy,
wayfernConfig,
reducedMotion,
t,
]);
if (
selectedProfiles.some((p) => !(profileNames[p.path] ?? p.name).trim())
) {
toast.error(t("importProfile.emptyNames"));
return;
}
// Filter AFTER the map, so a retry keeps the proxy each profile was
// originally assigned by the index-based round-robin.
const items: ImportProfileItem[] = selectedProfiles
.map((p, index) => ({
source_path: p.path,
browser_type: p.browser,
new_profile_name: (profileNames[p.path] ?? p.name).trim(),
proxy_id: proxyIdForIndex(index),
vpn_id: vpnAssignment === "none" ? null : vpnAssignment,
allow_running: allowRunning,
}))
.filter((item) => !retryPaths || retryPaths.has(item.source_path));
if (items.length === 0) {
return;
}
setCurrentStep("importing");
setIsImporting(true);
setProgress(null);
// A retry covers only the failed subset, so the earlier results are still
// the truth for everything else and must not be thrown away.
const previous = retryPaths ? result : null;
setResult(null);
try {
const batchResult = await invoke<ProfileImportBatchResult>(
"import_browser_profiles",
{
items,
groupId: selectedGroupId === "none" ? null : selectedGroupId,
duplicateStrategy: duplicateStrategy,
wayfernConfig,
},
);
setResult(
previous ? mergeImportResults(previous, batchResult) : batchResult,
);
toast.success(
t("importProfile.resultsSummary", {
imported: batchResult.imported_count,
skipped: batchResult.skipped_count,
failed: batchResult.failed_count,
}),
);
if (batchResult.imported_count > 0 && !reducedMotion) {
fireSprinkleConfetti();
}
} catch (error) {
console.error("Failed to import profiles:", error);
toast.error(translateBackendError(t, error));
setCurrentStep("configure");
} finally {
setIsImporting(false);
}
},
[
selectedProfiles,
profileNames,
proxyIdForIndex,
vpnAssignment,
selectedGroupId,
duplicateStrategy,
wayfernConfig,
reducedMotion,
result,
t,
],
);
// A source browser that is still running is the one failure the user can fix
// without starting over, so offer the override right where it happened.
const hasRunningBrowserFailure = useMemo(
() =>
(result?.results ?? []).some(
(item) =>
item.status === "failed" &&
item.error?.includes("IMPORT_SOURCE_BROWSER_RUNNING"),
),
[result],
);
const handleClose = () => {
void cleanupExtractedDir(extractedDir);
@@ -840,38 +948,74 @@ export function ImportProfileDialog({
</h3>
<div className="max-h-64 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
{result.results.map((item) => (
<div
key={item.source_path}
className="flex items-center gap-2 p-1 text-sm"
>
<span
className={cn(
"shrink-0 text-xs font-medium",
item.status === "imported" && "text-success-text",
item.status === "skipped" &&
"text-muted-foreground",
item.status === "failed" &&
"text-destructive-text",
)}
>
{item.status === "imported" &&
t("importProfile.statusImported")}
{item.status === "skipped" &&
t("importProfile.statusSkipped")}
{item.status === "failed" &&
t("importProfile.statusFailed")}
</span>
<span className="min-w-0 flex-1 truncate">
{item.name || item.source_path}
</span>
{item.error && (
<span className="min-w-0 flex-1 truncate text-xs text-destructive-text">
{translateBackendError(t, new Error(item.error))}
<div key={item.source_path} className="p-1 text-sm">
<div className="flex items-center gap-2">
<span
className={cn(
"shrink-0 text-xs font-medium",
item.status === "imported" &&
"text-success-text",
item.status === "skipped" &&
"text-muted-foreground",
item.status === "failed" &&
"text-destructive-text",
)}
>
{item.status === "imported" &&
t("importProfile.statusImported")}
{item.status === "skipped" &&
t("importProfile.statusSkipped")}
{item.status === "failed" &&
t("importProfile.statusFailed")}
</span>
<span className="min-w-0 flex-1 truncate">
{item.name || item.source_path}
</span>
{item.error && (
<span className="min-w-0 flex-1 truncate text-xs text-destructive-text">
{translateBackendError(
t,
new Error(item.error),
)}
</span>
)}
</div>
{item.report && (
<ImportReportSummary report={item.report} />
)}
</div>
))}
</div>
{hasRunningBrowserFailure && (
<Alert>
<AlertDescription className="space-y-2">
<p>{t("importProfile.closeSourceBrowserHint")}</p>
<Button
variant="outline"
size="sm"
onClick={() => {
void handleImport(
true,
new Set(
result.results
.filter(
(item) =>
item.status === "failed" &&
item.error?.includes(
"IMPORT_SOURCE_BROWSER_RUNNING",
),
)
.map((item) => item.source_path),
),
);
}}
>
{t("importProfile.importAnyway")}
</Button>
</AlertDescription>
</Alert>
)}
</div>
)}
</div>
+16
View File
@@ -1855,10 +1855,18 @@
"name": "ntapi",
"license": "Apache-2.0 OR MIT"
},
{
"name": "num",
"license": "Apache-2.0 OR MIT"
},
{
"name": "num-bigint",
"license": "Apache-2.0 OR MIT"
},
{
"name": "num-complex",
"license": "Apache-2.0 OR MIT"
},
{
"name": "num-conv",
"license": "Apache-2.0 OR MIT"
@@ -1871,6 +1879,10 @@
"name": "num-integer",
"license": "Apache-2.0 OR MIT"
},
{
"name": "num-iter",
"license": "Apache-2.0 OR MIT"
},
{
"name": "num-rational",
"license": "Apache-2.0 OR MIT"
@@ -2407,6 +2419,10 @@
"name": "sealed",
"license": "Apache-2.0 OR MIT"
},
{
"name": "secret-service",
"license": "Apache-2.0 OR MIT"
},
{
"name": "security-framework",
"license": "Apache-2.0 OR MIT"
+26 -2
View File
@@ -1467,7 +1467,28 @@
"vpnOptional": "VPN (Optional)",
"noVpn": "No VPN",
"advancedOptions": "Advanced options",
"configureFingerprint": "Configure fingerprint (optional)"
"configureFingerprint": "Configure fingerprint (optional)",
"reportCookies": "Cookies",
"reportPasswords": "Passwords",
"reportAutofill": "Payment methods",
"reportExtensions": "Extensions",
"reportHistory": "History",
"reportBookmarks": "Bookmarks",
"reportLocalStorage": "Site data",
"reportNothingCarried": "No readable data was carried over",
"reportUnrecoverable": "Could not be decrypted: {{count}}",
"closeSourceBrowserHint": "Close the source browser and try again for a complete copy, or import now and accept that site data may be incomplete.",
"importAnyway": "Import anyway",
"warnings": {
"secretsNotMigrated": "Cookies and passwords could not be unlocked, so you will need to sign in again.",
"appBoundEncrypted": "Chrome 127+ on Windows locks cookies to the browser itself; those cookies cannot be migrated by any other app.",
"storeTooOld": "A database was too old for this browser to open and was skipped.",
"storeTooNew": "A database came from a newer browser than this one and was skipped.",
"sourceBrowserRunning": "The source browser was running, so site data may be incomplete.",
"securePreferencesReset": "Protected settings such as the homepage and search engine reset to defaults.",
"extensionsPartial": "Some extensions belonged to the source browser and were not carried over.",
"storeUnreadable": "A database could not be read and was skipped rather than copied damaged."
}
},
"syncTooltips": {
"syncing": "Syncing...",
@@ -1914,7 +1935,10 @@
"malformed": "The VLESS URI is invalid."
},
"camoufoxRemoved": "Camoufox is no longer supported. Recreate this profile with Wayfern.",
"noE2ePasswordSet": "No end-to-end encryption password is set. Set one before syncing encrypted data."
"noE2ePasswordSet": "No end-to-end encryption password is set. Set one before syncing encrypted data.",
"importSourceNotChromium": "This folder is not a Chromium browser profile",
"importSourceNotChromiumNamed": "{{family}} profiles cannot be imported; only Chromium-based browsers are supported",
"importSourceBrowserRunning": "Close {{browser}} first, or choose to import anyway"
},
"rail": {
"profiles": "Profiles",
+26 -2
View File
@@ -1471,7 +1471,28 @@
"vpnOptional": "VPN (opcional)",
"noVpn": "Sin VPN",
"advancedOptions": "Opciones avanzadas",
"configureFingerprint": "Configurar huella digital (opcional)"
"configureFingerprint": "Configurar huella digital (opcional)",
"reportCookies": "Cookies",
"reportPasswords": "Contraseñas",
"reportAutofill": "Métodos de pago",
"reportExtensions": "Extensiones",
"reportHistory": "Historial",
"reportBookmarks": "Marcadores",
"reportLocalStorage": "Datos de sitios",
"reportNothingCarried": "No se transfirió ningún dato legible",
"reportUnrecoverable": "No se pudo descifrar: {{count}}",
"closeSourceBrowserHint": "Cierra el navegador de origen y vuelve a intentarlo para obtener una copia completa, o importa ahora aceptando que los datos de sitios pueden quedar incompletos.",
"importAnyway": "Importar de todos modos",
"warnings": {
"secretsNotMigrated": "No se pudieron desbloquear las cookies ni las contraseñas, así que tendrás que iniciar sesión de nuevo.",
"appBoundEncrypted": "Chrome 127+ en Windows vincula las cookies al propio navegador; ninguna otra aplicación puede migrarlas.",
"storeTooOld": "Una base de datos era demasiado antigua para este navegador y se omitió.",
"storeTooNew": "Una base de datos procede de un navegador más reciente que este y se omitió.",
"sourceBrowserRunning": "El navegador de origen estaba en ejecución, por lo que los datos de sitios pueden estar incompletos.",
"securePreferencesReset": "Los ajustes protegidos, como la página de inicio y el buscador, volvieron a sus valores predeterminados.",
"extensionsPartial": "Algunas extensiones pertenecían al navegador de origen y no se transfirieron.",
"storeUnreadable": "No se pudo leer una base de datos y se omitió en lugar de copiarla dañada."
}
},
"syncTooltips": {
"syncing": "Sincronizando...",
@@ -1921,7 +1942,10 @@
"malformed": "La URI VLESS no es válida."
},
"camoufoxRemoved": "Camoufox ya no es compatible. Vuelve a crear este perfil con Wayfern.",
"noE2ePasswordSet": "No hay contraseña de cifrado de extremo a extremo. Establece una antes de sincronizar datos cifrados."
"noE2ePasswordSet": "No hay contraseña de cifrado de extremo a extremo. Establece una antes de sincronizar datos cifrados.",
"importSourceNotChromium": "Esta carpeta no es un perfil de navegador Chromium",
"importSourceNotChromiumNamed": "Los perfiles de {{family}} no se pueden importar; solo se admiten navegadores basados en Chromium",
"importSourceBrowserRunning": "Cierra {{browser}} primero o elige importar de todos modos"
},
"rail": {
"profiles": "Perfiles",
+26 -2
View File
@@ -1471,7 +1471,28 @@
"vpnOptional": "VPN (facultatif)",
"noVpn": "Sans VPN",
"advancedOptions": "Options avancées",
"configureFingerprint": "Configurer l'empreinte (facultatif)"
"configureFingerprint": "Configurer l'empreinte (facultatif)",
"reportCookies": "Cookies",
"reportPasswords": "Mots de passe",
"reportAutofill": "Moyens de paiement",
"reportExtensions": "Extensions",
"reportHistory": "Historique",
"reportBookmarks": "Favoris",
"reportLocalStorage": "Données de sites",
"reportNothingCarried": "Aucune donnée lisible n'a été transférée",
"reportUnrecoverable": "Déchiffrement impossible : {{count}}",
"closeSourceBrowserHint": "Fermez le navigateur source et réessayez pour obtenir une copie complète, ou importez maintenant en acceptant que les données de sites soient incomplètes.",
"importAnyway": "Importer quand même",
"warnings": {
"secretsNotMigrated": "Les cookies et les mots de passe n'ont pas pu être déverrouillés : vous devrez vous reconnecter.",
"appBoundEncrypted": "Chrome 127+ sous Windows lie les cookies au navigateur lui-même ; aucune autre application ne peut les migrer.",
"storeTooOld": "Une base de données était trop ancienne pour ce navigateur et a été ignorée.",
"storeTooNew": "Une base de données provient d'un navigateur plus récent que celui-ci et a été ignorée.",
"sourceBrowserRunning": "Le navigateur source était en cours d'exécution, les données de sites peuvent donc être incomplètes.",
"securePreferencesReset": "Les réglages protégés, comme la page d'accueil et le moteur de recherche, sont revenus aux valeurs par défaut.",
"extensionsPartial": "Certaines extensions appartenaient au navigateur source et n'ont pas été transférées.",
"storeUnreadable": "Une base de données n'a pas pu être lue et a été ignorée plutôt que copiée endommagée."
}
},
"syncTooltips": {
"syncing": "Synchronisation...",
@@ -1921,7 +1942,10 @@
"malformed": "L'URI VLESS n'est pas valide."
},
"camoufoxRemoved": "Camoufox n'est plus pris en charge. Recréez ce profil avec Wayfern.",
"noE2ePasswordSet": "Aucun mot de passe de chiffrement de bout en bout n'est défini. Définissez-en un avant de synchroniser des données chiffrées."
"noE2ePasswordSet": "Aucun mot de passe de chiffrement de bout en bout n'est défini. Définissez-en un avant de synchroniser des données chiffrées.",
"importSourceNotChromium": "Ce dossier n'est pas un profil de navigateur Chromium",
"importSourceNotChromiumNamed": "Les profils {{family}} ne peuvent pas être importés ; seuls les navigateurs basés sur Chromium sont pris en charge",
"importSourceBrowserRunning": "Fermez d'abord {{browser}}, ou choisissez d'importer quand même"
},
"rail": {
"profiles": "Profils",
+26 -2
View File
@@ -1467,7 +1467,28 @@
"vpnOptional": "VPN(任意)",
"noVpn": "VPNなし",
"advancedOptions": "詳細オプション",
"configureFingerprint": "フィンガープリントを設定(任意)"
"configureFingerprint": "フィンガープリントを設定(任意)",
"reportCookies": "Cookie",
"reportPasswords": "パスワード",
"reportAutofill": "お支払い方法",
"reportExtensions": "拡張機能",
"reportHistory": "履歴",
"reportBookmarks": "ブックマーク",
"reportLocalStorage": "サイトデータ",
"reportNothingCarried": "読み取り可能なデータは引き継がれませんでした",
"reportUnrecoverable": "復号できませんでした: {{count}}",
"closeSourceBrowserHint": "完全にコピーするには、元のブラウザーを閉じてからもう一度お試しください。サイトデータが不完全になることを承知のうえで、このままインポートすることもできます。",
"importAnyway": "このままインポート",
"warnings": {
"secretsNotMigrated": "Cookie とパスワードのロックを解除できなかったため、再度サインインが必要です。",
"appBoundEncrypted": "Windows の Chrome 127 以降は Cookie をブラウザー自体に紐付けるため、他のアプリからは移行できません。",
"storeTooOld": "このブラウザーでは開けない古いデータベースがあったため、スキップしました。",
"storeTooNew": "このブラウザーより新しいブラウザーのデータベースだったため、スキップしました。",
"sourceBrowserRunning": "元のブラウザーが実行中だったため、サイトデータが不完全な可能性があります。",
"securePreferencesReset": "ホームページや検索エンジンなど、保護された設定は既定値に戻りました。",
"extensionsPartial": "一部の拡張機能は元のブラウザー付属のもので、引き継がれませんでした。",
"storeUnreadable": "読み取れないデータベースがあったため、破損したままコピーせずスキップしました。"
}
},
"syncTooltips": {
"syncing": "同期中...",
@@ -1914,7 +1935,10 @@
"malformed": "VLESS URIが無効です。"
},
"camoufoxRemoved": "Camoufoxはサポートされなくなりました。Wayfernでこのプロファイルを作り直してください。",
"noE2ePasswordSet": "エンドツーエンド暗号化のパスワードが設定されていません。暗号化データを同期する前に設定してください。"
"noE2ePasswordSet": "エンドツーエンド暗号化のパスワードが設定されていません。暗号化データを同期する前に設定してください。",
"importSourceNotChromium": "このフォルダーは Chromium ブラウザーのプロファイルではありません",
"importSourceNotChromiumNamed": "{{family}} のプロファイルはインポートできません。Chromium 系ブラウザーのみ対応しています",
"importSourceBrowserRunning": "先に {{browser}} を閉じるか、このままインポートを選択してください"
},
"rail": {
"profiles": "プロファイル",
+26 -2
View File
@@ -1467,7 +1467,28 @@
"vpnOptional": "VPN (선택 사항)",
"noVpn": "VPN 없음",
"advancedOptions": "고급 옵션",
"configureFingerprint": "핑거프린트 구성 (선택 사항)"
"configureFingerprint": "핑거프린트 구성 (선택 사항)",
"reportCookies": "쿠키",
"reportPasswords": "비밀번호",
"reportAutofill": "결제 수단",
"reportExtensions": "확장 프로그램",
"reportHistory": "방문 기록",
"reportBookmarks": "북마크",
"reportLocalStorage": "사이트 데이터",
"reportNothingCarried": "읽을 수 있는 데이터가 이전되지 않았습니다",
"reportUnrecoverable": "복호화할 수 없음: {{count}}",
"closeSourceBrowserHint": "완전하게 복사하려면 원본 브라우저를 닫고 다시 시도하세요. 사이트 데이터가 불완전할 수 있음을 감수하고 지금 가져올 수도 있습니다.",
"importAnyway": "그래도 가져오기",
"warnings": {
"secretsNotMigrated": "쿠키와 비밀번호를 잠금 해제하지 못해 다시 로그인해야 합니다.",
"appBoundEncrypted": "Windows의 Chrome 127 이상은 쿠키를 브라우저 자체에 묶어 두므로 다른 앱에서는 이전할 수 없습니다.",
"storeTooOld": "이 브라우저가 열 수 없을 만큼 오래된 데이터베이스가 있어 건너뛰었습니다.",
"storeTooNew": "이 브라우저보다 최신 브라우저의 데이터베이스여서 건너뛰었습니다.",
"sourceBrowserRunning": "원본 브라우저가 실행 중이어서 사이트 데이터가 불완전할 수 있습니다.",
"securePreferencesReset": "홈페이지와 검색 엔진 같은 보호된 설정이 기본값으로 초기화되었습니다.",
"extensionsPartial": "일부 확장 프로그램은 원본 브라우저의 것이어서 이전되지 않았습니다.",
"storeUnreadable": "읽을 수 없는 데이터베이스가 있어 손상된 채로 복사하지 않고 건너뛰었습니다."
}
},
"syncTooltips": {
"syncing": "동기화 중...",
@@ -1914,7 +1935,10 @@
"malformed": "VLESS URI가 올바르지 않습니다."
},
"camoufoxRemoved": "Camoufox는 더 이상 지원되지 않습니다. Wayfern으로 이 프로필을 다시 만드세요.",
"noE2ePasswordSet": "종단 간 암호화 비밀번호가 설정되지 않았습니다. 암호화된 데이터를 동기화하기 전에 설정하세요."
"noE2ePasswordSet": "종단 간 암호화 비밀번호가 설정되지 않았습니다. 암호화된 데이터를 동기화하기 전에 설정하세요.",
"importSourceNotChromium": "이 폴더는 Chromium 브라우저 프로필이 아닙니다",
"importSourceNotChromiumNamed": "{{family}} 프로필은 가져올 수 없습니다. Chromium 기반 브라우저만 지원합니다",
"importSourceBrowserRunning": "{{browser}}을(를) 먼저 닫거나 그래도 가져오기를 선택하세요"
},
"rail": {
"profiles": "프로필",
+26 -2
View File
@@ -1471,7 +1471,28 @@
"vpnOptional": "VPN (opcional)",
"noVpn": "Sem VPN",
"advancedOptions": "Opções avançadas",
"configureFingerprint": "Configurar impressão digital (opcional)"
"configureFingerprint": "Configurar impressão digital (opcional)",
"reportCookies": "Cookies",
"reportPasswords": "Senhas",
"reportAutofill": "Formas de pagamento",
"reportExtensions": "Extensões",
"reportHistory": "Histórico",
"reportBookmarks": "Favoritos",
"reportLocalStorage": "Dados de sites",
"reportNothingCarried": "Nenhum dado legível foi transferido",
"reportUnrecoverable": "Não foi possível descriptografar: {{count}}",
"closeSourceBrowserHint": "Feche o navegador de origem e tente de novo para obter uma cópia completa, ou importe agora aceitando que os dados de sites podem ficar incompletos.",
"importAnyway": "Importar mesmo assim",
"warnings": {
"secretsNotMigrated": "Não foi possível desbloquear cookies e senhas, então você precisará entrar novamente.",
"appBoundEncrypted": "O Chrome 127+ no Windows vincula os cookies ao próprio navegador; nenhum outro aplicativo consegue migrá-los.",
"storeTooOld": "Um banco de dados era antigo demais para este navegador e foi ignorado.",
"storeTooNew": "Um banco de dados veio de um navegador mais novo que este e foi ignorado.",
"sourceBrowserRunning": "O navegador de origem estava aberto, então os dados de sites podem estar incompletos.",
"securePreferencesReset": "Configurações protegidas, como página inicial e mecanismo de busca, voltaram ao padrão.",
"extensionsPartial": "Algumas extensões pertenciam ao navegador de origem e não foram transferidas.",
"storeUnreadable": "Não foi possível ler um banco de dados, que foi ignorado em vez de copiado danificado."
}
},
"syncTooltips": {
"syncing": "Sincronizando...",
@@ -1921,7 +1942,10 @@
"malformed": "A URI VLESS é inválida."
},
"camoufoxRemoved": "O Camoufox não é mais compatível. Recrie este perfil com o Wayfern.",
"noE2ePasswordSet": "Nenhuma senha de criptografia de ponta a ponta foi definida. Defina uma antes de sincronizar dados criptografados."
"noE2ePasswordSet": "Nenhuma senha de criptografia de ponta a ponta foi definida. Defina uma antes de sincronizar dados criptografados.",
"importSourceNotChromium": "Esta pasta não é um perfil de navegador Chromium",
"importSourceNotChromiumNamed": "Perfis do {{family}} não podem ser importados; apenas navegadores baseados em Chromium são compatíveis",
"importSourceBrowserRunning": "Feche o {{browser}} primeiro ou escolha importar mesmo assim"
},
"rail": {
"profiles": "Perfis",
+26 -2
View File
@@ -1475,7 +1475,28 @@
"vpnOptional": "VPN (необязательно)",
"noVpn": "Без VPN",
"advancedOptions": "Дополнительные параметры",
"configureFingerprint": "Настроить отпечаток (необязательно)"
"configureFingerprint": "Настроить отпечаток (необязательно)",
"reportCookies": "Файлы cookie",
"reportPasswords": "Пароли",
"reportAutofill": "Способы оплаты",
"reportExtensions": "Расширения",
"reportHistory": "История",
"reportBookmarks": "Закладки",
"reportLocalStorage": "Данные сайтов",
"reportNothingCarried": "Читаемые данные не перенесены",
"reportUnrecoverable": "Не удалось расшифровать: {{count}}",
"closeSourceBrowserHint": "Закройте исходный браузер и повторите попытку, чтобы получить полную копию, либо импортируйте сейчас, приняв, что данные сайтов могут оказаться неполными.",
"importAnyway": "Всё равно импортировать",
"warnings": {
"secretsNotMigrated": "Не удалось разблокировать файлы cookie и пароли, поэтому потребуется войти заново.",
"appBoundEncrypted": "Chrome 127+ в Windows привязывает файлы cookie к самому браузеру, и другое приложение не может их перенести.",
"storeTooOld": "База данных оказалась слишком старой для этого браузера и была пропущена.",
"storeTooNew": "База данных создана более новым браузером и была пропущена.",
"sourceBrowserRunning": "Исходный браузер был запущен, поэтому данные сайтов могут быть неполными.",
"securePreferencesReset": "Защищённые настройки, например домашняя страница и поисковая система, сброшены до значений по умолчанию.",
"extensionsPartial": "Некоторые расширения принадлежали исходному браузеру и не были перенесены.",
"storeUnreadable": "База данных не читалась и была пропущена, а не скопирована повреждённой."
}
},
"syncTooltips": {
"syncing": "Синхронизация...",
@@ -1928,7 +1949,10 @@
"malformed": "VLESS URI недействителен."
},
"camoufoxRemoved": "Camoufox больше не поддерживается. Создайте этот профиль заново с Wayfern.",
"noE2ePasswordSet": "Пароль сквозного шифрования не задан. Задайте его перед синхронизацией зашифрованных данных."
"noE2ePasswordSet": "Пароль сквозного шифрования не задан. Задайте его перед синхронизацией зашифрованных данных.",
"importSourceNotChromium": "Эта папка не является профилем браузера на Chromium",
"importSourceNotChromiumNamed": "Профили {{family}} импортировать нельзя: поддерживаются только браузеры на Chromium",
"importSourceBrowserRunning": "Сначала закройте {{browser}} или выберите импорт всё равно"
},
"rail": {
"profiles": "Профили",
+26 -2
View File
@@ -1467,7 +1467,28 @@
"vpnOptional": "VPN (isteğe bağlı)",
"noVpn": "VPN yok",
"advancedOptions": "Gelişmiş seçenekler",
"configureFingerprint": "Parmak izini yapılandır (isteğe bağlı)"
"configureFingerprint": "Parmak izini yapılandır (isteğe bağlı)",
"reportCookies": "Çerezler",
"reportPasswords": "Parolalar",
"reportAutofill": "Ödeme yöntemleri",
"reportExtensions": "Uzantılar",
"reportHistory": "Geçmiş",
"reportBookmarks": "Yer imleri",
"reportLocalStorage": "Site verileri",
"reportNothingCarried": "Okunabilir hiçbir veri aktarılmadı",
"reportUnrecoverable": "Şifresi çözülemedi: {{count}}",
"closeSourceBrowserHint": "Tam bir kopya için kaynak tarayıcıyı kapatıp yeniden deneyin ya da site verilerinin eksik olabileceğini kabul ederek şimdi içe aktarın.",
"importAnyway": "Yine de içe aktar",
"warnings": {
"secretsNotMigrated": "Çerezlerin ve parolaların kilidi açılamadı, bu yüzden yeniden oturum açmanız gerekecek.",
"appBoundEncrypted": "Windows'ta Chrome 127+ çerezleri tarayıcının kendisine bağlar; başka hiçbir uygulama bunları taşıyamaz.",
"storeTooOld": "Bir veritabanı bu tarayıcının açamayacağı kadar eskiydi ve atlandı.",
"storeTooNew": "Bir veritabanı bundan daha yeni bir tarayıcıdan geldi ve atlandı.",
"sourceBrowserRunning": "Kaynak tarayıcı çalışıyordu, bu yüzden site verileri eksik olabilir.",
"securePreferencesReset": "Ana sayfa ve arama motoru gibi korumalı ayarlar varsayılana döndü.",
"extensionsPartial": "Bazı uzantılar kaynak tarayıcıya aitti ve aktarılmadı.",
"storeUnreadable": "Bir veritabanı okunamadı ve bozuk şekilde kopyalanmak yerine atlandı."
}
},
"syncTooltips": {
"syncing": "Eşitleniyor...",
@@ -1914,7 +1935,10 @@
"malformed": "VLESS URI'si geçersiz."
},
"camoufoxRemoved": "Camoufox artık desteklenmiyor. Bu profili Wayfern ile yeniden oluşturun.",
"noE2ePasswordSet": "Uçtan uca şifreleme parolası ayarlanmamış. Şifreli veriyi eşitlemeden önce bir parola belirleyin."
"noE2ePasswordSet": "Uçtan uca şifreleme parolası ayarlanmamış. Şifreli veriyi eşitlemeden önce bir parola belirleyin.",
"importSourceNotChromium": "Bu klasör bir Chromium tarayıcı profili değil",
"importSourceNotChromiumNamed": "{{family}} profilleri içe aktarılamaz; yalnızca Chromium tabanlı tarayıcılar desteklenir",
"importSourceBrowserRunning": "Önce {{browser}} uygulamasını kapatın veya yine de içe aktarmayı seçin"
},
"rail": {
"profiles": "Profiller",
+26 -2
View File
@@ -1467,7 +1467,28 @@
"vpnOptional": "VPN (tùy chọn)",
"noVpn": "Không dùng VPN",
"advancedOptions": "Tùy chọn nâng cao",
"configureFingerprint": "Cấu hình vân tay (tùy chọn)"
"configureFingerprint": "Cấu hình vân tay (tùy chọn)",
"reportCookies": "Cookie",
"reportPasswords": "Mật khẩu",
"reportAutofill": "Phương thức thanh toán",
"reportExtensions": "Tiện ích mở rộng",
"reportHistory": "Lịch sử",
"reportBookmarks": "Dấu trang",
"reportLocalStorage": "Dữ liệu trang web",
"reportNothingCarried": "Không có dữ liệu đọc được nào được chuyển sang",
"reportUnrecoverable": "Không giải mã được: {{count}}",
"closeSourceBrowserHint": "Hãy đóng trình duyệt nguồn rồi thử lại để có bản sao đầy đủ, hoặc nhập ngay và chấp nhận rằng dữ liệu trang web có thể chưa đầy đủ.",
"importAnyway": "Vẫn nhập",
"warnings": {
"secretsNotMigrated": "Không mở khóa được cookie và mật khẩu, nên bạn sẽ phải đăng nhập lại.",
"appBoundEncrypted": "Chrome 127 trở lên trên Windows gắn cookie với chính trình duyệt; không ứng dụng nào khác có thể chuyển được.",
"storeTooOld": "Một cơ sở dữ liệu quá cũ để trình duyệt này mở nên đã bị bỏ qua.",
"storeTooNew": "Một cơ sở dữ liệu đến từ trình duyệt mới hơn nên đã bị bỏ qua.",
"sourceBrowserRunning": "Trình duyệt nguồn đang chạy nên dữ liệu trang web có thể chưa đầy đủ.",
"securePreferencesReset": "Các cài đặt được bảo vệ như trang chủ và công cụ tìm kiếm đã trở về mặc định.",
"extensionsPartial": "Một số tiện ích thuộc về trình duyệt nguồn nên không được chuyển sang.",
"storeUnreadable": "Một cơ sở dữ liệu không đọc được nên đã bị bỏ qua thay vì sao chép hỏng."
}
},
"syncTooltips": {
"syncing": "Đang đồng bộ...",
@@ -1914,7 +1935,10 @@
"malformed": "URI VLESS không hợp lệ."
},
"camoufoxRemoved": "Camoufox không còn được hỗ trợ. Hãy tạo lại hồ sơ này bằng Wayfern.",
"noE2ePasswordSet": "Chưa đặt mật khẩu mã hóa đầu cuối. Hãy đặt trước khi đồng bộ dữ liệu đã mã hóa."
"noE2ePasswordSet": "Chưa đặt mật khẩu mã hóa đầu cuối. Hãy đặt trước khi đồng bộ dữ liệu đã mã hóa.",
"importSourceNotChromium": "Thư mục này không phải hồ sơ trình duyệt Chromium",
"importSourceNotChromiumNamed": "Không thể nhập hồ sơ {{family}}; chỉ hỗ trợ các trình duyệt nền Chromium",
"importSourceBrowserRunning": "Hãy đóng {{browser}} trước, hoặc chọn vẫn nhập"
},
"rail": {
"profiles": "Profile",
+26 -2
View File
@@ -1467,7 +1467,28 @@
"vpnOptional": "VPN(可选)",
"noVpn": "不使用 VPN",
"advancedOptions": "高级选项",
"configureFingerprint": "配置指纹(可选)"
"configureFingerprint": "配置指纹(可选)",
"reportCookies": "Cookie",
"reportPasswords": "密码",
"reportAutofill": "付款方式",
"reportExtensions": "扩展程序",
"reportHistory": "历史记录",
"reportBookmarks": "书签",
"reportLocalStorage": "网站数据",
"reportNothingCarried": "没有可读取的数据被迁移",
"reportUnrecoverable": "无法解密:{{count}}",
"closeSourceBrowserHint": "关闭源浏览器后重试可获得完整副本;也可以现在导入,但网站数据可能不完整。",
"importAnyway": "仍要导入",
"warnings": {
"secretsNotMigrated": "无法解锁 Cookie 和密码,你需要重新登录。",
"appBoundEncrypted": "Windows 上的 Chrome 127+ 会把 Cookie 绑定到浏览器本身,其他任何应用都无法迁移。",
"storeTooOld": "某个数据库过旧,此浏览器无法打开,已跳过。",
"storeTooNew": "某个数据库来自更新版本的浏览器,已跳过。",
"sourceBrowserRunning": "源浏览器正在运行,网站数据可能不完整。",
"securePreferencesReset": "主页、搜索引擎等受保护的设置已恢复为默认值。",
"extensionsPartial": "部分扩展属于源浏览器,未被迁移。",
"storeUnreadable": "某个数据库无法读取,已跳过而不是复制损坏的副本。"
}
},
"syncTooltips": {
"syncing": "同步中...",
@@ -1914,7 +1935,10 @@
"malformed": "VLESS URI 无效。"
},
"camoufoxRemoved": "Camoufox 已不再受支持。请使用 Wayfern 重新创建此配置文件。",
"noE2ePasswordSet": "尚未设置端到端加密密码。请先设置后再同步加密数据。"
"noE2ePasswordSet": "尚未设置端到端加密密码。请先设置后再同步加密数据。",
"importSourceNotChromium": "该文件夹不是 Chromium 浏览器配置文件",
"importSourceNotChromiumNamed": "无法导入 {{family}} 配置文件;仅支持基于 Chromium 的浏览器",
"importSourceBrowserRunning": "请先关闭 {{browser}},或选择仍要导入"
},
"rail": {
"profiles": "配置文件",
+12
View File
@@ -43,6 +43,8 @@ export type BackendErrorCode =
| "UPDATE_PREPARATION_FAILED"
| "PROFILE_NAME_EXISTS"
| "IMPORT_SOURCE_NOT_FOUND"
| "IMPORT_SOURCE_NOT_CHROMIUM"
| "IMPORT_SOURCE_BROWSER_RUNNING"
| "IMPORT_NO_ITEMS"
| "BROWSER_NOT_DOWNLOADED"
| "ARCHIVE_EXTRACTION_FAILED"
@@ -253,6 +255,16 @@ export function translateBackendError(t: TFunction, err: unknown): string {
});
case "IMPORT_SOURCE_NOT_FOUND":
return t("backendErrors.importSourceNotFound");
case "IMPORT_SOURCE_NOT_CHROMIUM":
return parsed.params?.family
? t("backendErrors.importSourceNotChromiumNamed", {
family: parsed.params.family,
})
: t("backendErrors.importSourceNotChromium");
case "IMPORT_SOURCE_BROWSER_RUNNING":
return t("backendErrors.importSourceBrowserRunning", {
browser: parsed.params?.browser ?? "",
});
case "IMPORT_NO_ITEMS":
return t("backendErrors.importNoItems");
case "BROWSER_NOT_DOWNLOADED":
+35
View File
@@ -275,11 +275,44 @@ export interface DetectedProfile {
export interface ImportProfileItem {
source_path: string;
/**
* Source browser family. Selects which OS keychain entry holds the key that
* unlocks the source's cookies and passwords, so it decides whether secrets
* survive the import.
*/
browser_type?: string;
new_profile_name: string;
/** Mutually exclusive with `vpn_id`; the importer rejects setting both. */
proxy_id?: string | null;
vpn_id?: string | null;
/** Import even though the source browser is still running. */
allow_running?: boolean;
}
/** Stable warning codes; each maps to `importProfile.warnings.*`. */
export type ProfileImportWarning =
| "secretsNotMigrated"
| "appBoundEncrypted"
| "storeTooOld"
| "storeTooNew"
| "sourceBrowserRunning"
| "securePreferencesReset"
| "extensionsPartial"
| "storeUnreadable";
export interface ProfileImportReport {
cookies_migrated: number;
cookies_unrecoverable: number;
passwords_migrated: number;
passwords_unrecoverable: number;
payment_methods_migrated: number;
payment_methods_unrecoverable: number;
extensions_migrated: number;
history_entries: number;
bookmarks: number;
local_storage_origins: number;
bytes_copied: number;
warnings: ProfileImportWarning[];
}
export interface ProfileImportItemResult {
@@ -288,6 +321,8 @@ export interface ProfileImportItemResult {
status: "imported" | "skipped" | "failed";
profile_id: string | null;
error: string | null;
/** What actually came across. Present when status is "imported". */
report?: ProfileImportReport | null;
}
export interface ProfileImportBatchResult {