mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-11-15 08:36:49 +01:00
fix: cookie key diagnostics
This commit is contained in:
1 parent
2c66a3d5aa
commit
4e0e637559
13 files changed
+825
-89
No files matched your search
@@ -3267,7 +3267,10 @@ test("a password-protected profile keeps what each session saved", async () => {
|
||||
.map((n) => readFile(path.join(logDir, n), "utf8")),
|
||||
)
|
||||
).join("\n");
|
||||
assert.doesNotMatch(logText, /Cookie decryption check failed/);
|
||||
assert.doesNotMatch(
|
||||
logText,
|
||||
/Cookie store before launch .*(undecryptable=[1-9]|newest_ok=false|key_changed=yes)/,
|
||||
);
|
||||
assert.doesNotMatch(logText, /Profile re-encryption failed/);
|
||||
assert.equal(
|
||||
logText.match(
|
||||
|
||||
+78
-1
@@ -1,5 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readdir, readFile, utimes, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
mkdir,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
utimes,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -347,6 +354,8 @@ test("two real app devices reconcile profile files and every config entity with
|
||||
path.join(profileData, "Preferences"),
|
||||
JSON.stringify({ donutE2E: "regular-profile-payload" }),
|
||||
);
|
||||
const cookieKey = Buffer.alloc(32, 7);
|
||||
await writeFile(path.join(profileData, "..", "os_crypt_key"), cookieKey);
|
||||
await deviceA.invoke("update_profile_tags", {
|
||||
profileId: profile.id,
|
||||
tags: ["sync", "device-a"],
|
||||
@@ -379,6 +388,7 @@ test("two real app devices reconcile profile files and every config entity with
|
||||
`extension_groups/${extensionGroup.id}.json`,
|
||||
`profiles/${profile.id}/manifest.json`,
|
||||
`profiles/${profile.id}/files/profile/Default/Preferences`,
|
||||
`profiles/${profile.id}/files/profile/os_crypt_key`,
|
||||
].every((key) => keys.includes(key));
|
||||
},
|
||||
"all regular entities uploaded",
|
||||
@@ -425,6 +435,22 @@ test("two real app devices reconcile profile files and every config entity with
|
||||
).includes("regular-profile-payload"),
|
||||
"device B receives profile browser files",
|
||||
);
|
||||
const profileDirB = path.join(
|
||||
deviceB.dataRoot,
|
||||
"data",
|
||||
"profiles",
|
||||
profile.id,
|
||||
);
|
||||
assert.deepEqual(
|
||||
await readFile(path.join(profileDirB, "profile", "os_crypt_key")),
|
||||
cookieKey,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await readFile(path.join(profileDirB, ".donut-sync", "origin"), "utf8")
|
||||
).trim(),
|
||||
"synced_down",
|
||||
);
|
||||
|
||||
// updated_at has one-second resolution. Make the device-B edits
|
||||
// unambiguously newer, then verify last-write-wins in both directions.
|
||||
@@ -455,6 +481,57 @@ test("two real app devices reconcile profile files and every config entity with
|
||||
"newer device-B edits win on device A",
|
||||
);
|
||||
|
||||
// Device B publishes a newer copy without the key file. Device A takes the
|
||||
// newer files but keeps its key, which every cookie depends on, and puts
|
||||
// it back in the cloud.
|
||||
const remoteKey = `profiles/${profile.id}/files/profile/os_crypt_key`;
|
||||
await rm(path.join(profileDirB, "profile", "os_crypt_key"));
|
||||
await writeFile(
|
||||
path.join(profileDirB, "profile", "Default", "Preferences"),
|
||||
JSON.stringify({ donutE2E: "device-b-without-key" }),
|
||||
);
|
||||
await deviceB.invoke("request_profile_sync", { profileId: profile.id });
|
||||
await waitFor(
|
||||
deviceB,
|
||||
async () =>
|
||||
!(await listRemote("")).some((object) => object.key === remoteKey),
|
||||
"device B's copy without the key reaches the cloud",
|
||||
);
|
||||
await deviceA.invoke("request_profile_sync", { profileId: profile.id });
|
||||
await waitFor(
|
||||
deviceA,
|
||||
async () =>
|
||||
(
|
||||
await readFile(path.join(profileData, "Preferences"), "utf8").catch(
|
||||
() => "",
|
||||
)
|
||||
).includes("device-b-without-key") &&
|
||||
(await listRemote("")).some((object) => object.key === remoteKey),
|
||||
"device A takes the newer files and restores the key in the cloud",
|
||||
);
|
||||
assert.deepEqual(
|
||||
await readFile(path.join(profileData, "..", "os_crypt_key")),
|
||||
cookieKey,
|
||||
);
|
||||
const logDirA = path.join(deviceA.dataRoot, "logs");
|
||||
const keptLine = new RegExp(
|
||||
`Profile synced profile=${profile.id} .*key_files=os_crypt_key:kept`,
|
||||
);
|
||||
await waitFor(
|
||||
deviceA,
|
||||
async () =>
|
||||
(
|
||||
await Promise.all(
|
||||
(
|
||||
await readdir(logDirA)
|
||||
)
|
||||
.filter((name) => name.endsWith(".log"))
|
||||
.map((name) => readFile(path.join(logDirA, name), "utf8")),
|
||||
)
|
||||
).some((text) => keptLine.test(text)),
|
||||
"device A logs that it kept the key file",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await deviceA.invoke("is_proxy_in_use_by_synced_profile", {
|
||||
proxyId: proxy.id,
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
//! Whether a Wayfern profile's key file still opens its cookies, checked
|
||||
//! before each launch.
|
||||
//!
|
||||
//! The browser drops every cookie its key cannot open, so a key file that
|
||||
//! changed or went missing since the last launch logs a person out of every
|
||||
//! site. The line this writes says which: the key file's state, whether it
|
||||
//! changed since this device last launched the profile, and whether the
|
||||
//! oldest and the newest encrypted cookie open with it. It never logs the key,
|
||||
//! a hash of it, or a cookie.
|
||||
|
||||
use crate::profile_import::os_crypt;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KeyFile {
|
||||
Missing,
|
||||
Blank,
|
||||
Present(u64),
|
||||
}
|
||||
|
||||
impl KeyFile {
|
||||
fn of(contents: Option<&[u8]>) -> Self {
|
||||
match contents {
|
||||
None => Self::Missing,
|
||||
Some(bytes) if bytes.iter().all(|byte| *byte == 0) => Self::Blank,
|
||||
Some(bytes) => Self::Present(bytes.len() as u64),
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Missing => "missing",
|
||||
Self::Blank => "blank",
|
||||
Self::Present(_) => "present",
|
||||
}
|
||||
}
|
||||
|
||||
fn bytes(self) -> u64 {
|
||||
match self {
|
||||
Self::Present(bytes) => bytes,
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the key file changed since the last launch on this device.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KeyChange {
|
||||
/// No launch recorded yet.
|
||||
First,
|
||||
Same,
|
||||
Changed,
|
||||
}
|
||||
|
||||
impl KeyChange {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::First => "first",
|
||||
Self::Same => "no",
|
||||
Self::Changed => "yes",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct CookieStore {
|
||||
pub cookies: u64,
|
||||
pub encrypted: u64,
|
||||
/// Encrypted rows the key file does not open, by their 3-byte tag.
|
||||
pub undecryptable: BTreeMap<String, u64>,
|
||||
/// `None` when there is no encrypted row.
|
||||
pub oldest_ok: Option<bool>,
|
||||
pub newest_ok: Option<bool>,
|
||||
}
|
||||
|
||||
impl CookieStore {
|
||||
pub fn undecryptable_total(&self) -> u64 {
|
||||
self.undecryptable.values().sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `key` opens one stored `encrypted_value`. Only the cipher is
|
||||
/// checked, not the value inside: a value that is not UTF-8 still opened.
|
||||
fn row_opens(key: Option<&[u8]>, encrypted: &[u8]) -> bool {
|
||||
let Some(key) = key else {
|
||||
return false;
|
||||
};
|
||||
if encrypted.len() < 3 {
|
||||
return false;
|
||||
}
|
||||
let (tag, ciphertext) = encrypted.split_at(3);
|
||||
let Some(primary) = os_crypt::key_for_tag(key, tag) else {
|
||||
return false;
|
||||
};
|
||||
ciphertext.is_empty()
|
||||
|| primary
|
||||
.with_fallbacks()
|
||||
.iter()
|
||||
.any(|candidate| candidate.decrypt(ciphertext).is_some())
|
||||
}
|
||||
|
||||
fn tag_label(encrypted: &[u8]) -> String {
|
||||
match encrypted.get(..3) {
|
||||
Some(tag) if tag.iter().all(u8::is_ascii_alphanumeric) => {
|
||||
String::from_utf8_lossy(tag).into_owned()
|
||||
}
|
||||
_ => "other".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the cookie store at `cookies_path` and test every encrypted row
|
||||
/// against `key`. `None` when the database does not open.
|
||||
pub fn inspect_cookies(cookies_path: &Path, key: Option<&[u8]>) -> Option<CookieStore> {
|
||||
let conn =
|
||||
rusqlite::Connection::open_with_flags(cookies_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
|
||||
.ok()?;
|
||||
let mut store = CookieStore {
|
||||
cookies: conn
|
||||
.query_row("SELECT COUNT(*) FROM cookies", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.ok()?
|
||||
.max(0) as u64,
|
||||
..CookieStore::default()
|
||||
};
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT encrypted_value FROM cookies WHERE length(encrypted_value) > 0 ORDER BY creation_utc, rowid",
|
||||
)
|
||||
.ok()?;
|
||||
let mut rows = stmt.query([]).ok()?;
|
||||
let mut last_ok = None;
|
||||
while let Ok(Some(row)) = rows.next() {
|
||||
let encrypted: Vec<u8> = row.get(0).unwrap_or_default();
|
||||
let ok = row_opens(key, &encrypted);
|
||||
if !ok {
|
||||
*store
|
||||
.undecryptable
|
||||
.entry(tag_label(&encrypted))
|
||||
.or_default() += 1;
|
||||
}
|
||||
if store.oldest_ok.is_none() {
|
||||
store.oldest_ok = Some(ok);
|
||||
}
|
||||
last_ok = Some(ok);
|
||||
store.encrypted += 1;
|
||||
}
|
||||
store.newest_ok = last_ok;
|
||||
Some(store)
|
||||
}
|
||||
|
||||
fn key_stamp(install_key: &[u8], contents: &[u8]) -> String {
|
||||
use ring::hmac;
|
||||
let derived = hmac::sign(
|
||||
&hmac::Key::new(hmac::HMAC_SHA256, install_key),
|
||||
b"donut cookie key stamp",
|
||||
);
|
||||
let tag = hmac::sign(
|
||||
&hmac::Key::new(hmac::HMAC_SHA256, derived.as_ref()),
|
||||
contents,
|
||||
);
|
||||
tag
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Compare the key file with the stamp kept for `profile_id` in
|
||||
/// `stamps_file`, and keep the new stamp. A missing key file keeps the old
|
||||
/// stamp, so the key that comes back is compared with the one that was lost.
|
||||
pub fn key_change(
|
||||
stamps_file: &Path,
|
||||
install_key: &[u8],
|
||||
profile_id: &str,
|
||||
contents: Option<&[u8]>,
|
||||
) -> KeyChange {
|
||||
let mut stamps: BTreeMap<String, String> = std::fs::read(stamps_file)
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_slice(&raw).ok())
|
||||
.unwrap_or_default();
|
||||
let current = contents
|
||||
.filter(|bytes| KeyFile::of(Some(bytes)) != KeyFile::Blank)
|
||||
.map(|bytes| key_stamp(install_key, bytes));
|
||||
let change = match (stamps.get(profile_id), ¤t) {
|
||||
(None, _) => KeyChange::First,
|
||||
(Some(old), Some(new)) if old == new => KeyChange::Same,
|
||||
_ => KeyChange::Changed,
|
||||
};
|
||||
if let Some(new) = current {
|
||||
if stamps.get(profile_id) != Some(&new) {
|
||||
stamps.insert(profile_id.to_string(), new);
|
||||
save_stamps(stamps_file, &stamps);
|
||||
}
|
||||
}
|
||||
change
|
||||
}
|
||||
|
||||
/// Forget a deleted profile's stamp.
|
||||
pub fn forget(profile_id: &str) {
|
||||
let path = stamps_file();
|
||||
let Ok(raw) = std::fs::read(&path) else {
|
||||
return;
|
||||
};
|
||||
let Ok(mut stamps) = serde_json::from_slice::<BTreeMap<String, String>>(&raw) else {
|
||||
return;
|
||||
};
|
||||
if stamps.remove(profile_id).is_some() {
|
||||
save_stamps(&path, &stamps);
|
||||
}
|
||||
}
|
||||
|
||||
fn save_stamps(path: &Path, stamps: &BTreeMap<String, String>) {
|
||||
let Ok(json) = serde_json::to_vec(stamps) else {
|
||||
return;
|
||||
};
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let staging = path.with_extension("json.tmp");
|
||||
if std::fs::write(&staging, json).is_ok() && std::fs::rename(&staging, path).is_ok() {
|
||||
crate::app_dirs::restrict_to_owner(path);
|
||||
}
|
||||
}
|
||||
|
||||
fn stamps_file() -> std::path::PathBuf {
|
||||
crate::app_dirs::settings_dir().join("cookie_key_stamps.json")
|
||||
}
|
||||
|
||||
fn flag(value: Option<bool>) -> &'static str {
|
||||
match value {
|
||||
Some(true) => "true",
|
||||
Some(false) => "false",
|
||||
None => "-",
|
||||
}
|
||||
}
|
||||
|
||||
fn tags(undecryptable: &BTreeMap<String, u64>) -> String {
|
||||
if undecryptable.is_empty() {
|
||||
return "-".to_string();
|
||||
}
|
||||
undecryptable
|
||||
.iter()
|
||||
.map(|(tag, count)| format!("{tag}:{count}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
/// The line about a profile's key file and cookies, and its level.
|
||||
pub fn report(
|
||||
profile_id: &str,
|
||||
key: KeyFile,
|
||||
change: KeyChange,
|
||||
store: Option<&CookieStore>,
|
||||
) -> (log::Level, String) {
|
||||
let default = CookieStore::default();
|
||||
let store = store.unwrap_or(&default);
|
||||
let undecryptable = store.undecryptable_total();
|
||||
let level =
|
||||
if store.encrypted > 0 && (store.newest_ok == Some(false) || key != KeyFile::Present(32)) {
|
||||
log::Level::Error
|
||||
} else if change == KeyChange::Changed || undecryptable > 0 {
|
||||
log::Level::Warn
|
||||
} else {
|
||||
log::Level::Debug
|
||||
};
|
||||
(
|
||||
level,
|
||||
format!(
|
||||
"Cookie store before launch profile={profile_id} key={} key_bytes={} key_changed={} cookies={} encrypted={} undecryptable={undecryptable} undecryptable_tags={} oldest_ok={} newest_ok={}",
|
||||
key.label(),
|
||||
key.bytes(),
|
||||
change.label(),
|
||||
store.cookies,
|
||||
store.encrypted,
|
||||
tags(&store.undecryptable),
|
||||
flag(store.oldest_ok),
|
||||
flag(store.newest_ok),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Check a Wayfern profile's key file and cookies before a launch and log
|
||||
/// what was found. `profile_data_dir` is the browser's user-data dir.
|
||||
pub fn log_before_launch(profile_id: &str, profile_data_dir: &Path) {
|
||||
let contents = std::fs::read(profile_data_dir.join(os_crypt::KEY_FILE_NAME)).ok();
|
||||
let key = KeyFile::of(contents.as_deref());
|
||||
let change = match crate::vault::install_key() {
|
||||
Ok(install_key) => key_change(
|
||||
&stamps_file(),
|
||||
&install_key,
|
||||
profile_id,
|
||||
contents.as_deref(),
|
||||
),
|
||||
Err(_) => KeyChange::First,
|
||||
};
|
||||
let cookies_path = crate::cookie_manager::CookieManager::wayfern_cookie_path(profile_data_dir);
|
||||
let usable_key = contents.as_deref().filter(|_| key != KeyFile::Blank);
|
||||
let store = cookies_path
|
||||
.exists()
|
||||
.then(|| inspect_cookies(&cookies_path, usable_key))
|
||||
.flatten();
|
||||
let (level, line) = report(profile_id, key, change, store.as_ref());
|
||||
log::log!(level, "{line}");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn seal(key: &[u8; 32], host: &str, value: &[u8]) -> Vec<u8> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut plain = Sha256::digest(host.as_bytes()).to_vec();
|
||||
plain.extend_from_slice(value);
|
||||
let mut sealed = b"v10".to_vec();
|
||||
sealed.extend(
|
||||
os_crypt::CryptoKey::Aes256Gcm(*key)
|
||||
.encrypt(&plain)
|
||||
.unwrap(),
|
||||
);
|
||||
sealed
|
||||
}
|
||||
|
||||
fn cookie_db(path: &Path, rows: &[(i64, Vec<u8>)]) {
|
||||
let conn = rusqlite::Connection::open(path).unwrap();
|
||||
conn
|
||||
.execute_batch(
|
||||
"CREATE TABLE cookies (creation_utc INTEGER, host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB)",
|
||||
)
|
||||
.unwrap();
|
||||
for (created, encrypted) in rows {
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO cookies VALUES (?1, 'example.com', 'n', '', ?2)",
|
||||
rusqlite::params![created, encrypted],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_the_oldest_and_newest_rows_separately() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("Cookies");
|
||||
let key = [7u8; 32];
|
||||
let other = [9u8; 32];
|
||||
// Inserted newest first, so rowid order differs from creation order.
|
||||
cookie_db(
|
||||
&db,
|
||||
&[
|
||||
(300, seal(&key, "example.com", b"new")),
|
||||
(100, seal(&other, "example.com", b"stale")),
|
||||
(200, seal(&key, "example.com", &[0xE9, 0xFF])),
|
||||
(250, b"v20garbage-bytes".to_vec()),
|
||||
],
|
||||
);
|
||||
let store = inspect_cookies(&db, Some(&key)).unwrap();
|
||||
assert_eq!(store.cookies, 4);
|
||||
assert_eq!(store.encrypted, 4);
|
||||
assert_eq!(store.oldest_ok, Some(false));
|
||||
assert_eq!(store.newest_ok, Some(true));
|
||||
assert_eq!(
|
||||
store.undecryptable,
|
||||
BTreeMap::from([("v10".to_string(), 1), ("v20".to_string(), 1)])
|
||||
);
|
||||
let (level, line) = report("p", KeyFile::Present(32), KeyChange::Same, Some(&store));
|
||||
assert_eq!(level, log::Level::Warn);
|
||||
assert!(line
|
||||
.contains("undecryptable=2 undecryptable_tags=v10:1,v20:1 oldest_ok=false newest_ok=true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newest_row_that_does_not_open_is_an_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let db = dir.path().join("Cookies");
|
||||
cookie_db(&db, &[(1, seal(&[1u8; 32], "a.test", b"x"))]);
|
||||
let store = inspect_cookies(&db, Some(&[2u8; 32])).unwrap();
|
||||
let (level, line) = report("p", KeyFile::Present(32), KeyChange::Changed, Some(&store));
|
||||
assert_eq!(level, log::Level::Error);
|
||||
assert!(line.contains("key_changed=yes"));
|
||||
assert!(line.contains("newest_ok=false"));
|
||||
let no_key = inspect_cookies(&db, None).unwrap();
|
||||
assert_eq!(no_key.undecryptable_total(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_change_tracks_the_file_without_storing_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let stamps = dir.path().join("stamps.json");
|
||||
let install = [3u8; 32];
|
||||
let key = [5u8; 32];
|
||||
assert_eq!(
|
||||
key_change(&stamps, &install, "p", Some(&key)),
|
||||
KeyChange::First
|
||||
);
|
||||
assert_eq!(
|
||||
key_change(&stamps, &install, "p", Some(&key)),
|
||||
KeyChange::Same
|
||||
);
|
||||
let raw = std::fs::read(&stamps).unwrap();
|
||||
assert!(!raw.windows(key.len()).any(|w| w == key));
|
||||
// A lost file is a change, and the old stamp stays to compare against.
|
||||
assert_eq!(key_change(&stamps, &install, "p", None), KeyChange::Changed);
|
||||
assert_eq!(
|
||||
key_change(&stamps, &install, "p", Some(&key)),
|
||||
KeyChange::Same
|
||||
);
|
||||
assert_eq!(
|
||||
key_change(&stamps, &install, "p", Some(&[6u8; 32])),
|
||||
KeyChange::Changed
|
||||
);
|
||||
assert_eq!(
|
||||
key_change(&stamps, &install, "q", Some(&key)),
|
||||
KeyChange::First
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_healthy_store_logs_at_debug() {
|
||||
let store = CookieStore {
|
||||
cookies: 2,
|
||||
encrypted: 2,
|
||||
oldest_ok: Some(true),
|
||||
newest_ok: Some(true),
|
||||
..CookieStore::default()
|
||||
};
|
||||
let (level, line) = report("p", KeyFile::Present(32), KeyChange::Same, Some(&store));
|
||||
assert_eq!(level, log::Level::Debug);
|
||||
assert!(line.contains("key=present key_bytes=32 key_changed=no"));
|
||||
assert!(line.contains("undecryptable_tags=-"));
|
||||
}
|
||||
}
|
||||
@@ -250,7 +250,7 @@ fn facts(
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"{} browser={} version={} running={} ephemeral={} proxy={} vpn={} group={} sync={} extensions={}",
|
||||
"{} browser={} version={} running={} ephemeral={} proxy={} vpn={} group={} sync={} extensions={} clear_on_close={} origin={}",
|
||||
aliases.apply(&profile.id.to_string()),
|
||||
profile.browser,
|
||||
profile.version,
|
||||
@@ -261,6 +261,8 @@ fn facts(
|
||||
profile.group_id.as_deref().map_or("-".to_string(), |id| aliases.apply(id)),
|
||||
sync,
|
||||
profile.extension_group_id.is_some(),
|
||||
profile.clear_on_close,
|
||||
crate::profile::origin::read(&profile.id),
|
||||
);
|
||||
}
|
||||
if profiles.len() > MAX_LISTED_PROFILES {
|
||||
|
||||
@@ -255,6 +255,7 @@ mod cloud_errors;
|
||||
mod commercial_license;
|
||||
mod cookie_bot;
|
||||
mod cookie_bot_exit;
|
||||
mod cookie_health;
|
||||
mod cookie_manager;
|
||||
mod cookie_paste;
|
||||
pub mod events;
|
||||
|
||||
@@ -623,6 +623,7 @@ impl ProfileManager {
|
||||
|
||||
// Save profile info
|
||||
self.save_profile(&profile)?;
|
||||
crate::profile::origin::record(&profile.id, crate::profile::origin::Origin::Created);
|
||||
|
||||
// Verify the profile was saved correctly
|
||||
if !profile_file.exists() {
|
||||
@@ -914,6 +915,9 @@ impl ProfileManager {
|
||||
let started = std::time::Instant::now();
|
||||
let removed = self.find_profile(profile_id).ok();
|
||||
let outcome = self.remove_profile_unlogged(app_handle, profile_id, permanent, emit_events)?;
|
||||
if permanent {
|
||||
crate::cookie_health::forget(profile_id);
|
||||
}
|
||||
if let Some(profile) = &removed {
|
||||
report_team_edit(profile, "profile.delete", &[]);
|
||||
}
|
||||
@@ -1831,6 +1835,7 @@ impl ProfileManager {
|
||||
}
|
||||
|
||||
self.save_profile(&new_profile)?;
|
||||
crate::profile::origin::record(&new_profile.id, crate::profile::origin::Origin::Cloned);
|
||||
|
||||
if let Err(e) = events::emit_empty("profiles-changed") {
|
||||
log::warn!("Event emit failed event=profiles-changed err=\"{e}\"");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod clear_on_close;
|
||||
pub mod encryption;
|
||||
pub mod manager;
|
||||
pub mod origin;
|
||||
pub mod password;
|
||||
pub mod patch;
|
||||
pub mod portable;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! How a profile came to this device: made here, cloned, imported, or
|
||||
//! downloaded by sync. Kept per device in the profile's `.donut-sync` folder,
|
||||
//! which sync never uploads, because the same profile has a different origin
|
||||
//! on each device. Profiles from before this record read `unknown`.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Origin {
|
||||
Created,
|
||||
Cloned,
|
||||
Imported,
|
||||
SyncedDown,
|
||||
}
|
||||
|
||||
impl Origin {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Created => "created",
|
||||
Self::Cloned => "cloned",
|
||||
Self::Imported => "imported",
|
||||
Self::SyncedDown => "synced_down",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn origin_file(profile_dir: &Path) -> PathBuf {
|
||||
profile_dir.join(".donut-sync").join("origin")
|
||||
}
|
||||
|
||||
/// Note where the profile in `profile_dir` (`<profiles dir>/<id>`) came
|
||||
/// from. Written when the profile appears here, over any record a copied
|
||||
/// directory brought along.
|
||||
pub fn record_in(profile_dir: &Path, origin: Origin) {
|
||||
let file = origin_file(profile_dir);
|
||||
if let Some(parent) = file.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = std::fs::write(file, origin.label());
|
||||
}
|
||||
|
||||
pub fn read_in(profile_dir: &Path) -> &'static str {
|
||||
match std::fs::read_to_string(origin_file(profile_dir))
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
{
|
||||
"created" => "created",
|
||||
"cloned" => "cloned",
|
||||
"imported" => "imported",
|
||||
"synced_down" => "synced_down",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record(profile_id: &uuid::Uuid, origin: Origin) {
|
||||
record_in(
|
||||
&crate::app_dirs::profiles_dir().join(profile_id.to_string()),
|
||||
origin,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn read(profile_id: &uuid::Uuid) -> &'static str {
|
||||
read_in(&crate::app_dirs::profiles_dir().join(profile_id.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_unknown_without_a_record_and_the_latest_record_with_one() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(read_in(dir.path()), "unknown");
|
||||
record_in(dir.path(), Origin::SyncedDown);
|
||||
assert_eq!(read_in(dir.path()), "synced_down");
|
||||
record_in(dir.path(), Origin::Cloned);
|
||||
assert_eq!(read_in(dir.path()), "cloned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_record_is_outside_what_sync_uploads() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
record_in(dir.path(), Origin::Imported);
|
||||
let mut cache = crate::sync::manifest::HashCache::default();
|
||||
let manifest = crate::sync::manifest::generate_manifest("p", dir.path(), &mut cache).unwrap();
|
||||
assert!(manifest.files.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -598,6 +598,7 @@ pub async fn import_profile_archive(
|
||||
let _ = fs::remove_dir_all(data_dir.parent().unwrap_or(&data_dir));
|
||||
import_err(format!("could not save it: {e}"))
|
||||
})?;
|
||||
crate::profile::origin::record(&profile.id, crate::profile::origin::Origin::Imported);
|
||||
let _ = crate::events::emit("profiles-changed", serde_json::json!({}));
|
||||
Ok(profile)
|
||||
})
|
||||
|
||||
@@ -1202,6 +1202,7 @@ impl ProfileImporter {
|
||||
};
|
||||
|
||||
self.profile_manager.save_profile(&profile)?;
|
||||
crate::profile::origin::record(&profile.id, crate::profile::origin::Origin::Imported);
|
||||
|
||||
if report.is_empty_import() {
|
||||
// Not an error — an empty source profile imports legitimately — but it is
|
||||
|
||||
@@ -1137,6 +1137,9 @@ impl SyncEngine {
|
||||
log::warn!("Unsafe manifest path, local delete skipped path={path:?}");
|
||||
continue;
|
||||
}
|
||||
if pending_session.is_none() && super::manifest::is_key_material(path) {
|
||||
continue;
|
||||
}
|
||||
let file_path = profile_dir.join(path);
|
||||
if file_path.exists() {
|
||||
let _ = fs::remove_file(&file_path);
|
||||
@@ -1144,6 +1147,37 @@ impl SyncEngine {
|
||||
}
|
||||
}
|
||||
|
||||
// The remote copy lacks a key file this device has. Deleting it would lose
|
||||
// every encrypted cookie, so it stays and goes up with the rest.
|
||||
let kept_keys: Vec<super::manifest::ManifestFileEntry> = local_manifest
|
||||
.files
|
||||
.iter()
|
||||
.filter(|f| {
|
||||
pending_session.is_none()
|
||||
&& super::manifest::is_key_material(&f.path)
|
||||
&& diff.files_to_delete_local.contains(&f.path)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if !kept_keys.is_empty() {
|
||||
log::warn!(
|
||||
"Remote copy lacks key files, local kept profile={profile_id} files={}",
|
||||
kept_keys.len()
|
||||
);
|
||||
self
|
||||
.upload_profile_files(
|
||||
app_handle,
|
||||
&profile_id,
|
||||
&profile.name,
|
||||
&profile_dir,
|
||||
&kept_keys,
|
||||
encryption_key.as_ref(),
|
||||
&key_prefix,
|
||||
&cancel_flag,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Delete remote files that don't exist locally (when local is newer)
|
||||
for path in &diff.files_to_delete_remote {
|
||||
let remote_key = format!("{}profiles/{}/files/{}", key_prefix, profile_id, path);
|
||||
@@ -1158,6 +1192,8 @@ impl SyncEngine {
|
||||
// devices ping-pong re-uploads forever. Regenerate from the
|
||||
// actual on-disk files before uploading. When only uploads happened, the
|
||||
// on-disk state is unchanged and the original manifest is still accurate.
|
||||
let key_files =
|
||||
super::manifest::key_file_changes(&diff, &local_manifest, pending_session.is_none());
|
||||
let local_changed =
|
||||
!diff.files_to_download.is_empty() || !diff.files_to_delete_local.is_empty();
|
||||
let final_manifest = if local_changed {
|
||||
@@ -1248,7 +1284,7 @@ impl SyncEngine {
|
||||
crate::cookie_bot::report_profile_state(&updated_profile);
|
||||
|
||||
log::info!(
|
||||
"Profile synced profile={profile_id} uploaded={} downloaded={} deleted_local={} deleted_remote={} bytes_up={upload_bytes} bytes_down={download_bytes} elapsed_ms={}",
|
||||
"Profile synced profile={profile_id} uploaded={} downloaded={} deleted_local={} deleted_remote={} key_files={key_files} bytes_up={upload_bytes} bytes_down={download_bytes} elapsed_ms={}",
|
||||
diff.files_to_upload.len(),
|
||||
diff.files_to_download.len(),
|
||||
diff.files_to_delete_local.len(),
|
||||
@@ -3072,6 +3108,7 @@ impl SyncEngine {
|
||||
profile_manager
|
||||
.save_profile(&profile)
|
||||
.map_err(|e| SyncError::IoError(format!("Failed to save cross-OS profile: {e}")))?;
|
||||
crate::profile::origin::record_in(&profile_dir, crate::profile::origin::Origin::SyncedDown);
|
||||
|
||||
let _ = events::emit("profiles-changed", ());
|
||||
let _ = events::emit(
|
||||
@@ -3179,6 +3216,7 @@ impl SyncEngine {
|
||||
profile_manager
|
||||
.save_profile(&profile)
|
||||
.map_err(|e| SyncError::IoError(format!("Failed to save downloaded profile: {e}")))?;
|
||||
crate::profile::origin::record_in(&profile_dir, crate::profile::origin::Origin::SyncedDown);
|
||||
|
||||
let _ = events::emit("profiles-changed", ());
|
||||
let _ = events::emit(
|
||||
|
||||
@@ -451,6 +451,56 @@ pub enum DiffBias {
|
||||
Restore,
|
||||
}
|
||||
|
||||
/// The files that hold a Wayfern profile's cookies and the key that opens
|
||||
/// them, by manifest path. A sync that moves one without the other logs the
|
||||
/// person out of every site, so the result line names what happened to each.
|
||||
const KEY_FILES: &[(&str, &str)] = &[
|
||||
("profile/os_crypt_key", "os_crypt_key"),
|
||||
("profile/Local State", "Local State"),
|
||||
("profile/Default/Network/Cookies", "Cookies"),
|
||||
("profile/Default/Cookies", "Cookies"),
|
||||
];
|
||||
|
||||
/// Key material a sync never deletes locally: without it every encrypted
|
||||
/// cookie and password in the profile is lost, and a remote copy that lacks it
|
||||
/// cannot replace it.
|
||||
pub fn is_key_material(path: &str) -> bool {
|
||||
matches!(path, "profile/os_crypt_key" | "profile/Local State")
|
||||
}
|
||||
|
||||
/// `os_crypt_key:downloaded,Cookies:replaced`: what `diff` does to the
|
||||
/// profile's key files on this device, or `-` when it touches none. A download
|
||||
/// over a file `local` has is `replaced`; key material the sync keeps instead
|
||||
/// of deleting (`keeps_keys`) is `kept`.
|
||||
pub fn key_file_changes(diff: &ManifestDiff, local: &SyncManifest, keeps_keys: bool) -> String {
|
||||
let changes: Vec<String> = KEY_FILES
|
||||
.iter()
|
||||
.filter_map(|(path, name)| {
|
||||
let action = if diff.files_to_download.iter().any(|f| f.path == *path) {
|
||||
if local.files.iter().any(|f| f.path == *path) {
|
||||
"replaced"
|
||||
} else {
|
||||
"downloaded"
|
||||
}
|
||||
} else if diff.files_to_delete_local.iter().any(|p| p == path) {
|
||||
if keeps_keys && is_key_material(path) {
|
||||
"kept"
|
||||
} else {
|
||||
"deleted"
|
||||
}
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
Some(format!("{name}:{action}"))
|
||||
})
|
||||
.collect();
|
||||
if changes.is_empty() {
|
||||
"-".to_string()
|
||||
} else {
|
||||
changes.join(",")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> ManifestDiff {
|
||||
compute_diff_with_bias(local, remote, DiffBias::Auto)
|
||||
}
|
||||
@@ -830,6 +880,52 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_file_changes_name_what_a_sync_does_to_the_key_and_cookies() {
|
||||
let local = manifest_at(
|
||||
"2026-01-01T00:00:00Z",
|
||||
&[
|
||||
("profile/os_crypt_key", "k1"),
|
||||
("profile/Local State", "ls"),
|
||||
("profile/Default/Network/Cookies", "c1"),
|
||||
("profile/Default/History", "h1"),
|
||||
],
|
||||
);
|
||||
let remote = manifest_at(
|
||||
"2026-01-02T00:00:00Z",
|
||||
&[
|
||||
("profile/Default/Network/Cookies", "c2"),
|
||||
("profile/Default/History", "h2"),
|
||||
],
|
||||
);
|
||||
let diff = compute_diff(&local, Some(&remote));
|
||||
assert_eq!(
|
||||
key_file_changes(&diff, &local, true),
|
||||
"os_crypt_key:kept,Local State:kept,Cookies:replaced"
|
||||
);
|
||||
assert_eq!(
|
||||
key_file_changes(&diff, &local, false),
|
||||
"os_crypt_key:deleted,Local State:deleted,Cookies:replaced"
|
||||
);
|
||||
|
||||
let empty = manifest_at("2026-01-01T00:00:00Z", &[("profile/x", "1")]);
|
||||
let fresh = manifest_at(
|
||||
"2026-01-02T00:00:00Z",
|
||||
&[("profile/x", "1"), ("profile/os_crypt_key", "k")],
|
||||
);
|
||||
let diff = compute_diff(&empty, Some(&fresh));
|
||||
assert_eq!(
|
||||
key_file_changes(&diff, &empty, true),
|
||||
"os_crypt_key:downloaded"
|
||||
);
|
||||
assert_eq!(
|
||||
key_file_changes(&compute_diff(&fresh, Some(&fresh)), &fresh, true),
|
||||
"-"
|
||||
);
|
||||
assert!(is_key_material("profile/os_crypt_key"));
|
||||
assert!(!is_key_material("profile/Default/Network/Cookies"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefer_remote_downloads_even_though_the_local_clock_is_later() {
|
||||
// The exact shape of the data-loss bug. A remote session finishes and the
|
||||
|
||||
@@ -1021,6 +1021,38 @@ impl BrowserLogTap {
|
||||
}
|
||||
}
|
||||
|
||||
/// The level a browser stderr line is logged at, or `None` to drop it. Only
|
||||
/// what the browser says about Wayfern, its own errors, and its cookie key
|
||||
/// (`os_crypt`) are kept.
|
||||
fn browser_line_level(line: &str) -> Option<log::Level> {
|
||||
if line.contains("os_crypt") {
|
||||
// The browser could not read or write the key that seals cookies: the
|
||||
// profile is about to lose its sign-ins.
|
||||
return Some(log::Level::Warn);
|
||||
}
|
||||
// Chromium's own errors are kept too: a browser that quits before its
|
||||
// control port opens says why only there.
|
||||
if line.contains("Wayfern")
|
||||
|| line.contains("wayfern_")
|
||||
|| line.contains(":ERROR:")
|
||||
|| line.contains(":FATAL:")
|
||||
{
|
||||
// What the browser did with the identity explains a profile that
|
||||
// launched without one; the rest is detail.
|
||||
return Some(if line.contains("Wayfern launch identity") {
|
||||
log::Level::Info
|
||||
} else {
|
||||
log::Level::Debug
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// One `os_crypt` line per profile a minute: a browser that cannot open its
|
||||
/// key says so for every cookie.
|
||||
static OS_CRYPT_LINES: crate::log_streak::Throttle =
|
||||
crate::log_streak::Throttle::new(Duration::from_secs(60));
|
||||
|
||||
/// Keep reading the browser's stderr for its lifetime, keeping only what it
|
||||
/// says about Wayfern. Reading it all is what keeps the pipe from filling.
|
||||
/// `owner` is `key=value` text naming whose browser this is.
|
||||
@@ -1029,27 +1061,27 @@ fn tap_browser_stderr(stderr: tokio::process::ChildStderr, tap: BrowserLogTap, o
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
let mut lines = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
// Chromium's own errors are kept too: a browser that quits before its
|
||||
// control port opens says why only there.
|
||||
if line.contains("Wayfern")
|
||||
|| line.contains("wayfern_")
|
||||
|| line.contains(":ERROR:")
|
||||
|| line.contains(":FATAL:")
|
||||
{
|
||||
// What the browser did with the identity explains a profile that
|
||||
// launched without one; the rest is detail.
|
||||
let level = if line.contains("Wayfern launch identity") {
|
||||
log::Level::Info
|
||||
} else {
|
||||
log::Level::Debug
|
||||
};
|
||||
let Some(level) = browser_line_level(&line) else {
|
||||
continue;
|
||||
};
|
||||
let text = crate::log_redaction::text(&line);
|
||||
if level == log::Level::Warn {
|
||||
let key = owner.split_whitespace().next().unwrap_or_default();
|
||||
if let Some(dropped) = OS_CRYPT_LINES.allow(key) {
|
||||
log::warn!(
|
||||
"Browser cookie key error {owner} line=\"{}\"{}",
|
||||
crate::log_redaction::Plain(&text),
|
||||
crate::log_streak::suppressed(dropped)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
log::log!(
|
||||
level,
|
||||
"Browser said {owner} line=\"{}\"",
|
||||
crate::log_redaction::Plain(&crate::log_redaction::text(&line))
|
||||
crate::log_redaction::Plain(&text)
|
||||
);
|
||||
tap.push(line);
|
||||
}
|
||||
tap.push(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -3065,75 +3097,10 @@ impl WayfernManager {
|
||||
None => Self::find_free_port().await?,
|
||||
};
|
||||
|
||||
// Diagnostic: verify critical profile files and test cookie decryption
|
||||
{
|
||||
let profile_path_buf = std::path::PathBuf::from(profile_path);
|
||||
let key_path = profile_path_buf.join("os_crypt_key");
|
||||
let cookies_path =
|
||||
crate::cookie_manager::CookieManager::wayfern_cookie_path(&profile_path_buf);
|
||||
|
||||
// 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()).ok();
|
||||
let mut cookie_counts = None;
|
||||
let mut decryption = "not_checked";
|
||||
|
||||
if cookies_path.exists() {
|
||||
// Try to open Cookies DB and check if encrypted cookies can be decrypted
|
||||
if let Ok(conn) = rusqlite::Connection::open_with_flags(
|
||||
&cookies_path,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
|
||||
) {
|
||||
let cookie_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM cookies WHERE length(encrypted_value) > 0",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let total_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM cookies", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
cookie_counts = Some((total_count, cookie_count));
|
||||
|
||||
// Try decrypting one cookie using the cookie_manager
|
||||
if let Some(encryption_key) =
|
||||
crate::cookie_manager::chrome_decrypt::get_encryption_key(&profile_path_buf)
|
||||
{
|
||||
if let Ok(mut stmt) = conn.prepare(
|
||||
"SELECT name, host_key, encrypted_value FROM cookies WHERE length(encrypted_value) > 0 LIMIT 1",
|
||||
) {
|
||||
if let Ok(mut rows) = stmt.query([]) {
|
||||
if let Ok(Some(row)) = rows.next() {
|
||||
let host: String = row.get(1).unwrap_or_default();
|
||||
let encrypted: Vec<u8> = row.get(2).unwrap_or_default();
|
||||
let decrypted = crate::cookie_manager::chrome_decrypt::decrypt(
|
||||
&encrypted,
|
||||
&host,
|
||||
&encryption_key,
|
||||
);
|
||||
decryption = if decrypted.is_some() { "ok" } else { "failed" };
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
decryption = "no_key";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (cookies, encrypted) = cookie_counts.unwrap_or_default();
|
||||
log::debug!(
|
||||
"Cookie store before launch profile={} os_crypt_key_bytes={key_len:?} cookies={cookies} encrypted={encrypted} decryption={decryption}",
|
||||
profile.id
|
||||
);
|
||||
if encrypted > 0 && matches!(decryption, "failed" | "no_key") {
|
||||
log::error!(
|
||||
"Cookie decryption check failed profile={} encrypted={encrypted} reason={decryption}",
|
||||
profile.id
|
||||
);
|
||||
}
|
||||
}
|
||||
crate::cookie_health::log_before_launch(
|
||||
&profile.id.to_string(),
|
||||
std::path::Path::new(profile_path),
|
||||
);
|
||||
|
||||
let mut args = vec![
|
||||
format!("--remote-debugging-port={port}"),
|
||||
@@ -4412,6 +4379,29 @@ fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn only_wayfern_errors_and_cookie_key_lines_leave_the_browser() {
|
||||
use super::browser_line_level;
|
||||
assert_eq!(
|
||||
browser_line_level(
|
||||
"[1:2:1102/0:ERROR:components\\os_crypt\\sync\\os_crypt_win.cc:90] Failed to decrypt"
|
||||
),
|
||||
Some(log::Level::Warn)
|
||||
);
|
||||
assert_eq!(
|
||||
browser_line_level("[1:2:INFO:wayfern_launch_identity.cc:1] Wayfern launch identity applied"),
|
||||
Some(log::Level::Info)
|
||||
);
|
||||
assert_eq!(
|
||||
browser_line_level("[1:2:ERROR:gpu_init.cc:1] no GPU"),
|
||||
Some(log::Level::Debug)
|
||||
);
|
||||
assert_eq!(
|
||||
browser_line_level("[1:2:INFO:visited.cc:1] https://example.com"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_location_carries_the_exit_for_create_identity() {
|
||||
let geo = crate::geolocation::Geolocation {
|
||||
|
||||
Reference in new issue
Block a user