fix(portable): harden persistence and release paths (#15)

Preserve legacy source data when portable sanitization cannot replace it, reject malformed settings without panicking, and make portable pairing regeneration durable before UI state changes.\n\nMake the packaged smoke assertion tolerate slow WebView startup and clarify AppImage storage behavior.\n\nRefs #15
This commit is contained in:
NimBold
2026-07-13 00:09:09 +03:30
parent f441c687f0
commit dad5b7bc5e
4 changed files with 65 additions and 29 deletions
+46 -17
View File
@@ -303,26 +303,31 @@ fn sanitize_legacy_source(path: &Path) -> Result<(), String> {
return Ok(());
}
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| format!("invalid legacy store path '{}'", path.display()))?;
let temporary = path.with_file_name(format!(".{file_name}.portable-sanitized.tmp"));
fs::write(&temporary, sanitized).map_err(|error| {
let parent = path
.parent()
.ok_or_else(|| format!("legacy store path has no parent: '{}'", path.display()))?;
use std::io::Write;
let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|error| {
format!(
"failed to write temporary sanitized legacy store '{}': {error}",
temporary.display()
"failed to create temporary sanitized legacy store beside '{}': {error}",
path.display()
)
})?;
if let Err(rename_error) = fs::rename(&temporary, path) {
let _ = fs::remove_file(path);
fs::rename(&temporary, path).map_err(|error| {
temporary
.write_all(sanitized.as_bytes())
.and_then(|_| temporary.flush())
.map_err(|error| {
format!(
"failed to replace legacy store '{}' after rename error ({rename_error}): {error}",
"failed to write temporary sanitized legacy store beside '{}': {error}",
path.display()
)
})?;
}
temporary.persist(path).map_err(|error| {
format!(
"failed to replace legacy store '{}' without losing the original: {}",
path.display(), error.error
)
})?;
Ok(())
}
@@ -1071,12 +1076,17 @@ pub fn save_pairing_token_to_settings(
let state = if value.get("state").is_some() {
value
.get_mut("state")
.expect("state is an object")
.and_then(serde_json::Value::as_object_mut)
.ok_or_else(|| "persisted settings state must be an object".to_string())?
} else {
&mut value
value
.as_object_mut()
.ok_or_else(|| "persisted settings must be an object".to_string())?
};
state["extensionPairingToken"] =
serde_json::Value::String(token.to_string());
state.insert(
"extensionPairingToken".to_string(),
serde_json::Value::String(token.to_string()),
);
let updated = serde_json::to_string(&value)
.map_err(|error| format!("failed to encode settings: {error}"))?;
save_settings(connection, &updated)
@@ -1572,6 +1582,25 @@ mod tests {
assert!(!saved.to_string().contains("PORTABLE_EXISTING_QUERY_TOKEN"));
}
#[test]
fn rejects_malformed_settings_state_without_panicking() {
let temp = TempDir::new().unwrap();
let state = init_at_path(temp.path()).unwrap();
let connection = state.lock().unwrap();
save_settings(
&connection,
&json!({ "state": "corrupted", "version": 3 }).to_string(),
)
.unwrap();
let result = save_pairing_token_to_settings(&connection, "token", true);
assert_eq!(
result.unwrap_err(),
"persisted settings state must be an object"
);
}
#[test]
fn pairing_token_is_persisted_before_frontend_settings_exist() {
let temp = TempDir::new().unwrap();
+3
View File
@@ -4350,6 +4350,7 @@ fn get_free_space(app_handle: tauri::AppHandle, path: String) -> Result<String,
#[tauri::command]
fn set_keychain_password(
database: tauri::State<'_, crate::db::DbState>,
state: tauri::State<'_, AppState>,
id: String,
password: String,
@@ -4357,6 +4358,8 @@ fn set_keychain_password(
if state.storage_layout.is_portable()
&& id == crate::db::PAIRING_TOKEN_KEYCHAIN_ID
{
let connection = database.lock()?;
crate::db::save_pairing_token_to_settings(&connection, &password, true)?;
return Ok(());
}
crate::db::set_keychain_password(&id, &password)