diff --git a/src/openhuman/credentials/profiles.rs b/src/openhuman/credentials/profiles.rs index b5a7d77e5..efea98c1b 100644 --- a/src/openhuman/credentials/profiles.rs +++ b/src/openhuman/credentials/profiles.rs @@ -373,13 +373,23 @@ impl AuthProfilesStore { return Ok(PersistedAuthProfiles::default()); } - let mut persisted: PersistedAuthProfiles = - serde_json::from_slice(&bytes).with_context(|| { - format!( - "Failed to parse auth profile store at {}", - self.path.display() - ) - })?; + let mut persisted: PersistedAuthProfiles = match serde_json::from_slice(&bytes) { + Ok(p) => p, + Err(err) => { + let quarantined = quarantine_corrupt_store(&self.path)?; + let quarantined_file = quarantined + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("auth-profiles.corrupt"); + tracing::warn!( + path_file = PROFILES_FILENAME, + quarantined_file = quarantined_file, + error = %err, + "[credentials] auth profile store unparseable; quarantined and reset to empty" + ); + return Ok(PersistedAuthProfiles::default()); + } + }; if persisted.schema_version == 0 { persisted.schema_version = CURRENT_SCHEMA_VERSION; @@ -598,6 +608,33 @@ pub fn profile_id(provider: &str, profile_name: &str) -> String { format!("{}:{}", provider.trim(), profile_name.trim()) } +fn quarantine_corrupt_store(path: &Path) -> Result { + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("auth-profiles"); + let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("json"); + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let mut candidate = parent.join(format!("{stem}.corrupt-{ts}.{ext}")); + let mut suffix = 0u32; + while candidate.exists() { + suffix += 1; + candidate = parent.join(format!("{stem}.corrupt-{ts}-{suffix}.{ext}")); + } + fs::rename(path, &candidate).with_context(|| { + format!( + "Failed to quarantine corrupt auth profile store {} -> {}", + path.display(), + candidate.display() + ) + })?; + Ok(candidate) +} + #[cfg(test)] #[path = "profiles_tests.rs"] mod tests; diff --git a/src/openhuman/credentials/profiles_tests.rs b/src/openhuman/credentials/profiles_tests.rs index 8ccf781e6..9add44a1a 100644 --- a/src/openhuman/credentials/profiles_tests.rs +++ b/src/openhuman/credentials/profiles_tests.rs @@ -127,6 +127,33 @@ fn auth_profiles_data_default() { assert!(data.active_profiles.is_empty()); } +#[test] +fn corrupt_store_is_quarantined_and_reset() { + let tmp = TempDir::new().unwrap(); + let store = AuthProfilesStore::new(tmp.path(), false); + let path = store.path().to_path_buf(); + + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, b"{ not valid json").unwrap(); + + let data = store.load().unwrap(); + assert!(data.profiles.is_empty()); + assert_eq!(data.schema_version, CURRENT_SCHEMA_VERSION); + + let parent = path.parent().unwrap(); + let quarantined: Vec<_> = std::fs::read_dir(parent) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().contains(".corrupt-")) + .collect(); + assert_eq!(quarantined.len(), 1, "expected one quarantined file"); + + let profile = AuthProfile::new_token("openai", "default", "tok".into()); + store.upsert_profile(profile, true).unwrap(); + let reloaded = store.load().unwrap(); + assert_eq!(reloaded.profiles.len(), 1); +} + #[test] fn remove_nonexistent_profile_returns_false() { let tmp = TempDir::new().unwrap();