Simpl-Resultat/src-tauri/src/commands/profile_commands.rs
le king fu 419b20e769
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 2m1s
PR Check — Rust / rust (pull_request) Successful in 3m56s
feat(profiles): name the copy of a stopped catch-up and keep one copy per profile
- ProfilePreparation.catch_up_backup carries the file name of the copy as
  soon as it exists, so the Journal error line of a catch-up that stops
  afterwards (history_write_failed, pragma_failed, apply_pending) names it.
- Error lines and the JS fallback message keep only the last segment of a
  path (withoutFolders): no error line contains a slash or a backslash.
- The copy is taken again at every catch-up, written as .tmp, synced and
  renamed over a same-name copy; after a successful catch-up only, the
  other copies of that profile are pruned (remove_backups keep argument).
- Pins: a pre-existing foreign key violation in the profile without
  category 31 (RV4, RV7) and the Journal line that reports it (RT2).
- Ignored test measuring prepare_at on 100 000 transactions at floor 6:
  306 ms in release, so STARTUP_TIMEOUT_MS stays 10 s (ADR 0021).
- Guide, docs.profiles.tips, ADR 0021, architecture and changelogs.

Resolves #395

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:18:18 -04:00

535 lines
19 KiB
Rust

use argon2::{Algorithm, Argon2, Params, Version};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256, Sha384};
use std::fs;
use std::path::{Path, PathBuf};
use subtle::ConstantTimeEq;
use tauri::Manager;
use crate::database;
use crate::database::profile_db;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Profile {
pub id: String,
pub name: String,
pub color: String,
pub pin_hash: Option<String>,
pub db_filename: String,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfilesConfig {
pub active_profile_id: String,
pub profiles: Vec<Profile>,
}
fn get_profiles_path(app: &tauri::AppHandle) -> Result<std::path::PathBuf, String> {
let app_dir = app
.path()
.app_data_dir()
.map_err(|e| format!("Cannot get app data dir: {}", e))?;
Ok(app_dir.join("profiles.json"))
}
/// Folder where tauri-plugin-sql keeps every profile database.
///
/// The plugin opens `sqlite:<file>` under `app_config_dir()` (tauri-plugin-sql
/// 2.3.2, `wrapper.rs`: `DbPool::connect` then `path_mapper`), not under
/// `app_data_dir()`, where `profiles.json` and the licence files live. Both
/// are the same folder on Windows and macOS, not on Linux (`~/.config/<id>`
/// and `~/.local/share/<id>`): until #390, every command that looked for a
/// profile database under `app_data_dir()` found nothing on Linux.
pub(crate) fn profile_db_dir<R: tauri::Runtime>(app: &tauri::AppHandle<R>) -> Result<PathBuf, String> {
app.path()
.app_config_dir()
.map_err(|e| format!("Cannot get app config dir: {}", e))
}
/// Path of a profile database, from the bare file name the webview passes.
pub(crate) fn profile_db_path<R: tauri::Runtime>(
app: &tauri::AppHandle<R>,
db_filename: &str,
) -> Result<PathBuf, String> {
profile_db_path_in(&profile_db_dir(app)?, db_filename)
}
/// Accept only the two shapes the app ever gives a profile database:
/// `simpl_resultat.db` and `profile_<hex>.db` (ProfileContext, since v0.3.0).
/// `Path::join` with an absolute value would replace the folder, and these
/// commands write and delete.
pub(crate) fn profile_db_path_in(dir: &Path, db_filename: &str) -> Result<PathBuf, String> {
if !is_profile_db_filename(db_filename) {
return Err("invalid_profile_db_filename".to_string());
}
Ok(dir.join(db_filename))
}
fn is_profile_db_filename(name: &str) -> bool {
if name == database::DEFAULT_PROFILE_DB {
return true;
}
match name.strip_prefix("profile_").and_then(|rest| rest.strip_suffix(".db")) {
Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_hexdigit()),
None => false,
}
}
fn make_default_config() -> ProfilesConfig {
let now = chrono_now();
let default_id = "default".to_string();
ProfilesConfig {
active_profile_id: default_id.clone(),
profiles: vec![Profile {
id: default_id,
name: "Default".to_string(),
color: "#4A90A4".to_string(),
pin_hash: None,
db_filename: "simpl_resultat.db".to_string(),
created_at: now,
}],
}
}
fn chrono_now() -> String {
// Simple ISO-ish timestamp without pulling in chrono crate
let dur = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let secs = dur.as_secs();
// Return as unix timestamp string — frontend can format it
secs.to_string()
}
#[tauri::command]
pub fn load_profiles(app: tauri::AppHandle) -> Result<ProfilesConfig, String> {
let path = get_profiles_path(&app)?;
if !path.exists() {
let config = make_default_config();
let json =
serde_json::to_string_pretty(&config).map_err(|e| format!("JSON error: {}", e))?;
// Ensure parent dir exists
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Cannot create app data dir: {}", e))?;
}
fs::write(&path, json).map_err(|e| format!("Cannot write profiles.json: {}", e))?;
return Ok(config);
}
let content =
fs::read_to_string(&path).map_err(|e| format!("Cannot read profiles.json: {}", e))?;
let config: ProfilesConfig =
serde_json::from_str(&content).map_err(|e| format!("Invalid profiles.json: {}", e))?;
Ok(config)
}
#[tauri::command]
pub fn save_profiles(app: tauri::AppHandle, config: ProfilesConfig) -> Result<(), String> {
let path = get_profiles_path(&app)?;
let json =
serde_json::to_string_pretty(&config).map_err(|e| format!("JSON error: {}", e))?;
fs::write(&path, json).map_err(|e| format!("Cannot write profiles.json: {}", e))
}
/// Delete a secondary profile's database, with its SQLite side files and the
/// copies a catch-up took (#390): the guide promises that deleting a profile
/// deletes all its data.
#[tauri::command]
pub fn delete_profile_db(app: tauri::AppHandle, db_filename: String) -> Result<(), String> {
if db_filename == database::DEFAULT_PROFILE_DB {
return Err("Cannot delete the default profile database".to_string());
}
let db_path = profile_db_path(&app, &db_filename)?;
delete_profile_files(&db_path, &db_filename)
}
fn delete_profile_files(db_path: &Path, db_filename: &str) -> Result<(), String> {
for suffix in ["", "-wal", "-shm", "-journal"] {
let path = db_path.with_file_name(format!("{}{}", db_filename, suffix));
if path.exists() {
fs::remove_file(&path).map_err(|e| format!("Cannot delete database file: {}", e))?;
}
}
if let Some(dir) = db_path.parent() {
profile_db::remove_backups(
&dir.join(profile_db::MIGRATION_BACKUP_DIR),
db_filename.trim_end_matches(".db"),
None,
)?;
}
Ok(())
}
/// SQL that creates a secondary profile, run by the webview through the
/// plugin (`initializeNewProfileDb`).
#[tauri::command]
pub fn get_new_profile_init_sql() -> Result<Vec<String>, String> {
// The consolidated schema is the whole database: tauri-plugin-sql runs no
// migration for a `profile_<id>.db`, only the default profile's URL is
// registered (lib.rs). It carries the v1 IPC-aligned taxonomy (categories,
// keywords, categories_schema_version='v1'). The second statement writes
// the complete migration history, so later versions reach the profile
// through `prepare_profile_db` without inferring anything (#390).
Ok(vec![
database::CONSOLIDATED_SCHEMA.to_string(),
profile_db::history_sql(&crate::migrations()),
])
}
/// Bring a profile database up to date and repair legacy values before the
/// webview opens it (#390, ADR 0021). Secondary profiles catch up on the
/// migrations they missed; every profile gets its text manual-categorization
/// flags repaired. Sync on purpose: Tauri runs sync commands on the main
/// thread, which serializes two opens of the same profile (React StrictMode
/// mounts twice in development).
#[tauri::command]
pub fn prepare_profile_db(
app: tauri::AppHandle,
db_filename: String,
) -> Result<profile_db::ProfilePreparation, String> {
let db_path = profile_db_path(&app, &db_filename)?;
profile_db::prepare_at(&db_path, &db_filename, &crate::migrations())
}
// Argon2id parameters for PIN hashing (same as export_import_commands.rs)
const ARGON2_M_COST: u32 = 65536; // 64 MiB
const ARGON2_T_COST: u32 = 3;
const ARGON2_P_COST: u32 = 1;
const ARGON2_OUTPUT_LEN: usize = 32;
const ARGON2_SALT_LEN: usize = 16;
fn argon2_hash(pin: &str, salt: &[u8]) -> Result<Vec<u8>, String> {
let params = Params::new(ARGON2_M_COST, ARGON2_T_COST, ARGON2_P_COST, Some(ARGON2_OUTPUT_LEN))
.map_err(|e| format!("Argon2 params error: {}", e))?;
let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut hash = vec![0u8; ARGON2_OUTPUT_LEN];
argon2
.hash_password_into(pin.as_bytes(), salt, &mut hash)
.map_err(|e| format!("Argon2 hash error: {}", e))?;
Ok(hash)
}
#[tauri::command]
pub fn hash_pin(pin: String) -> Result<String, String> {
let mut salt = [0u8; ARGON2_SALT_LEN];
rand::rngs::OsRng.fill_bytes(&mut salt);
let salt_hex = hex_encode(&salt);
let hash = argon2_hash(&pin, &salt)?;
let hash_hex = hex_encode(&hash);
// Store as "argon2id:salt:hash" to distinguish from legacy SHA-256 "salt:hash"
Ok(format!("argon2id:{}:{}", salt_hex, hash_hex))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VerifyPinResult {
pub valid: bool,
/// New Argon2id hash when a legacy SHA-256 PIN was successfully verified and re-hashed
pub rehashed: Option<String>,
}
#[tauri::command]
pub fn verify_pin(pin: String, stored_hash: String) -> Result<VerifyPinResult, String> {
// Argon2id format: "argon2id:salt_hex:hash_hex"
if let Some(rest) = stored_hash.strip_prefix("argon2id:") {
let parts: Vec<&str> = rest.split(':').collect();
if parts.len() != 2 {
return Err("Invalid Argon2id hash format".to_string());
}
let salt = hex_decode(parts[0])?;
let expected_hash = hex_decode(parts[1])?;
let computed = argon2_hash(&pin, &salt)?;
let valid = computed.ct_eq(&expected_hash).into();
return Ok(VerifyPinResult { valid, rehashed: None });
}
// Legacy SHA-256 format: "salt_hex:hash_hex"
let parts: Vec<&str> = stored_hash.split(':').collect();
if parts.len() != 2 {
return Err("Invalid stored hash format".to_string());
}
let salt_hex = parts[0];
let expected_hash = hex_decode(parts[1])?;
let mut hasher = Sha256::new();
hasher.update(salt_hex.as_bytes());
hasher.update(pin.as_bytes());
let result = hasher.finalize();
let valid: bool = result.as_slice().ct_eq(&expected_hash).into();
if valid {
// Re-hash with Argon2id so this legacy PIN is upgraded.
// If rehash fails, still allow login — don't block the user.
let rehashed = hash_pin(pin).ok();
Ok(VerifyPinResult { valid: true, rehashed })
} else {
Ok(VerifyPinResult { valid: false, rehashed: None })
}
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
fn hex_decode(hex: &str) -> Result<Vec<u8>, String> {
if !hex.len().is_multiple_of(2) {
return Err("Invalid hex string length".to_string());
}
(0..hex.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&hex[i..i + 2], 16)
.map_err(|e| format!("Invalid hex character: {}", e))
})
.collect()
}
/// Repair migration checksums for a profile database.
/// Updates stored checksums to match current migration SQL, avoiding re-application
/// of destructive migrations (e.g., migration 2 which DELETEs categories/keywords).
#[tauri::command]
pub fn repair_migrations(app: tauri::AppHandle, db_filename: String) -> Result<bool, String> {
let db_path = profile_db_path(&app, &db_filename)?;
if !db_path.exists() {
return Ok(false);
}
let conn = rusqlite::Connection::open(&db_path)
.map_err(|e| format!("Cannot open database: {}", e))?;
let table_exists: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='_sqlx_migrations'",
[],
|row| row.get(0),
)
.unwrap_or(false);
if !table_exists {
return Ok(false);
}
// Current migration SQL — must match the vec in lib.rs
let migrations: &[(i64, &str)] = &[
(1, database::SCHEMA),
(2, database::SEED_CATEGORIES),
];
let mut repaired = false;
for (version, sql) in migrations {
let expected_checksum = Sha384::digest(sql.as_bytes()).to_vec();
// Check if this migration exists with a different checksum
let needs_repair: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM _sqlx_migrations WHERE version = ?1 AND checksum != ?2",
rusqlite::params![version, expected_checksum],
|row| row.get(0),
)
.unwrap_or(false);
if needs_repair {
conn.execute(
"UPDATE _sqlx_migrations SET checksum = ?1 WHERE version = ?2",
rusqlite::params![expected_checksum, version],
)
.map_err(|e| format!("Cannot repair migration {}: {}", version, e))?;
repaired = true;
}
}
Ok(repaired)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hash_pin_produces_argon2id_format() {
let hash = hash_pin("1234".to_string()).unwrap();
assert!(hash.starts_with("argon2id:"), "Hash should start with 'argon2id:' prefix");
let parts: Vec<&str> = hash.split(':').collect();
assert_eq!(parts.len(), 3, "Hash should have 3 parts: prefix:salt:hash");
assert_eq!(parts[1].len(), ARGON2_SALT_LEN * 2, "Salt should be {} hex chars", ARGON2_SALT_LEN * 2);
assert_eq!(parts[2].len(), ARGON2_OUTPUT_LEN * 2, "Hash should be {} hex chars", ARGON2_OUTPUT_LEN * 2);
}
#[test]
fn test_hash_pin_different_salts() {
let h1 = hash_pin("1234".to_string()).unwrap();
let h2 = hash_pin("1234".to_string()).unwrap();
assert_ne!(h1, h2, "Two hashes of the same PIN should use different salts");
}
#[test]
fn test_verify_argon2id_pin_correct() {
let hash = hash_pin("5678".to_string()).unwrap();
let result = verify_pin("5678".to_string(), hash).unwrap();
assert!(result.valid, "Correct PIN should verify");
assert!(result.rehashed.is_none(), "Argon2id PIN should not be rehashed");
}
#[test]
fn test_verify_argon2id_pin_wrong() {
let hash = hash_pin("5678".to_string()).unwrap();
let result = verify_pin("0000".to_string(), hash).unwrap();
assert!(!result.valid, "Wrong PIN should not verify");
assert!(result.rehashed.is_none());
}
#[test]
fn test_verify_legacy_sha256_correct_and_rehash() {
// Create a legacy SHA-256 hash: "salt_hex:sha256(salt_hex + pin)"
let salt_hex = "abcdef0123456789";
let mut hasher = Sha256::new();
hasher.update(salt_hex.as_bytes());
hasher.update(b"4321");
let hash_bytes = hasher.finalize();
let hash_hex = hex_encode(&hash_bytes);
let stored = format!("{}:{}", salt_hex, hash_hex);
let result = verify_pin("4321".to_string(), stored).unwrap();
assert!(result.valid, "Correct legacy PIN should verify");
assert!(result.rehashed.is_some(), "Legacy PIN should be rehashed to Argon2id");
// Verify the rehashed value is a valid Argon2id hash
let new_hash = result.rehashed.unwrap();
assert!(new_hash.starts_with("argon2id:"));
// Verify the rehashed value works for future verification
let result2 = verify_pin("4321".to_string(), new_hash).unwrap();
assert!(result2.valid, "Rehashed PIN should verify");
assert!(result2.rehashed.is_none(), "Already Argon2id, no rehash needed");
}
#[test]
fn test_verify_legacy_sha256_wrong() {
let salt_hex = "abcdef0123456789";
let mut hasher = Sha256::new();
hasher.update(salt_hex.as_bytes());
hasher.update(b"4321");
let hash_bytes = hasher.finalize();
let hash_hex = hex_encode(&hash_bytes);
let stored = format!("{}:{}", salt_hex, hash_hex);
let result = verify_pin("9999".to_string(), stored).unwrap();
assert!(!result.valid, "Wrong legacy PIN should not verify");
assert!(result.rehashed.is_none(), "Failed verification should not rehash");
}
#[test]
fn test_verify_invalid_format() {
let result = verify_pin("1234".to_string(), "invalid".to_string());
assert!(result.is_err(), "Single-part hash should fail");
let result = verify_pin("1234".to_string(), "argon2id:bad".to_string());
assert!(result.is_err(), "Argon2id with wrong part count should fail");
}
#[test]
fn test_hex_roundtrip() {
let original = vec![0u8, 127, 255, 1, 16];
let encoded = hex_encode(&original);
let decoded = hex_decode(&encoded).unwrap();
assert_eq!(original, decoded);
}
// -- profile database location (#390) ------------------------------------
#[test]
fn profile_databases_live_where_the_sql_plugin_opens_them() {
// tauri-plugin-sql resolves `sqlite:<file>` under app_config_dir().
// On Linux (the CI platform) that differs from app_data_dir(), so
// going back to the data folder turns this red.
let app = tauri::test::mock_app();
let path = profile_db_path(app.handle(), "profile_0a1b2c3d.db").unwrap();
assert_eq!(path.parent(), Some(app.path().app_config_dir().unwrap().as_path()));
}
#[test]
fn profile_db_path_accepts_the_two_shapes_the_app_uses() {
let dir = Path::new("/profiles");
assert_eq!(
profile_db_path_in(dir, "simpl_resultat.db"),
Ok(dir.join("simpl_resultat.db"))
);
assert_eq!(
profile_db_path_in(dir, "profile_0a1b2c3d.db"),
Ok(dir.join("profile_0a1b2c3d.db"))
);
}
#[test]
fn profile_db_path_refuses_anything_else() {
let dir = Path::new("/profiles");
for name in [
"",
"/etc/passwd",
"../simpl_resultat.db",
"profile_.db",
"profile_0a1b/../x.db",
"profile_0a1b2c3d.sqlite",
"profile_0a1b2c3d.db.bak",
"PROFILE_0a1b2c3d.db",
"profile_xyz.db",
"C:\\Users\\x\\profile_0a1b2c3d.db",
] {
assert_eq!(
profile_db_path_in(dir, name),
Err("invalid_profile_db_filename".to_string()),
"{:?}",
name
);
}
}
#[test]
fn deleting_a_profile_removes_its_files_and_only_its_files() {
let dir = profile_db::TestDir::new("delete");
let backups = dir.join(profile_db::MIGRATION_BACKUP_DIR);
fs::create_dir_all(&backups).unwrap();
for name in [
"profile_0a1b2c3d.db",
// The only side file this app produces: sqlx leaves SQLite's
// rollback journal by default, `-wal`/`-shm` come with WAL.
"profile_0a1b2c3d.db-journal",
"profile_0a1b2c3d.db-wal",
"profile_0a1b2c3d.db-shm",
"profile_99999999.db",
] {
fs::write(dir.join(name), b"x").unwrap();
}
for name in ["profile_0a1b2c3d-before-v7.db", "profile_99999999-before-v17.db"] {
fs::write(backups.join(name), b"x").unwrap();
}
delete_profile_files(&dir.join("profile_0a1b2c3d.db"), "profile_0a1b2c3d.db").unwrap();
let mut left: Vec<String> = fs::read_dir(&dir)
.unwrap()
.chain(fs::read_dir(&backups).unwrap())
.map(|e| e.unwrap().file_name().into_string().unwrap())
.collect();
left.sort();
assert_eq!(
left,
vec![
"migration-backups".to_string(),
"profile_99999999-before-v17.db".to_string(),
"profile_99999999.db".to_string(),
]
);
}
}