mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 06:51:24 +02:00
fix: profile data and launch
This commit is contained in:
1 parent
e9e86edcc3
commit
df928fb12e
11 files changed
+153
-22
No files matched your search
@@ -1268,6 +1268,8 @@ mod tests {
|
||||
const SYNTHETIC_ENCRYPTED_COOKIE_HEX: &str = "763130d83b9fd3e6d1b1c793769f55251f5e9d1193be72c0c08ea32e2cf068a85d9d0b97d8b2e6deca93a2b3c290e98e1a851f83d5566f9aa9314befe56dc6bdbd423d";
|
||||
/// `v10`, raw key file: AES-256-GCM, nonce 0xa0..0xab, then ciphertext and tag.
|
||||
const V10_RAW_KEY_GCM_HEX: &str = "763130a0a1a2a3a4a5a6a7a8a9aaab80ca93cabb34c5dd58110a3d46900c1fa9ecd0d2de71d7b20447d42094e312b029ee1c37a83550c88b77cbf131874eaccf3a660a9e9c574aba285fd5136dc8dfcc4f4cf4f000";
|
||||
/// `v10`, raw key file: AES-128-CBC, PBKDF2(the 32 bytes, "saltysalt", 1003).
|
||||
const V10_RAW_KEY_CBC_HEX: &str = "7631301293fcc3514b2f9f809661394660f53164bc85085480268bb96eeff8ec82f45cbf38e0cc19bc44626a36381cd719fc7714a818351bfe922cd2283c9cb5e6a267";
|
||||
/// `v11`, password file: AES-128-CBC, PBKDF2(password, "saltysalt", 1).
|
||||
const V11_PASSWORD_HEX: &str = "76313134ac798d9e3721442b1ff0eeaf137cbaa12cbf87938b967e5f63791f03bd1928007773702a89a8f4a53145d1ea1814815f2ab922cd4e1dbe341cd327af6cd02f";
|
||||
/// `v11`, raw key file: AES-128-CBC, PBKDF2(the 32 bytes, "saltysalt", 1).
|
||||
@@ -1952,13 +1954,25 @@ mod tests {
|
||||
assert_eq!(open(V10_RAW_KEY_GCM_HEX, &password_key()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raw_key_file_opens_a_cbc_v10_record_with_1003_iterations() {
|
||||
assert_eq!(
|
||||
open(V10_RAW_KEY_CBC_HEX, &raw_key()).as_deref(),
|
||||
Some(SYNTHETIC_COOKIE_VALUE)
|
||||
);
|
||||
assert_eq!(
|
||||
open(V10_PEANUTS_FALLBACK_HEX, &raw_key()).as_deref(),
|
||||
Some(SYNTHETIC_COOKIE_VALUE)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_password_key_file_opens_v10_with_1003_iterations() {
|
||||
assert_eq!(
|
||||
open(SYNTHETIC_ENCRYPTED_COOKIE_HEX, &password_key()).as_deref(),
|
||||
Some(SYNTHETIC_COOKIE_VALUE)
|
||||
);
|
||||
// A raw key file reads v10 as GCM, so this CBC record does not open there.
|
||||
// A record keyed by another file's password does not open under a raw key.
|
||||
assert_eq!(open(SYNTHETIC_ENCRYPTED_COOKIE_HEX, &raw_key()), None);
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ const TMP_EXTENSION: &str = "donut-tmp";
|
||||
|
||||
/// What a sealed copy leaves out: caches the browser rebuilds, lock and temp
|
||||
/// files, crash and metrics dumps, and the files Donut writes again before
|
||||
/// every launch. Everything else is kept, the SQLite journals and write-ahead
|
||||
/// every launch. Service worker scripts and Cache Storage are site data and
|
||||
/// are sealed: without the script, Chromium drops the registration. Everything else is kept, the SQLite journals and write-ahead
|
||||
/// logs included: without them a database the browser did not close cleanly
|
||||
/// opens torn instead of rolled back.
|
||||
pub const SEALED_EXCLUDE_PATTERNS: &[&str] = &[
|
||||
@@ -49,8 +50,6 @@ pub const SEALED_EXCLUDE_PATTERNS: &[&str] = &[
|
||||
"**/DawnGraphiteCache/**",
|
||||
"**/DawnWebGPUCache/**",
|
||||
"**/GraphiteDawnCache/**",
|
||||
"**/Service Worker/CacheStorage/**",
|
||||
"**/Service Worker/ScriptCache/**",
|
||||
"**/blob_storage/**",
|
||||
"**/Crashpad/**",
|
||||
"**/Crash Reports/**",
|
||||
|
||||
@@ -338,6 +338,8 @@ fn the_sealed_exclusions_keep_what_a_database_needs() {
|
||||
"Default/History-wal",
|
||||
"Default/Secure Preferences",
|
||||
"Default/Session Storage/000003.log",
|
||||
"Default/Service Worker/ScriptCache/index",
|
||||
"Default/Service Worker/CacheStorage/index.txt",
|
||||
"Local State",
|
||||
"os_crypt_key",
|
||||
] {
|
||||
|
||||
@@ -141,7 +141,9 @@ fn collect_files(dir: &Path) -> Result<Vec<(String, PathBuf)>, String> {
|
||||
|
||||
/// Whether a path inside the profile directory is left out of an export.
|
||||
pub fn is_excluded(relative: &str) -> bool {
|
||||
const CACHE_SEGMENTS: [&str; 10] = [
|
||||
// `Service Worker/CacheStorage` and `Service Worker/ScriptCache` are site
|
||||
// data: without the script, Chromium drops the service worker registration.
|
||||
const CACHE_SEGMENTS: [&str; 8] = [
|
||||
"Cache",
|
||||
"Code Cache",
|
||||
"GPUCache",
|
||||
@@ -150,8 +152,6 @@ pub fn is_excluded(relative: &str) -> bool {
|
||||
"DawnCache",
|
||||
"DawnGraphiteCache",
|
||||
"GraphiteDawnCache",
|
||||
"CacheStorage",
|
||||
"ScriptCache",
|
||||
];
|
||||
const LAUNCH_FILES: [&str; 4] = [
|
||||
"wayfern-identity.json",
|
||||
|
||||
@@ -44,15 +44,14 @@ const RESTORED_SUFFIX: &str = "(restored)";
|
||||
|
||||
/// Chromium directories that only ever hold caches, relative to the profile
|
||||
/// data directory. Every one of them is recreated by the browser on demand.
|
||||
const CACHE_DIRS: [&str; 8] = [
|
||||
/// Service worker scripts and Cache Storage are site data and stay.
|
||||
const CACHE_DIRS: [&str; 6] = [
|
||||
"Cache",
|
||||
"Code Cache",
|
||||
"GPUCache",
|
||||
"GrShaderCache",
|
||||
"ShaderCache",
|
||||
"DawnCache",
|
||||
"Service Worker/CacheStorage",
|
||||
"Service Worker/ScriptCache",
|
||||
];
|
||||
|
||||
/// Serialises every trash mutation so a restore cannot interleave with a
|
||||
|
||||
@@ -137,7 +137,9 @@ impl CryptoKey {
|
||||
/// This key, then, for AES-128-CBC, the fixed keys Chromium retries a CBC
|
||||
/// record with when it does not open: the empty password
|
||||
/// ([`EMPTY_PASSWORD_KEY`]) and Linux's fixed password
|
||||
/// ([`POSIX_FALLBACK_KEY`]). An AES-256-GCM key has no fallbacks.
|
||||
/// ([`POSIX_FALLBACK_KEY`]). A raw key file's `v10` records can also be
|
||||
/// AES-128-CBC, keyed by PBKDF2(the 32 bytes, "saltysalt", 1003) or by
|
||||
/// Linux's fixed password, so an AES-256-GCM key falls back to those two.
|
||||
pub fn with_fallbacks(&self) -> Vec<CryptoKey> {
|
||||
match self {
|
||||
Self::Aes128Cbc(_) => vec![
|
||||
@@ -145,7 +147,11 @@ impl CryptoKey {
|
||||
Self::Aes128Cbc(EMPTY_PASSWORD_KEY),
|
||||
Self::Aes128Cbc(POSIX_FALLBACK_KEY),
|
||||
],
|
||||
Self::Aes256Gcm(_) => vec![self.clone()],
|
||||
Self::Aes256Gcm(raw) => vec![
|
||||
self.clone(),
|
||||
Self::Aes128Cbc(derive_key(raw, MAC_ITERATIONS)),
|
||||
Self::Aes128Cbc(POSIX_FALLBACK_KEY),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ fn the_key_file_format_and_the_tag_choose_the_key_on_every_host() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_cbc_keys_carry_chromiums_fallbacks() {
|
||||
fn every_key_carries_chromiums_fallbacks() {
|
||||
let cbc = CryptoKey::Aes128Cbc([1u8; 16]);
|
||||
let keys: Vec<Vec<u8>> = cbc.with_fallbacks().iter().map(key_bytes).collect();
|
||||
assert_eq!(
|
||||
@@ -70,7 +70,22 @@ fn only_cbc_keys_carry_chromiums_fallbacks() {
|
||||
POSIX_FALLBACK_KEY.to_vec()
|
||||
]
|
||||
);
|
||||
assert_eq!(CryptoKey::Aes256Gcm([1u8; 32]).with_fallbacks().len(), 1);
|
||||
// A raw key file's v10 records can be AES-128-CBC under PBKDF2 of the raw
|
||||
// bytes, or under Linux's fixed password.
|
||||
let raw = [1u8; 32];
|
||||
let keys: Vec<Vec<u8>> = CryptoKey::Aes256Gcm(raw)
|
||||
.with_fallbacks()
|
||||
.iter()
|
||||
.map(key_bytes)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
vec![
|
||||
raw.to_vec(),
|
||||
derive_key(&raw, MAC_ITERATIONS).to_vec(),
|
||||
POSIX_FALLBACK_KEY.to_vec()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,6 +12,13 @@ use super::types::{SyncError, SyncResult};
|
||||
/// Default exclude patterns for volatile browser profile files.
|
||||
/// Patterns use `**/` prefix to match at any directory depth, since the sync
|
||||
/// engine scans from `profiles/{uuid}/` which contains `profile/Default/...`.
|
||||
///
|
||||
/// `Service Worker/ScriptCache` and `Service Worker/CacheStorage` are site
|
||||
/// data, not caches: Chromium drops a service worker registration whose script
|
||||
/// is missing, which loses the site's push subscription and offline data, and
|
||||
/// an extension's background worker fails its first start. `Secure
|
||||
/// Preferences` holds the extension, homepage and startup settings of profiles
|
||||
/// written by browsers that kept tracked preferences there.
|
||||
pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
|
||||
"**/Cache/**",
|
||||
"**/Code Cache/**",
|
||||
@@ -20,8 +27,6 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
|
||||
"**/ShaderCache/**",
|
||||
"**/DawnCache/**",
|
||||
"**/DawnGraphiteCache/**",
|
||||
"**/Service Worker/CacheStorage/**",
|
||||
"**/Service Worker/ScriptCache/**",
|
||||
"**/Session Storage/**",
|
||||
"**/blob_storage/**",
|
||||
"**/Crashpad/**",
|
||||
@@ -61,9 +66,9 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
|
||||
"**/wayfern-persona.json",
|
||||
"**/SingletonSocket",
|
||||
"**/SingletonCookie",
|
||||
"**/Secure Preferences",
|
||||
"**/GraphiteDawnCache/**",
|
||||
"**/DawnWebGPUCache/**",
|
||||
"**/GPUPersistentCache/**",
|
||||
"**/BrowserMetrics*",
|
||||
"**/.DS_Store",
|
||||
// Profile metadata is a separately reconciled LWW config object. Including
|
||||
@@ -680,6 +685,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_manifest_keeps_site_data_and_secure_preferences() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let profile_dir = temp_dir.path().join("profile_root");
|
||||
let default_dir = profile_dir.join("profile/Default");
|
||||
for relative in [
|
||||
"Service Worker/Database/000001.log",
|
||||
"Service Worker/ScriptCache/index",
|
||||
"Service Worker/CacheStorage/index.txt",
|
||||
"Secure Preferences",
|
||||
] {
|
||||
let path = default_dir.join(relative);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(&path, "keep").unwrap();
|
||||
}
|
||||
|
||||
let mut cache = HashCache::default();
|
||||
let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap();
|
||||
|
||||
let paths: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect();
|
||||
for kept in [
|
||||
"profile/Default/Service Worker/Database/000001.log",
|
||||
"profile/Default/Service Worker/ScriptCache/index",
|
||||
"profile/Default/Service Worker/CacheStorage/index.txt",
|
||||
"profile/Default/Secure Preferences",
|
||||
] {
|
||||
assert!(paths.contains(&kept), "{kept} must sync: {paths:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_manifest_excludes_sqlite_shm_sidecars() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -156,6 +156,7 @@ pub fn converted_profile(
|
||||
let config = updated
|
||||
.wayfern_config
|
||||
.get_or_insert_with(WayfernConfig::default);
|
||||
keep_persona(&profile.id, config, identity_id);
|
||||
config.identity_id = Some(identity_id.to_string());
|
||||
config.os = Some(os.to_string());
|
||||
config.identity_overrides = if overrides.is_empty() {
|
||||
@@ -176,6 +177,18 @@ pub fn converted_profile(
|
||||
Ok(updated)
|
||||
}
|
||||
|
||||
/// Keeps the person a profile presents when its identity changes: the
|
||||
/// persona is derived from the identity id, or from the profile id when there
|
||||
/// is none.
|
||||
fn keep_persona(profile_id: &uuid::Uuid, config: &mut WayfernConfig, identity_id: &str) {
|
||||
let seed = stored_identity_id(config)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| profile_id.to_string());
|
||||
if seed != identity_id.trim() {
|
||||
config.persona = crate::wayfern_persona::pinned_edits(&seed, config.persona.as_deref());
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn read_profile(id: &uuid::Uuid) -> Option<BrowserProfile> {
|
||||
let path = ProfileManager::instance()
|
||||
.get_profiles_dir()
|
||||
@@ -321,6 +334,7 @@ fn profile_with_result(profile: &BrowserProfile, result: &LaunchResult) -> Optio
|
||||
let config = updated
|
||||
.wayfern_config
|
||||
.get_or_insert_with(WayfernConfig::default);
|
||||
keep_persona(&profile.id, config, &identity_id);
|
||||
config.identity_id = Some(identity_id);
|
||||
config.os = Some(os.to_string());
|
||||
config.identity_overrides = text("identity_overrides");
|
||||
@@ -1225,7 +1239,17 @@ mod tests {
|
||||
config.location, sent.location,
|
||||
"an absent location keeps the stored one"
|
||||
);
|
||||
assert_eq!(config.persona.as_deref(), Some("p"));
|
||||
assert_eq!(
|
||||
crate::wayfern_persona::with_edits(
|
||||
CURRENT_ID,
|
||||
&serde_json::from_str::<Vec<crate::wayfern_persona::PersonaField>>(
|
||||
config.persona.as_deref().unwrap()
|
||||
)
|
||||
.unwrap()
|
||||
),
|
||||
crate::wayfern_persona::with_edits(&profile.id.to_string(), &[]),
|
||||
"the converted profile presents the person it presented before"
|
||||
);
|
||||
assert!(config.proxy.is_none());
|
||||
|
||||
let backup = crate::app_dirs::data_subdir()
|
||||
|
||||
@@ -1000,11 +1000,14 @@ impl BrowserLogTap {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the browser ran without the profile's stored edits because the
|
||||
/// plan could not be checked. A plan that does not include them is a settled
|
||||
/// answer and the launch goes on without them.
|
||||
pub fn dropped_overrides(&self) -> bool {
|
||||
self
|
||||
.lines()
|
||||
.iter()
|
||||
.any(|line| line.contains("Wayfern launch identity: dropping"))
|
||||
self.lines().iter().any(|line| {
|
||||
line.contains("Wayfern launch identity: dropping")
|
||||
&& (line.contains("temporarily unavailable") || line.contains("not known yet"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the browser reported the launch identity as applied.
|
||||
@@ -5753,6 +5756,22 @@ mod tests {
|
||||
);
|
||||
assert!(tap.dropped_overrides());
|
||||
assert!(tap.identity_refusal().is_none());
|
||||
|
||||
let settled = BrowserLogTap::default();
|
||||
settled.push(
|
||||
"[WARNING] Wayfern launch identity: dropping 3 override(s); this plan does not \
|
||||
include custom fingerprints. The identity is applied as derived."
|
||||
.to_string(),
|
||||
);
|
||||
assert!(!settled.dropped_overrides());
|
||||
|
||||
let unknown = BrowserLogTap::default();
|
||||
unknown.push(
|
||||
"[WARNING] Wayfern launch identity: dropping the stored profile's custom values \
|
||||
for this launch; the plan is not known yet. The identity is applied as derived."
|
||||
.to_string(),
|
||||
);
|
||||
assert!(unknown.dropped_overrides());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -242,6 +242,24 @@ pub fn with_edits(seed: &str, edits: &[PersonaField]) -> Vec<PersonaField> {
|
||||
fields
|
||||
}
|
||||
|
||||
/// The stored edits that keep the person `seed` gives today once the profile
|
||||
/// derives its persona from another seed: every field as it reads now, and a
|
||||
/// blank edit for each derived field the user removed.
|
||||
pub fn pinned_edits(seed: &str, edits: Option<&str>) -> Option<String> {
|
||||
let edits: Vec<PersonaField> = edits
|
||||
.map(str::trim)
|
||||
.filter(|edits| !edits.is_empty())
|
||||
.and_then(|edits| serde_json::from_str(edits).ok())
|
||||
.unwrap_or_default();
|
||||
let mut pinned = with_edits(seed, &edits);
|
||||
for id in FIELD_IDS {
|
||||
if !pinned.iter().any(|field| field.id == id) {
|
||||
pinned.push(field(id, "", String::new()));
|
||||
}
|
||||
}
|
||||
serde_json::to_string(&pinned).ok()
|
||||
}
|
||||
|
||||
fn truncate_chars(text: &mut String, limit: usize) {
|
||||
if text.chars().count() > limit {
|
||||
*text = text.chars().take(limit).collect();
|
||||
|
||||
Reference in new issue
Block a user