fix: profile data and launch

This commit is contained in:
zhom committed 2026-10-18 15:00:55 +04:00
1 parent e9e86edcc3
commit df928fb12e
11 files changed
+153 -22

No files matched your search

+15 -1
View File
@@ -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);
}
+2 -3
View File
@@ -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",
] {
+3 -3
View File
@@ -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",
+2 -3
View File
@@ -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
+8 -2
View File
@@ -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),
],
}
}
+17 -2
View File
@@ -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]
+38 -3
View File
@@ -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();
+25 -1
View File
@@ -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()
+23 -4
View File
@@ -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]
+18
View File
@@ -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();