From b0b3fe18e1d81c7ea620ac79e1e62788a22d9964 Mon Sep 17 00:00:00 2001 From: Nico Wiedemann Date: Sun, 27 Sep 2026 16:50:07 +0200 Subject: [PATCH 1/3] Format the Rust backend with rustfmt The tree had never been through rustfmt, so `cargo fmt --check` found 324 differences and failed every CI run. This is `cargo fmt` and nothing else, in its own commit so the next one's fixes stay readable and `git blame` can skip it (.git-blame-ignore-revs, added next). Co-Authored-By: Claude Opus 5 (1M context) --- src-tauri/build.rs | 5 +- src-tauri/src/account_export.rs | 32 +- src-tauri/src/contexts.rs | 24 +- src-tauri/src/db.rs | 580 ++++++++++++++++++++------------ src-tauri/src/e2ee.rs | 29 +- src-tauri/src/e2ee_enrol.rs | 21 +- src-tauri/src/e2ee_session.rs | 132 +++++--- src-tauri/src/envelope.rs | 48 ++- src-tauri/src/keychain.rs | 104 +++--- src-tauri/src/lib.rs | 120 +++---- src-tauri/src/localkey.rs | 32 +- src-tauri/src/main.rs | 2 +- src-tauri/src/models.rs | 5 +- src-tauri/src/settings.rs | 65 ++-- src-tauri/src/stashes.rs | 272 +++++++++------ src-tauri/src/state.rs | 2 +- src-tauri/src/sync.rs | 236 +++++++++---- src-tauri/src/transfer.rs | 148 ++++++-- src-tauri/src/utils.rs | 148 +++++--- 19 files changed, 1295 insertions(+), 710 deletions(-) diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 0ab3af0..1762694 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -28,10 +28,7 @@ fn main() { { let lib_path = toolchain.join("lib/swift/macosx"); if lib_path.exists() { - println!( - "cargo:rustc-link-arg=-Wl,-rpath,{}", - lib_path.display() - ); + println!("cargo:rustc-link-arg=-Wl,-rpath,{}", lib_path.display()); } } } diff --git a/src-tauri/src/account_export.rs b/src-tauri/src/account_export.rs index 35fc7c7..c319f66 100644 --- a/src-tauri/src/account_export.rs +++ b/src-tauri/src/account_export.rs @@ -118,9 +118,7 @@ pub async fn import_account_export( } /// Parse and decrypt, without touching the database. -async fn parse_export( - path: &str, -) -> Result<(Vec, Vec, usize, usize), String> { +async fn parse_export(path: &str) -> Result<(Vec, Vec, usize, usize), String> { let text = tokio::fs::read_to_string(path) .await .map_err(|e| format!("Could not read {}: {}", path, e))?; @@ -146,9 +144,15 @@ async fn parse_export( continue; } }; - let description = open(raw["description"].as_str(), &user_id, "context", &id, "description") - .ok() - .filter(|d| !d.is_empty()); + let description = open( + raw["description"].as_str(), + &user_id, + "context", + &id, + "description", + ) + .ok() + .filter(|d| !d.is_empty()); contexts.push(Context { id, @@ -229,7 +233,10 @@ fn open( } let binding = match (kind, field) { - ("stash", "content") => (crate::envelope::Kind::Stash, crate::envelope::Field::Content), + ("stash", "content") => ( + crate::envelope::Kind::Stash, + crate::envelope::Field::Content, + ), ("stash", "enhanced_content") => ( crate::envelope::Kind::Stash, crate::envelope::Field::EnhancedContent, @@ -258,7 +265,7 @@ fn open( }, ) .map_err(|e| format!("{:?}", e)) - .map_err(UiError::from) + .map_err(UiError::from) } /// Reduce a context name to something that can be a file name. @@ -309,7 +316,10 @@ pub async fn export_whole_account( let mut by_context: HashMap> = HashMap::new(); for stash in stashes { - by_context.entry(stash.context_id).or_default().push(stash.id); + by_context + .entry(stash.context_id) + .or_default() + .push(stash.id); } (contexts, by_context) }; @@ -330,8 +340,8 @@ pub async fn export_whole_account( // One archive per context, named after it. `safe_name` because a context name is // user-authored and this becomes a path. - let file = std::path::Path::new(&dest_dir) - .join(format!("{}.md", safe_file_name(&context.name))); + let file = + std::path::Path::new(&dest_dir).join(format!("{}.md", safe_file_name(&context.name))); crate::transfer::export_context_archive( state.clone(), diff --git a/src-tauri/src/contexts.rs b/src-tauri/src/contexts.rs index 151c3bd..f7676c1 100644 --- a/src-tauri/src/contexts.rs +++ b/src-tauri/src/contexts.rs @@ -11,14 +11,14 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. // See the GNU Affero General Public License for more details. -use std::sync::Arc; -use tauri::State; -use std::time::{SystemTime, UNIX_EPOCH}; -use rusqlite::params; +use crate::db::WriteOrigin; use crate::models::Context; use crate::state::DbState; -use crate::db::WriteOrigin; use crate::uierror::UiError; +use rusqlite::params; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; +use tauri::State; #[tauri::command] pub async fn get_contexts(state: State<'_, Arc>) -> Result, UiError> { @@ -36,7 +36,10 @@ pub async fn get_contexts(state: State<'_, Arc>) -> Result } #[tauri::command] -pub async fn save_contexts(state: State<'_, Arc>, contexts: Vec) -> Result<(), UiError> { +pub async fn save_contexts( + state: State<'_, Arc>, + contexts: Vec, +) -> Result<(), UiError> { println!("Saving {} contexts", contexts.len()); let mut db = state.lock_db(); let tx_result = db.conn.transaction().and_then(|tx| { @@ -85,7 +88,10 @@ pub async fn save_context(state: State<'_, Arc>, context: Context) -> R /// stamping it with the local clock here would make every pulled record look locally /// edited and push it straight back on the next sync. #[tauri::command] -pub async fn import_contexts(state: State<'_, Arc>, contexts: Vec) -> Result<(), UiError> { +pub async fn import_contexts( + state: State<'_, Arc>, + contexts: Vec, +) -> Result<(), UiError> { let mut db = state.lock_db(); // A context deleted on another device takes its stashes with it here too. let deleted = db.import_contexts(&contexts).map_err(|e| e.to_string())?; @@ -108,7 +114,9 @@ pub async fn delete_context(state: State<'_, Arc>, id: String) -> Resul /// Contexts with local changes the server has not acknowledged yet. #[tauri::command] -pub async fn claim_pending_contexts(state: State<'_, Arc>) -> Result, UiError> { +pub async fn claim_pending_contexts( + state: State<'_, Arc>, +) -> Result, UiError> { Ok(state.lock_db().claim_pending_contexts().unwrap_or_default()) } diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 7c35aa1..063de45 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -11,8 +11,8 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. // See the GNU Affero General Public License for more details. -use crate::models::{Context, StashItem, StashPosition, Attachment, ContextRule}; -use rusqlite::{params, Connection, Result, OptionalExtension}; +use crate::models::{Attachment, Context, ContextRule, StashItem, StashPosition}; +use rusqlite::{params, Connection, OptionalExtension, Result}; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; @@ -86,7 +86,7 @@ pub struct DbManager { impl DbManager { pub fn new(path: &Path) -> Result { let conn = Connection::open(path)?; - + // Enable WAL mode for better concurrency and performance conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")?; @@ -97,7 +97,8 @@ impl DbManager { pub fn prepare_shutdown(&self) -> Result<()> { // Checkpoint WAL and truncate to clean up -wal and -shm files - self.conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?; + self.conn + .execute_batch("PRAGMA wal_checkpoint(TRUNCATE);")?; Ok(()) } @@ -117,7 +118,8 @@ impl DbManager { )?; // Check/Migrate description column for contexts - let description_exists: bool = self.conn + let description_exists: bool = self + .conn .query_row( "SELECT COUNT(*) FROM pragma_table_info('contexts') WHERE name='description'", [], @@ -126,7 +128,9 @@ impl DbManager { .unwrap_or(false); if !description_exists { - let _ = self.conn.execute("ALTER TABLE contexts ADD COLUMN description TEXT", []); + let _ = self + .conn + .execute("ALTER TABLE contexts ADD COLUMN description TEXT", []); } // Stashes table @@ -146,8 +150,14 @@ impl DbManager { [], )?; - self.conn.execute("CREATE INDEX IF NOT EXISTS idx_stashes_context ON stashes(context_id)", [])?; - self.conn.execute("CREATE INDEX IF NOT EXISTS idx_stashes_position ON stashes(position)", [])?; + self.conn.execute( + "CREATE INDEX IF NOT EXISTS idx_stashes_context ON stashes(context_id)", + [], + )?; + self.conn.execute( + "CREATE INDEX IF NOT EXISTS idx_stashes_position ON stashes(position)", + [], + )?; // Attachments table self.conn.execute( @@ -166,7 +176,8 @@ impl DbManager { )?; // Check/Migrate syntax column - let syntax_exists: bool = self.conn + let syntax_exists: bool = self + .conn .query_row( "SELECT COUNT(*) FROM pragma_table_info('attachments') WHERE name='syntax'", [], @@ -175,13 +186,16 @@ impl DbManager { .unwrap_or(false); if !syntax_exists { - let _ = self.conn.execute("ALTER TABLE attachments ADD COLUMN syntax TEXT", []); + let _ = self + .conn + .execute("ALTER TABLE attachments ADD COLUMN syntax TEXT", []); } // Tracks when this attachment's bytes were successfully pushed to the cloud. // Without it every sync re-uploaded every file that ever existed, because the // upload path had no idempotency check at all. - let uploaded_at_exists: bool = self.conn + let uploaded_at_exists: bool = self + .conn .query_row( "SELECT COUNT(*) FROM pragma_table_info('attachments') WHERE name='uploaded_at'", [], @@ -190,7 +204,9 @@ impl DbManager { .unwrap_or(false); if !uploaded_at_exists { - let _ = self.conn.execute("ALTER TABLE attachments ADD COLUMN uploaded_at INTEGER", []); + let _ = self + .conn + .execute("ALTER TABLE attachments ADD COLUMN uploaded_at INTEGER", []); } // Marks a record as having local changes the server has not acknowledged yet, so @@ -246,8 +262,14 @@ impl DbManager { // been synced and no device's copy is authoritative, so nothing is pushed until // the user actually reorders something. for (column, ddl) in [ - ("position_updated_at", "ALTER TABLE stashes ADD COLUMN position_updated_at INTEGER NOT NULL DEFAULT 0"), - ("pending_position", "ALTER TABLE stashes ADD COLUMN pending_position INTEGER NOT NULL DEFAULT 0"), + ( + "position_updated_at", + "ALTER TABLE stashes ADD COLUMN position_updated_at INTEGER NOT NULL DEFAULT 0", + ), + ( + "pending_position", + "ALTER TABLE stashes ADD COLUMN pending_position INTEGER NOT NULL DEFAULT 0", + ), ] { let exists: bool = self .conn @@ -271,10 +293,14 @@ impl DbManager { [], ); - self.conn.execute("CREATE INDEX IF NOT EXISTS idx_attachments_stash_id ON attachments(stash_id)", [])?; + self.conn.execute( + "CREATE INDEX IF NOT EXISTS idx_attachments_stash_id ON attachments(stash_id)", + [], + )?; // Migrate enhanced_content column for AI enhancement feature - let enhanced_content_exists: bool = self.conn + let enhanced_content_exists: bool = self + .conn .query_row( "SELECT COUNT(*) FROM pragma_table_info('stashes') WHERE name='enhanced_content'", [], @@ -283,7 +309,9 @@ impl DbManager { .unwrap_or(false); if !enhanced_content_exists { - let _ = self.conn.execute("ALTER TABLE stashes ADD COLUMN enhanced_content TEXT", []); + let _ = self + .conn + .execute("ALTER TABLE stashes ADD COLUMN enhanced_content TEXT", []); } // Migrate potentially existing files to attachments @@ -425,7 +453,11 @@ impl DbManager { params![path, actual], ) { Ok(changed) => repaired += changed, - Err(e) => log::warn!("[Attachments] could not correct the size of {}: {}", path, e), + Err(e) => log::warn!( + "[Attachments] could not correct the size of {}: {}", + path, + e + ), } } @@ -439,7 +471,8 @@ impl DbManager { fn ensure_default_context(&self) -> Result<()> { // Check if default context exists - let exists: bool = self.conn + let exists: bool = self + .conn .query_row( "SELECT COUNT(*) FROM contexts WHERE id = 'default' AND deleted = 0", [], @@ -449,7 +482,7 @@ impl DbManager { if !exists { let now = chrono::Utc::now().to_rfc3339(); - + // Create default context with empty rules self.conn.execute( "INSERT OR REPLACE INTO contexts (id, name, rules, last_used, updated_at, deleted) VALUES ('default', 'Default', '[]', ?1, ?2, 0)", @@ -487,13 +520,15 @@ impl DbManager { fn migrate_v1_files_to_attachments(&self) -> Result<()> { // Query stashes with files - let mut stmt = self.conn.prepare("SELECT id, files, created_at FROM stashes WHERE files != '[]' AND files != ''")?; - + let mut stmt = self.conn.prepare( + "SELECT id, files, created_at FROM stashes WHERE files != '[]' AND files != ''", + )?; + let rows = stmt.query_map([], |row| { - let id: String = row.get(0)?; - let files_str: String = row.get(1)?; - let created_at: String = row.get(2)?; - Ok((id, files_str, created_at)) + let id: String = row.get(0)?; + let files_str: String = row.get(1)?; + let created_at: String = row.get(2)?; + Ok((id, files_str, created_at)) })?; let mut stashes_to_migrate = Vec::new(); @@ -507,30 +542,37 @@ impl DbManager { return Ok(()); } - println!("Migrating v1 files to attachments for {} stashes...", stashes_to_migrate.len()); - + println!( + "Migrating v1 files to attachments for {} stashes...", + stashes_to_migrate.len() + ); + // Transaction for migration for (stash_id, files_str, created_at) in stashes_to_migrate { - let files: Vec = serde_json::from_str(&files_str).unwrap_or_default(); - - for file_path in files { - let path = Path::new(&file_path); - if !path.exists() { - continue; // Skip non-existent - } - - let file_name = path.file_name().unwrap_or_default().to_string_lossy().to_string(); - let metadata = std::fs::metadata(&path); - let file_size = metadata.map(|m| m.len()).unwrap_or(0) as i64; - - // Generate ID (simple UUID v4 like) - use uuid::Uuid; - let att_id = Uuid::new_v4().to_string(); - - // Extension mime guess - let mime_type = mime_guess::from_path(&path).first().map(|m| m.to_string()); - - self.conn.execute( + let files: Vec = serde_json::from_str(&files_str).unwrap_or_default(); + + for file_path in files { + let path = Path::new(&file_path); + if !path.exists() { + continue; // Skip non-existent + } + + let file_name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + let metadata = std::fs::metadata(&path); + let file_size = metadata.map(|m| m.len()).unwrap_or(0) as i64; + + // Generate ID (simple UUID v4 like) + use uuid::Uuid; + let att_id = Uuid::new_v4().to_string(); + + // Extension mime guess + let mime_type = mime_guess::from_path(&path).first().map(|m| m.to_string()); + + self.conn.execute( "INSERT OR IGNORE INTO attachments (id, stash_id, file_path, file_name, file_size, mime_type, syntax, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", params![ att_id, @@ -543,16 +585,23 @@ impl DbManager { created_at // Use stash creation time as fallback ] )?; - } - - // Clear files column to avoid re-migration - self.conn.execute("UPDATE stashes SET files = '[]' WHERE id = ?1", params![stash_id])?; + } + + // Clear files column to avoid re-migration + self.conn.execute( + "UPDATE stashes SET files = '[]' WHERE id = ?1", + params![stash_id], + )?; } Ok(()) } - pub fn migrate_from_json(&mut self, stashes: Vec, contexts: Vec) -> Result<()> { + pub fn migrate_from_json( + &mut self, + stashes: Vec, + contexts: Vec, + ) -> Result<()> { let tx = self.conn.transaction()?; // Contexts @@ -627,7 +676,10 @@ impl DbManager { // Force default context to keep its name and empty rules ("Default".to_string(), "[]".to_string()) } else { - (ctx.name.clone(), serde_json::to_string(&ctx.rules).unwrap_or_default()) + ( + ctx.name.clone(), + serde_json::to_string(&ctx.rules).unwrap_or_default(), + ) }; self.conn.execute( @@ -708,12 +760,12 @@ impl DbManager { pub fn get_stashes(&self) -> Result> { // 1. Get all stashes let mut stmt = self.conn.prepare("SELECT id, context_id, content, files, created_at, completed, completed_at, position, updated_at, enhanced_content FROM stashes WHERE deleted = 0 ORDER BY position ASC, id ASC")?; - + let stash_rows = stmt.query_map([], |row| { let files_str: String = row.get(3)?; // files_str kept for backward compat or if needed, but we now use attachments table. // We'll populate attachments below. - + Ok(StashItem { id: row.get(0)?, context_id: row.get(1)?, @@ -742,25 +794,29 @@ impl DbManager { a.syntax, a.created_at FROM attachments a \ JOIN stashes s ON s.id = a.stash_id WHERE s.deleted = 0", )?; - + let att_rows = att_stmt.query_map([], |row| { - Ok(Attachment { - id: row.get(0)?, - stash_id: row.get(1)?, - file_path: row.get(2)?, - file_name: row.get(3)?, - file_size: row.get(4)?, - mime_type: row.get(5)?, - syntax: row.get(6)?, - created_at: row.get(7)?, - }) + Ok(Attachment { + id: row.get(0)?, + stash_id: row.get(1)?, + file_path: row.get(2)?, + file_name: row.get(3)?, + file_size: row.get(4)?, + mime_type: row.get(5)?, + syntax: row.get(6)?, + created_at: row.get(7)?, + }) })?; // Group by stash_id - let mut attachments_map: std::collections::HashMap> = std::collections::HashMap::new(); + let mut attachments_map: std::collections::HashMap> = + std::collections::HashMap::new(); for att in att_rows { if let Ok(a) = att { - attachments_map.entry(a.stash_id.clone()).or_default().push(a); + attachments_map + .entry(a.stash_id.clone()) + .or_default() + .push(a); } } @@ -783,11 +839,11 @@ impl DbManager { fn load_stashes_for_sync_where(&mut self, filter: &str) -> Result> { let sql = format!("SELECT id, context_id, content, files, created_at, completed, completed_at, position, updated_at, enhanced_content, deleted FROM stashes {}", filter); let mut stmt = self.conn.prepare(&sql)?; - + let stash_rows = stmt.query_map([], |row| { let files_str: String = row.get(3)?; let deleted_int: i32 = row.get(10)?; - + Ok(StashItem { id: row.get(0)?, context_id: row.get(1)?, @@ -820,24 +876,28 @@ impl DbManager { filter ); let mut att_stmt = self.conn.prepare(&att_sql)?; - + let att_rows = att_stmt.query_map([], |row| { - Ok(Attachment { - id: row.get(0)?, - stash_id: row.get(1)?, - file_path: row.get(2)?, - file_name: row.get(3)?, - file_size: row.get(4)?, - mime_type: row.get(5)?, - syntax: row.get(6)?, - created_at: row.get(7)?, - }) + Ok(Attachment { + id: row.get(0)?, + stash_id: row.get(1)?, + file_path: row.get(2)?, + file_name: row.get(3)?, + file_size: row.get(4)?, + mime_type: row.get(5)?, + syntax: row.get(6)?, + created_at: row.get(7)?, + }) })?; - let mut attachments_map: std::collections::HashMap> = std::collections::HashMap::new(); + let mut attachments_map: std::collections::HashMap> = + std::collections::HashMap::new(); for att in att_rows { if let Ok(a) = att { - attachments_map.entry(a.stash_id.clone()).or_default().push(a); + attachments_map + .entry(a.stash_id.clone()) + .or_default() + .push(a); } } @@ -1062,7 +1122,10 @@ impl DbManager { /// Shared loader for the context sync payload. `filter` is a trusted, hard-coded SQL /// fragment - never caller input. fn load_contexts_for_sync_where(&mut self, filter: &str) -> Result> { - let sql = format!("SELECT id, name, rules, last_used, updated_at, deleted, description FROM contexts {}", filter); + let sql = format!( + "SELECT id, name, rules, last_used, updated_at, deleted, description FROM contexts {}", + filter + ); let mut stmt = self.conn.prepare(&sql)?; let rows = stmt.query_map([], |row| { let rules_json: String = row.get(2)?; @@ -1108,9 +1171,9 @@ impl DbManager { let tx = self.conn.transaction()?; for stash in stashes { let files_json = serde_json::to_string(&stash.files).unwrap_or_default(); - + let existing_pos = read_position(&tx, &stash.id)?; - + let final_pos = match existing_pos { Some(p) => p, None => next_position_at_end(&tx)?, @@ -1176,7 +1239,7 @@ impl DbManager { origin: WriteOrigin, ) -> Result<()> { let files_json = serde_json::to_string(&stash.files).unwrap_or_default(); - + // An explicit `position` is a deliberate placement - a new stash going to the top // or the bottom, or a completed one moving there. Without one the row keeps the // ordering it already has, and a brand new row lands at the end. @@ -1208,7 +1271,11 @@ impl DbManager { if stash.deleted { 1 } else { 0 }, // A local edit still needs pushing; data that just came from the server // is already in sync by definition. - if origin == WriteOrigin::LocalEdit { 1 } else { 0 }, + if origin == WriteOrigin::LocalEdit { + 1 + } else { + 0 + }, position_stamp, if position_stamp > 0 { 1 } else { 0 } ], @@ -1245,7 +1312,7 @@ impl DbManager { Ok(()) } - /// Update positions for a list of stashes. + /// Update positions for a list of stashes. /// Assuming the input list represents the new order. /// Persist a new ordering. /// @@ -1258,28 +1325,28 @@ impl DbManager { /// Only rows whose position actually changes are stamped, so re-persisting an /// unchanged list is free and does not queue a pointless push. pub fn update_stash_positions(&mut self, stashes: &Vec) -> Result<()> { - let tx = self.conn.transaction()?; - let now = now_ts(); - for (i, stash) in stashes.iter().enumerate() { - let pos = i as f64; - tx.execute( - "UPDATE stashes SET position = ?2, position_updated_at = ?3, pending_position = 1 \ + let tx = self.conn.transaction()?; + let now = now_ts(); + for (i, stash) in stashes.iter().enumerate() { + let pos = i as f64; + tx.execute( + "UPDATE stashes SET position = ?2, position_updated_at = ?3, pending_position = 1 \ WHERE id = ?1 AND position IS NOT ?2", - params![stash.id, pos, now] - )?; - } - tx.commit()?; - Ok(()) + params![stash.id, pos, now], + )?; + } + tx.commit()?; + Ok(()) } - + pub fn delete_completed_stashes(&mut self, context_id: Option) -> Result<()> { if let Some(ctx_id) = context_id { - self.conn.execute( + self.conn.execute( "UPDATE stashes SET deleted = 1, updated_at = ?2, pending_sync = 1 WHERE completed = 1 AND context_id = ?1", params![ctx_id, now_ts()], )?; } else { - self.conn.execute( + self.conn.execute( "UPDATE stashes SET deleted = 1, updated_at = ?1, pending_sync = 1 WHERE completed = 1", params![now_ts()], )?; @@ -1341,7 +1408,7 @@ pub fn now_ts() -> u64 { mod tests { use super::*; use rusqlite::Connection; - + /// Helper to create an in-memory test database fn create_test_db() -> DbManager { let conn = Connection::open_in_memory().expect("Failed to create in-memory database"); @@ -1349,7 +1416,7 @@ mod tests { manager.init_tables().expect("Failed to initialize tables"); manager } - + /// Two attachments sharing one file, as the name-collision bug left them: one /// uploaded row whose size matches the surviving file, and one that does not. fn seed_shared_path(db: &DbManager, path: &str, sizes: &[(&str, i64, Option)]) { @@ -1466,11 +1533,11 @@ mod tests { #[test] fn test_default_context_creation() { let db = create_test_db(); - + // Default context should be created automatically let contexts = db.get_contexts().expect("Failed to get contexts"); assert!(contexts.len() >= 1, "Should have at least default context"); - + let default_ctx = contexts.iter().find(|c| c.id == "default"); assert!(default_ctx.is_some(), "Default context should exist"); assert_eq!(default_ctx.unwrap().name, "Default"); @@ -1534,7 +1601,8 @@ mod tests { #[test] fn deleting_a_context_deletes_its_stashes() { let mut db = create_test_db(); - db.save_context(&plain_context("work", false), WriteOrigin::LocalEdit).unwrap(); + db.save_context(&plain_context("work", false), WriteOrigin::LocalEdit) + .unwrap(); insert_raw_stash(&db, "s-work", Some("work")); insert_raw_stash(&db, "s-default", Some("default")); @@ -1545,7 +1613,11 @@ mod tests { assert_eq!(stored(&db, "s-default"), ("default".to_string(), false)); let pending: i32 = db .conn - .query_row("SELECT pending_sync FROM stashes WHERE id = 's-work'", [], |row| row.get(0)) + .query_row( + "SELECT pending_sync FROM stashes WHERE id = 's-work'", + [], + |row| row.get(0), + ) .unwrap(); assert_eq!(pending, 1, "the deletion has to reach the cloud"); } @@ -1559,7 +1631,10 @@ mod tests { let deleted = db.import_contexts(&[plain_context("work", true)]).unwrap(); assert_eq!(deleted.len(), 1); - assert!(stored(&db, "s-work").1, "a stash in a deleted context is deleted"); + assert!( + stored(&db, "s-work").1, + "a stash in a deleted context is deleted" + ); } #[test] @@ -1567,9 +1642,14 @@ mod tests { let mut db = create_test_db(); insert_raw_stash(&db, "s-default", Some("default")); - db.import_contexts(&[plain_context("default", true)]).unwrap(); + db.import_contexts(&[plain_context("default", true)]) + .unwrap(); - assert!(db.get_contexts().unwrap().iter().any(|c| c.id == "default" && !c.deleted)); + assert!(db + .get_contexts() + .unwrap() + .iter() + .any(|c| c.id == "default" && !c.deleted)); assert!(!stored(&db, "s-default").1); } @@ -1584,9 +1664,10 @@ mod tests { assert!(insert.is_err()); insert_raw_stash(&db, "s-raw", Some("default")); - let update = db - .conn - .execute("UPDATE stashes SET context_id = NULL WHERE id = 's-raw'", []); + let update = db.conn.execute( + "UPDATE stashes SET context_id = NULL WHERE id = 's-raw'", + [], + ); assert!(update.is_err()); } @@ -1601,63 +1682,68 @@ mod tests { assert_eq!(stash.context_id, "default", "{}", payload); } } - + #[test] fn test_save_and_get_context() { let mut db = create_test_db(); - + let test_context = Context { id: "test-project".to_string(), name: "Test Project".to_string(), - rules: vec![ - ContextRule { - rule_type: "process".to_string(), - value: "code".to_string(), - match_type: "exact".to_string(), - match_case: false, - use_regex: false, - } - ], + rules: vec![ContextRule { + rule_type: "process".to_string(), + value: "code".to_string(), + match_type: "exact".to_string(), + match_case: false, + use_regex: false, + }], last_used: Some(chrono::Utc::now().to_rfc3339()), description: Some("Test description".to_string()), updated_at: None, deleted: false, }; - - db.save_context(&test_context, WriteOrigin::LocalEdit).expect("Failed to save context"); - + + db.save_context(&test_context, WriteOrigin::LocalEdit) + .expect("Failed to save context"); + let contexts = db.get_contexts().expect("Failed to get contexts"); let saved = contexts.iter().find(|c| c.id == "test-project"); - + assert!(saved.is_some(), "Context should be saved"); assert_eq!(saved.unwrap().name, "Test Project"); - assert_eq!(saved.unwrap().description, Some("Test description".to_string())); + assert_eq!( + saved.unwrap().description, + Some("Test description".to_string()) + ); } - + #[test] fn test_default_context_protection() { let mut db = create_test_db(); - + // Try to rename default context let modified_default = Context { id: "default".to_string(), - name: "Modified Name".to_string(), // Should be ignored + name: "Modified Name".to_string(), // Should be ignored rules: vec![], last_used: None, description: None, updated_at: None, deleted: false, }; - - db.save_context(&modified_default, WriteOrigin::LocalEdit).expect("Save should succeed"); - + + db.save_context(&modified_default, WriteOrigin::LocalEdit) + .expect("Save should succeed"); + let contexts = db.get_contexts().expect("Failed to get contexts"); let default = contexts.iter().find(|c| c.id == "default").unwrap(); - + // Name should still be "Default", protected from modification - assert_eq!(default.name, "Default", "Default context name should be protected"); + assert_eq!( + default.name, "Default", + "Default context name should be protected" + ); } - /// The attachment queries are joined against the stash set now, rather than reading /// the whole table. `claim_pending_stashes` reuses that join with a @@ -1704,7 +1790,10 @@ mod tests { // The queue view assigns the attachment to its own stash and nobody else's. let stashes = db.get_stashes().expect("Failed to get stashes"); - let with = stashes.iter().find(|s| s.id == "s-with").expect("missing s-with"); + let with = stashes + .iter() + .find(|s| s.id == "s-with") + .expect("missing s-with"); let without = stashes .iter() .find(|s| s.id == "s-without") @@ -1725,15 +1814,20 @@ mod tests { assert_eq!(claimed.attachments.len(), 1); // And the full sync payload agrees. - let all = db.get_stashes_for_sync().expect("get_stashes_for_sync failed"); - let synced = all.iter().find(|s| s.id == "s-with").expect("missing s-with"); + let all = db + .get_stashes_for_sync() + .expect("get_stashes_for_sync failed"); + let synced = all + .iter() + .find(|s| s.id == "s-with") + .expect("missing s-with"); assert_eq!(synced.attachments.len(), 1); } #[test] fn test_save_and_get_stash() { let mut db = create_test_db(); - + let stash = StashItem { id: "test-stash-1".to_string(), context_id: "default".to_string(), @@ -1747,21 +1841,22 @@ mod tests { updated_at: None, deleted: false, }; - - db.save_stash(&stash, None, WriteOrigin::LocalEdit).expect("Failed to save stash"); - + + db.save_stash(&stash, None, WriteOrigin::LocalEdit) + .expect("Failed to save stash"); + let stashes = db.get_stashes().expect("Failed to get stashes"); let saved = stashes.iter().find(|s| s.id == "test-stash-1"); - + assert!(saved.is_some(), "Stash should be saved"); assert_eq!(saved.unwrap().content, "Test stash content"); assert_eq!(saved.unwrap().completed, false); } - + #[test] fn test_delete_stash() { let mut db = create_test_db(); - + let stash = StashItem { id: "stash-to-delete".to_string(), context_id: "default".to_string(), @@ -1775,20 +1870,22 @@ mod tests { updated_at: None, deleted: false, }; - - db.save_stash(&stash, None, WriteOrigin::LocalEdit).expect("Failed to save stash"); - db.delete_stash("stash-to-delete").expect("Failed to delete stash"); - + + db.save_stash(&stash, None, WriteOrigin::LocalEdit) + .expect("Failed to save stash"); + db.delete_stash("stash-to-delete") + .expect("Failed to delete stash"); + let stashes = db.get_stashes().expect("Failed to get stashes"); let deleted = stashes.iter().find(|s| s.id == "stash-to-delete"); - + assert!(deleted.is_none(), "Stash should be soft-deleted"); } - + #[test] fn test_delete_completed_stashes() { let mut db = create_test_db(); - + // Create completed and active stashes let completed = StashItem { id: "completed-1".to_string(), @@ -1803,7 +1900,7 @@ mod tests { updated_at: None, deleted: false, }; - + let active = StashItem { id: "active-1".to_string(), context_id: "default".to_string(), @@ -1817,22 +1914,31 @@ mod tests { updated_at: None, deleted: false, }; - - db.save_stash(&completed, None, WriteOrigin::LocalEdit).expect("Failed to save completed stash"); - db.save_stash(&active, None, WriteOrigin::LocalEdit).expect("Failed to save active stash"); - - db.delete_completed_stashes(None).expect("Failed to delete completed stashes"); - + + db.save_stash(&completed, None, WriteOrigin::LocalEdit) + .expect("Failed to save completed stash"); + db.save_stash(&active, None, WriteOrigin::LocalEdit) + .expect("Failed to save active stash"); + + db.delete_completed_stashes(None) + .expect("Failed to delete completed stashes"); + let stashes = db.get_stashes().expect("Failed to get stashes"); - - assert!(stashes.iter().find(|s| s.id == "completed-1").is_none(), "Completed stash should be deleted"); - assert!(stashes.iter().find(|s| s.id == "active-1").is_some(), "Active stash should remain"); + + assert!( + stashes.iter().find(|s| s.id == "completed-1").is_none(), + "Completed stash should be deleted" + ); + assert!( + stashes.iter().find(|s| s.id == "active-1").is_some(), + "Active stash should remain" + ); } - + #[test] fn test_stash_positioning() { let mut db = create_test_db(); - + let stash1 = StashItem { id: "pos-1".to_string(), context_id: "default".to_string(), @@ -1846,7 +1952,7 @@ mod tests { updated_at: None, deleted: false, }; - + let stash2 = StashItem { id: "pos-2".to_string(), context_id: "default".to_string(), @@ -1860,53 +1966,86 @@ mod tests { updated_at: None, deleted: false, }; - + // Save without explicit position (should append) - db.save_stash(&stash1, None, WriteOrigin::LocalEdit).expect("Failed to save stash1"); - db.save_stash(&stash2, None, WriteOrigin::LocalEdit).expect("Failed to save stash2"); - + db.save_stash(&stash1, None, WriteOrigin::LocalEdit) + .expect("Failed to save stash1"); + db.save_stash(&stash2, None, WriteOrigin::LocalEdit) + .expect("Failed to save stash2"); + let stashes = db.get_stashes().expect("Failed to get stashes"); - + // Should be ordered by position let pos1_idx = stashes.iter().position(|s| s.id == "pos-1"); let pos2_idx = stashes.iter().position(|s| s.id == "pos-2"); - - assert!(pos1_idx.is_some() && pos2_idx.is_some(), "Both stashes should exist"); - assert!(pos1_idx.unwrap() < pos2_idx.unwrap(), "Stashes should be in insertion order"); + + assert!( + pos1_idx.is_some() && pos2_idx.is_some(), + "Both stashes should exist" + ); + assert!( + pos1_idx.unwrap() < pos2_idx.unwrap(), + "Stashes should be in insertion order" + ); } #[test] fn test_migrate_v1_files_to_attachments() { let db = create_test_db(); - + // Create a temporary file to test migration let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let file_path = temp_dir.path().join("test_file.txt"); std::fs::write(&file_path, "test file content").expect("Failed to write test file"); - + // Manually insert a stash with "v1" style files let stash_id = "v1-stash".to_string(); - let files_json = format!("[\"{}\"]", file_path.to_string_lossy().replace("\\", "\\\\")); + let files_json = format!( + "[\"{}\"]", + file_path.to_string_lossy().replace("\\", "\\\\") + ); let now = chrono::Utc::now().to_rfc3339(); - + db.conn.execute( "INSERT INTO stashes (id, context_id, content, files, created_at, completed, position, updated_at) VALUES (?1, 'default', 'v1 content', ?2, ?3, 0, 1.0, ?4)", params![stash_id, files_json, now, now_ts()], ).expect("Failed to insert v1 stash"); - + // Verify it was inserted - let files_check: String = db.conn.query_row("SELECT files FROM stashes WHERE id = 'v1-stash'", [], |row| row.get(0)).unwrap(); + let files_check: String = db + .conn + .query_row( + "SELECT files FROM stashes WHERE id = 'v1-stash'", + [], + |row| row.get(0), + ) + .unwrap(); assert_eq!(files_check, files_json); - + // Run migration - db.migrate_v1_files_to_attachments().expect("Migration failed"); - + db.migrate_v1_files_to_attachments() + .expect("Migration failed"); + // Check if files column is cleared - let files_after: String = db.conn.query_row("SELECT files FROM stashes WHERE id = 'v1-stash'", [], |row| row.get(0)).unwrap(); + let files_after: String = db + .conn + .query_row( + "SELECT files FROM stashes WHERE id = 'v1-stash'", + [], + |row| row.get(0), + ) + .unwrap(); assert_eq!(files_after, "[]"); - + // Check if attachments were created - let count: i32 = db.conn.query_row("SELECT COUNT(*) FROM attachments WHERE stash_id = 'v1-stash'", [], |row| row.get(0)).unwrap(); + let count: i32 = db + .conn + .query_row( + "SELECT COUNT(*) FROM attachments WHERE stash_id = 'v1-stash'", + [], + |row| row.get(0), + ) + .unwrap(); assert_eq!(count, 1, "Should have 1 attachment after migration"); } @@ -1992,7 +2131,10 @@ mod tests { "only the stash this device placed should be queued" ); assert_eq!(claimed[0].id, "s-top"); - assert_eq!(claimed[0].position, -1.0, "the top placement must be the one pushed"); + assert_eq!( + claimed[0].position, -1.0, + "the top placement must be the one pushed" + ); assert!( claimed[0].position_updated_at > 0, "the placement needs a clock or the server's last-write-wins check drops it" @@ -2054,11 +2196,18 @@ mod tests { // overwrite the arrangement the sender actually made. let mut db = create_test_db(); - db.import_stashes(&vec![stash_with_updated_at("s-remote", "from elsewhere", Some(1_000))]) - .expect("import should succeed"); + db.import_stashes(&vec![stash_with_updated_at( + "s-remote", + "from elsewhere", + Some(1_000), + )]) + .expect("import should succeed"); let claimed = db.claim_pending_positions().expect("claim should succeed"); - assert!(claimed.is_empty(), "an imported stash must not push an ordering"); + assert!( + claimed.is_empty(), + "an imported stash must not push an ordering" + ); // And the placeholder ordering must not block the real one from being applied. let applied = db @@ -2078,7 +2227,10 @@ mod tests { |r| r.get(0), ) .expect("row should exist"); - assert_eq!(position, -5.0, "the sender's placement must win over the local append"); + assert_eq!( + position, -5.0, + "the sender's placement must win over the local append" + ); } #[test] @@ -2100,9 +2252,11 @@ mod tests { let position: f64 = db .conn - .query_row("SELECT position FROM stashes WHERE id = 's-first'", [], |r| { - r.get(0) - }) + .query_row( + "SELECT position FROM stashes WHERE id = 's-first'", + [], + |r| r.get(0), + ) .expect("row should exist"); assert_eq!(position, 1.0); } @@ -2114,8 +2268,12 @@ mod tests { db.save_stash(&stash, Some(0.0), WriteOrigin::SyncImport) .expect("save should succeed"); - db.update_stash_positions(&vec![stash_with_updated_at("s-still", "content", Some(1_000))]) - .expect("reorder should succeed"); + db.update_stash_positions(&vec![stash_with_updated_at( + "s-still", + "content", + Some(1_000), + )]) + .expect("reorder should succeed"); let pending: i64 = db .conn @@ -2154,8 +2312,12 @@ mod tests { assert_eq!(claimed.len(), 1); // The user moves it again while the push is in flight. - db.update_stash_positions(&vec![stash_with_updated_at("s-race", "content", Some(1_000))]) - .expect("second reorder should succeed"); + db.update_stash_positions(&vec![stash_with_updated_at( + "s-race", + "content", + Some(1_000), + )]) + .expect("second reorder should succeed"); db.mark_positions_synced(&["s-race".to_string()]) .expect("ack should succeed"); @@ -2251,7 +2413,6 @@ mod tests { assert_eq!(row.3, 0, "and must not queue the record for a push"); } - fn stash_with_updated_at(id: &str, content: &str, updated_at: Option) -> StashItem { StashItem { id: id.to_string(), @@ -2325,7 +2486,8 @@ mod tests { let server_ts = 1_700_000_000_u64; let stash = stash_with_updated_at("from-server", "remote content", Some(server_ts)); - db.import_stashes(&vec![stash]).expect("import should succeed"); + db.import_stashes(&vec![stash]) + .expect("import should succeed"); assert_eq!( stored_updated_at(&db, "from-server"), @@ -2546,7 +2708,10 @@ mod tests { before, after, "claiming moves a record to in flight, which is not the same as converted" ); - assert_eq!(total, total_after, "claiming does not change the corpus size"); + assert_eq!( + total, total_after, + "claiming does not change the corpus size" + ); } #[test] @@ -2576,7 +2741,8 @@ mod tests { let mut db = create_test_db(); let stash = stash_with_updated_at("s-remote", "from another device", Some(1_700_000_000)); - db.import_stashes(&vec![stash]).expect("import should succeed"); + db.import_stashes(&vec![stash]) + .expect("import should succeed"); assert_eq!(pending_flag(&db, "stashes", "s-remote"), 0); // Scoped to this record: a fresh database also seeds starter stashes, which are diff --git a/src-tauri/src/e2ee.rs b/src-tauri/src/e2ee.rs index 53f43f0..6a0fe0d 100644 --- a/src-tauri/src/e2ee.rs +++ b/src-tauri/src/e2ee.rs @@ -43,11 +43,11 @@ // so nothing here is unverified - only uncalled. The allow comes off when enrolment lands. #![allow(dead_code)] +use crate::uierror::UiError; use base64::{engine::general_purpose::STANDARD, Engine as _}; use rand::RngCore; use sha2::{Digest, Sha256}; use zeroize::Zeroizing; -use crate::uierror::UiError; /// Crockford base32: no I, L, O or U, so nothing in a written code can be misread as /// something else. Decoding folds I and L to 1, and O to 0, which is what people actually @@ -178,8 +178,7 @@ pub fn wrap_to_device( let info = wrap_info(user_id, &ephemeral_public, recipient_public, epoch); let key = derive_wrap_key(shared.as_bytes(), &info); - let cipher = - XChaCha20Poly1305::new_from_slice(key.as_slice()).map_err(|_| "bad key length")?; + let cipher = XChaCha20Poly1305::new_from_slice(key.as_slice()).map_err(|_| "bad key length")?; let mut nonce = [0u8; 24]; rand::thread_rng().fill_bytes(&mut nonce); let ciphertext = cipher @@ -223,8 +222,7 @@ pub fn unwrap_with_device( let info = wrap_info(user_id, &ephemeral_public, &keypair.public_bytes(), epoch); let key = derive_wrap_key(shared.as_bytes(), &info); - let cipher = - XChaCha20Poly1305::new_from_slice(key.as_slice()).map_err(|_| "bad key length")?; + let cipher = XChaCha20Poly1305::new_from_slice(key.as_slice()).map_err(|_| "bad key length")?; let plaintext = cipher .decrypt(XNonce::from_slice(nonce), ciphertext) .map_err(|_| "this wrapped key is not for this installation".to_string())?; @@ -254,7 +252,11 @@ impl RecoveryCode { /// Stored so a user holding two pieces of paper can tell which is current. It costs 20 /// of 240 bits; the remaining 220 are still far past any brute-force budget. pub fn hint(&self) -> String { - self.printed.split('-').take(2).collect::>().join("-") + self.printed + .split('-') + .take(2) + .collect::>() + .join("-") } } @@ -648,7 +650,9 @@ mod tests { let ck = new_content_key(); let wrapped = wrap_to_device(&original.public_bytes(), &ck, USER, 1).unwrap(); assert_eq!( - unwrap_with_device(&restored, &wrapped, USER, 1).unwrap().as_slice(), + unwrap_with_device(&restored, &wrapped, USER, 1) + .unwrap() + .as_slice(), ck.as_slice() ); } @@ -724,7 +728,10 @@ mod tests { let mut chars: Vec = code.printed.chars().collect(); chars[at] = if chars[at] == '2' { '3' } else { '2' }; let typo: String = chars.into_iter().collect(); - assert_ne!(typo, code.printed, "the test must actually change something"); + assert_ne!( + typo, code.printed, + "the test must actually change something" + ); assert_eq!(typo.len(), code.printed.len(), "and only the one character"); match parse_recovery_code(&typo) { @@ -747,8 +754,7 @@ mod tests { let code = new_recovery_code(); let ck = new_content_key(); let (salt, wrapped) = wrap_to_recovery(&code, &ck, USER, 1).expect("wrap"); - let opened = - unwrap_with_recovery(&code.printed, &salt, &wrapped, USER, 1).expect("unwrap"); + let opened = unwrap_with_recovery(&code.printed, &salt, &wrapped, USER, 1).expect("unwrap"); assert_eq!(opened.as_slice(), ck.as_slice()); } @@ -832,8 +838,7 @@ fn print_recovery_fixture() { let epoch: u32 = 1; let code = new_recovery_code(); - let (salt_b64, wrapped) = - wrap_to_recovery(&code, &content_key, user_id, epoch).expect("wrap"); + let (salt_b64, wrapped) = wrap_to_recovery(&code, &content_key, user_id, epoch).expect("wrap"); println!("typed: {}", code.printed); println!("salt_b64: {}", salt_b64); diff --git a/src-tauri/src/e2ee_enrol.rs b/src-tauri/src/e2ee_enrol.rs index 6cb04e4..5e3b907 100644 --- a/src-tauri/src/e2ee_enrol.rs +++ b/src-tauri/src/e2ee_enrol.rs @@ -119,8 +119,11 @@ async fn fetch_state( settings_state: &Arc, ) -> Result<(ServerState, String, String), UiError> { let device_id = crate::utils::get_device_id(None).await?; - let body = crate::sync::e2ee_get(settings_state, &format!("/e2ee/state?deviceId={}", device_id)) - .await?; + let body = crate::sync::e2ee_get( + settings_state, + &format!("/e2ee/state?deviceId={}", device_id), + ) + .await?; let user_id = { let settings = settings_state.lock_settings(); settings @@ -412,8 +415,7 @@ pub async fn e2ee_approve_device( ) -> Result<(), UiError> { use base64::{engine::general_purpose::STANDARD, Engine as _}; - let content_key = - e2ee_session::content_key_bytes().ok_or("Unlock this installation first")?; + let content_key = e2ee_session::content_key_bytes().ok_or("Unlock this installation first")?; let (server, user_id, this_device) = fetch_state(&settings_state).await?; let target = server @@ -502,7 +504,12 @@ pub async fn e2ee_recover( // Now this installation holds the key, wrap it to its own public key so later starts do // not need the code again. let content_key = e2ee_session::content_key_bytes().ok_or("Unlocking did not take")?; - let wrap = e2ee::wrap_to_device(&keypair.public_bytes(), &content_key, &user_id, server.epoch)?; + let wrap = e2ee::wrap_to_device( + &keypair.public_bytes(), + &content_key, + &user_id, + server.epoch, + )?; crate::sync::e2ee_post( &settings_state, @@ -688,8 +695,7 @@ pub async fn e2ee_create_access_key( let minted = if server.epoch > 0 { let content_key = e2ee_session::content_key_bytes() .ok_or("Unlock this installation before creating an access key")?; - let (salt, wrapped) = - e2ee::wrap_to_api_key(&secret, &content_key, &user_id, server.epoch)?; + let (salt, wrapped) = e2ee::wrap_to_api_key(&secret, &content_key, &user_id, server.epoch)?; Some(serde_json::json!({ "prefix": prefix, "hash": hash, @@ -725,7 +731,6 @@ pub async fn e2ee_create_access_key( }) } - #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/e2ee_session.rs b/src-tauri/src/e2ee_session.rs index 902a006..e07555f 100644 --- a/src-tauri/src/e2ee_session.rs +++ b/src-tauri/src/e2ee_session.rs @@ -51,8 +51,8 @@ use zeroize::Zeroizing; use crate::e2ee::{self, ContentKey, DeviceKeypair}; use crate::envelope::{self, Binding, Field, Kind}; use crate::state::lock_or_recover; -use crate::utils::get_app_dir; use crate::uierror::UiError; +use crate::utils::get_app_dir; /// The content key for this account, once something has unwrapped it. static CONTENT_KEY: Mutex> = Mutex::new(None); @@ -76,8 +76,7 @@ pub fn load_or_create_device_keypair() -> Result { let path = device_key_path(); if let Ok(sealed) = fs::read_to_string(&path) { - let opened = open_local(sealed.trim()) - .ok_or_else(|| { + let opened = open_local(sealed.trim()).ok_or_else(|| { UiError::new( "e2ee.device_key_unopenable", "This installation's key could not be opened on this machine", @@ -85,9 +84,9 @@ pub fn load_or_create_device_keypair() -> Result { })?; if opened.len() != 32 { return Err(UiError::new( - "e2ee.device_key_damaged", - "This installation's key is damaged", - )); + "e2ee.device_key_damaged", + "This installation's key is damaged", + )); } let mut bytes = [0u8; 32]; bytes.copy_from_slice(&opened); @@ -95,13 +94,12 @@ pub fn load_or_create_device_keypair() -> Result { } let keypair = DeviceKeypair::generate(); - let sealed = seal_local(keypair.secret_bytes().as_slice()) - .ok_or_else(|| { - UiError::new( - "e2ee.nowhere_safe", - "There is nowhere safe on this machine to keep an encryption key", - ) - })?; + let sealed = seal_local(keypair.secret_bytes().as_slice()).ok_or_else(|| { + UiError::new( + "e2ee.nowhere_safe", + "There is nowhere safe on this machine to keep an encryption key", + ) + })?; fs::write(&path, sealed).map_err(|e| format!("Could not save the key: {}", e))?; Ok(keypair) } @@ -171,10 +169,7 @@ pub struct UnreadableRecord { /// /// A no-op when this installation holds no content key, which is every account that has not /// turned encryption on. -pub fn seal_stash_payload( - payload: &mut serde_json::Value, - user_id: &str, -) -> Result<(), UiError> { +pub fn seal_stash_payload(payload: &mut serde_json::Value, user_id: &str) -> Result<(), UiError> { let guard = lock_or_recover(&CONTENT_KEY); let Some(key) = guard.as_ref() else { return Ok(()); @@ -204,7 +199,16 @@ pub fn seal_stash_payload( continue; } - seal_field(stash, "content", key, user_id, &id, Kind::Stash, Field::Content, epoch)?; + seal_field( + stash, + "content", + key, + user_id, + &id, + Kind::Stash, + Field::Content, + epoch, + )?; seal_field( stash, "enhancedContent", @@ -242,10 +246,7 @@ fn strip_attachment_details(stash: &mut serde_json::Value) { /// /// Records that cannot be opened are removed from `synced` and returned, so the caller can /// report them. They are never written locally - see the rule in the module notes. -pub fn open_stash_response( - body: &mut serde_json::Value, - user_id: &str, -) -> Vec { +pub fn open_stash_response(body: &mut serde_json::Value, user_id: &str) -> Vec { let mut unreadable = Vec::new(); let guard = lock_or_recover(&CONTENT_KEY); @@ -269,7 +270,10 @@ pub fn open_stash_response( open_field(stash, field, key, user_id, &id, Kind::Stash, which, epoch) { log::warn!("Dropping stash {} from this sync: {}", id, reason); - unreadable.push(UnreadableRecord { id: id.clone(), reason: reason.message }); + unreadable.push(UnreadableRecord { + id: id.clone(), + reason: reason.message, + }); return false; } } @@ -280,10 +284,7 @@ pub fn open_stash_response( } /// Seal a context-sync payload. Name, description and rules travel together. -pub fn seal_context_payload( - payload: &mut serde_json::Value, - user_id: &str, -) -> Result<(), UiError> { +pub fn seal_context_payload(payload: &mut serde_json::Value, user_id: &str) -> Result<(), UiError> { let guard = lock_or_recover(&CONTENT_KEY); let Some(key) = guard.as_ref() else { return Ok(()); @@ -306,7 +307,16 @@ pub fn seal_context_payload( continue; } - seal_field(ctx, "name", key, user_id, &id, Kind::Context, Field::Name, epoch)?; + seal_field( + ctx, + "name", + key, + user_id, + &id, + Kind::Context, + Field::Name, + epoch, + )?; seal_field( ctx, "description", @@ -343,10 +353,7 @@ pub fn seal_context_payload( } /// Open a context-sync response. -pub fn open_context_response( - body: &mut serde_json::Value, - user_id: &str, -) -> Vec { +pub fn open_context_response(body: &mut serde_json::Value, user_id: &str) -> Vec { let mut unreadable = Vec::new(); let guard = lock_or_recover(&CONTENT_KEY); @@ -367,18 +374,19 @@ pub fn open_context_response( open_field(ctx, field, key, user_id, &id, Kind::Context, which, epoch) { log::warn!("Dropping context {} from this sync: {}", id, reason); - unreadable.push(UnreadableRecord { id: id.clone(), reason: reason.message }); + unreadable.push(UnreadableRecord { + id: id.clone(), + reason: reason.message, + }); return false; } } // The sealed-rules case: a one-element array holding an envelope. - let sealed_rules = ctx["rules"] - .as_array() - .and_then(|r| match r.as_slice() { - [serde_json::Value::String(s)] if envelope::is_envelope(s) => Some(s.clone()), - _ => None, - }); + let sealed_rules = ctx["rules"].as_array().and_then(|r| match r.as_slice() { + [serde_json::Value::String(s)] if envelope::is_envelope(s) => Some(s.clone()), + _ => None, + }); if let Some(sealed) = sealed_rules { match envelope::open( @@ -394,12 +402,16 @@ pub fn open_context_response( }, ) { Ok(json) => { - ctx["rules"] = serde_json::from_str(&json).unwrap_or_else(|_| serde_json::json!([])); + ctx["rules"] = + serde_json::from_str(&json).unwrap_or_else(|_| serde_json::json!([])); } Err(e) => { let reason = format!("{:?}", e); log::warn!("Dropping context {} from this sync: {}", id, reason); - unreadable.push(UnreadableRecord { id: id.clone(), reason }); + unreadable.push(UnreadableRecord { + id: id.clone(), + reason, + }); return false; } } @@ -703,7 +715,9 @@ fn confirm_and_hold(key: ContentKey, epoch: u32, verifier: &str) -> Result<(), U /// Keep a copy of the raw key bytes for a caller that has to wrap it onward. pub fn content_key_bytes() -> Option> { - lock_or_recover(&CONTENT_KEY).as_ref().map(|k| Zeroizing::new(**k)) + lock_or_recover(&CONTENT_KEY) + .as_ref() + .map(|k| Zeroizing::new(**k)) } #[cfg(test)] @@ -793,7 +807,10 @@ mod tests { payload["stashes"][0]["attachments"] = serde_json::json!([{ "id": "a", "fileName": "shot.png", "fileSize": 5 }]); seal_stash_payload(&mut payload, USER).expect("seal"); - assert_eq!(payload["stashes"][0]["attachments"][0]["fileName"], "shot.png"); + assert_eq!( + payload["stashes"][0]["attachments"][0]["fileName"], + "shot.png" + ); } #[test] @@ -847,7 +864,10 @@ mod tests { with_key(|| { let mut payload = stash_payload(); seal_stash_payload(&mut payload, USER).expect("first"); - let once = payload["stashes"][0]["content"].as_str().unwrap().to_string(); + let once = payload["stashes"][0]["content"] + .as_str() + .unwrap() + .to_string(); seal_stash_payload(&mut payload, USER).expect("second"); assert_eq!(payload["stashes"][0]["content"], once); @@ -999,7 +1019,10 @@ mod attachment_tests { .expect("open") .expect("sealed"); assert_eq!(details.file_name, "Q3-layoffs.xlsx"); - assert_eq!(details.mime_type.as_deref(), Some("application/vnd.ms-excel")); + assert_eq!( + details.mime_type.as_deref(), + Some("application/vnd.ms-excel") + ); assert_eq!(details.syntax, None); assert_eq!(details.plaintext_size, plaintext.len() as i64); @@ -1015,11 +1038,20 @@ mod attachment_tests { let a = seal_attachment(b"one", "a.txt", None, None, ATT, USER) .unwrap() .unwrap(); - let b = seal_attachment(b"two", "b.txt", None, None, "66666666-6666-4666-8666-666666666666", USER) + let b = seal_attachment( + b"two", + "b.txt", + None, + None, + "66666666-6666-4666-8666-666666666666", + USER, + ) + .unwrap() + .unwrap(); + + let details_a = open_attachment_metadata(&a.metadata, ATT, USER) .unwrap() .unwrap(); - - let details_a = open_attachment_metadata(&a.metadata, ATT, USER).unwrap().unwrap(); assert!( open_attachment_bytes(&details_a, &b.bytes).is_err(), "one file's key must not open another's bytes" @@ -1065,7 +1097,9 @@ mod attachment_tests { let sealed = seal_attachment(b"the bytes", "a.txt", None, None, ATT, USER) .unwrap() .unwrap(); - let details = open_attachment_metadata(&sealed.metadata, ATT, USER).unwrap().unwrap(); + let details = open_attachment_metadata(&sealed.metadata, ATT, USER) + .unwrap() + .unwrap(); assert!(open_attachment_bytes(&details, &sealed.bytes[..20]).is_err()); }); } diff --git a/src-tauri/src/envelope.rs b/src-tauri/src/envelope.rs index 557433a..0cf5b66 100644 --- a/src-tauri/src/envelope.rs +++ b/src-tauri/src/envelope.rs @@ -188,7 +188,9 @@ pub fn parse(value: &str) -> Result { .parse::() .map_err(|_| EnvelopeError::Malformed("epoch is not a number"))?; - let nonce_b64 = parts.next().ok_or(EnvelopeError::Malformed("missing nonce"))?; + let nonce_b64 = parts + .next() + .ok_or(EnvelopeError::Malformed("missing nonce"))?; let ct_b64 = parts .next() .ok_or(EnvelopeError::Malformed("missing ciphertext"))?; @@ -210,7 +212,9 @@ pub fn parse(value: &str) -> Result { .decode(ct_b64) .map_err(|_| EnvelopeError::Malformed("ciphertext is not base64url"))?; if ciphertext.len() < TAG_LEN { - return Err(EnvelopeError::Malformed("ciphertext is too short to carry a tag")); + return Err(EnvelopeError::Malformed( + "ciphertext is too short to carry a tag", + )); } Ok(Envelope { @@ -384,7 +388,10 @@ mod tests { user_id: "99999999-9999-4999-8999-999999999999", ..binding() }; - assert_eq!(open(&KEY, 1, &sealed, &other), Err(EnvelopeError::NotAuthentic)); + assert_eq!( + open(&KEY, 1, &sealed, &other), + Err(EnvelopeError::NotAuthentic) + ); } #[test] @@ -394,7 +401,10 @@ mod tests { record_id: "33333333-3333-4333-8333-333333333333", ..binding() }; - assert_eq!(open(&KEY, 1, &sealed, &other), Err(EnvelopeError::NotAuthentic)); + assert_eq!( + open(&KEY, 1, &sealed, &other), + Err(EnvelopeError::NotAuthentic) + ); } /// Both are sealed strings on the same row under the same content key, so only the @@ -406,7 +416,10 @@ mod tests { ..binding() }; let sealed = seal(&KEY, "the rewritten copy", &enhanced).expect("seal"); - assert_eq!(open(&KEY, 1, &sealed, &binding()), Err(EnvelopeError::NotAuthentic)); + assert_eq!( + open(&KEY, 1, &sealed, &binding()), + Err(EnvelopeError::NotAuthentic) + ); } #[test] @@ -441,7 +454,10 @@ mod tests { #[test] fn an_epoch_this_client_has_no_key_for_is_reported_as_such() { - let future = Binding { epoch: 9, ..binding() }; + let future = Binding { + epoch: 9, + ..binding() + }; let sealed = seal(&KEY, "secret", &future).expect("seal"); assert_eq!( open(&KEY, 1, &sealed, &binding()), @@ -457,7 +473,10 @@ mod tests { let last = raw.len() - 1; raw[last] ^= 0xff; let tampered = format!("{}.{}", head, URL_SAFE_NO_PAD.encode(&raw)); - assert_eq!(open(&KEY, 1, &tampered, &binding()), Err(EnvelopeError::NotAuthentic)); + assert_eq!( + open(&KEY, 1, &tampered, &binding()), + Err(EnvelopeError::NotAuthentic) + ); } #[test] @@ -473,7 +492,10 @@ mod tests { #[test] fn plaintext_is_not_mistaken_for_an_envelope() { assert!(!is_envelope("just a normal stash")); - assert_eq!(parse("just a normal stash"), Err(EnvelopeError::NotAnEnvelope)); + assert_eq!( + parse("just a normal stash"), + Err(EnvelopeError::NotAnEnvelope) + ); } /// A stash whose text genuinely starts with the marker looks like an envelope to the @@ -488,7 +510,10 @@ mod tests { #[test] fn malformed_shapes_are_refused() { - assert!(matches!(parse("SPE1.1.onlythree"), Err(EnvelopeError::Malformed(_)))); + assert!(matches!( + parse("SPE1.1.onlythree"), + Err(EnvelopeError::Malformed(_)) + )); assert!(matches!( parse("SPE1.notanumber.AAAA.AAAA"), Err(EnvelopeError::Malformed(_)) @@ -516,7 +541,10 @@ mod tests { let a = seal(&KEY, "same", &binding()).expect("seal"); let b = seal(&KEY, "same", &binding()).expect("seal"); assert_ne!(a, b); - assert_eq!(open(&KEY, 1, &a, &binding()).unwrap(), open(&KEY, 1, &b, &binding()).unwrap()); + assert_eq!( + open(&KEY, 1, &a, &binding()).unwrap(), + open(&KEY, 1, &b, &binding()).unwrap() + ); } #[test] diff --git a/src-tauri/src/keychain.rs b/src-tauri/src/keychain.rs index 639010d..614ac12 100644 --- a/src-tauri/src/keychain.rs +++ b/src-tauri/src/keychain.rs @@ -53,11 +53,7 @@ const KEYCHAIN_LOCAL_KEY_TARGET: &str = "stashpad.local_key"; /// * **Windows** - it is the credential's TargetName, and secrets exist under it. /// * **Linux** - it is one of the lookup attributes (`src/secret_service.rs:237`), so /// dropping it would silently hide every secret already stored under it. -fn build_entry( - target: &str, - service: &str, - user: &str, -) -> Result { +fn build_entry(target: &str, service: &str, user: &str) -> Result { #[cfg(target_os = "macos")] { let _ = target; @@ -85,7 +81,11 @@ pub fn create_cloud_keychain_entry() -> Result { /// what seals the device key file, and on a machine with a credential store it is the only /// thing standing between that file and anyone who can read the folder. pub fn create_local_key_entry() -> Result { - build_entry(KEYCHAIN_LOCAL_KEY_TARGET, KEYCHAIN_SERVICE, KEYCHAIN_LOCAL_KEY_USER) + build_entry( + KEYCHAIN_LOCAL_KEY_TARGET, + KEYCHAIN_SERVICE, + KEYCHAIN_LOCAL_KEY_USER, + ) } /// Whether this machine has a credential store that actually works. @@ -150,9 +150,10 @@ pub fn flush_early_diagnostics() { pub fn probe_keychain() -> KeychainStatus { let status = run_probe(); match status { - KeychainStatus::Working => { - early_log(log::Level::Info, "Credential store is available and round-trips".to_string()) - } + KeychainStatus::Working => early_log( + log::Level::Info, + "Credential store is available and round-trips".to_string(), + ), KeychainStatus::Unavailable => early_log( log::Level::Warn, "No usable credential store on this machine - secrets fall back to an encrypted file" @@ -188,11 +189,7 @@ fn run_probe() -> KeychainStatus { // The cost is an orphaned credential if the process dies between the write and the // delete. That is a narrow window and a tiny value with a recognisable name, which is // a better trade than intermittently mistaking a working store for a missing one. - let unique = format!( - "{}-{:x}", - std::process::id(), - rand::random::() - ); + let unique = format!("{}-{:x}", std::process::id(), rand::random::()); let target = format!("stashpad.probe.{}", unique); let canary = format!("stashpad-keychain-probe-{}", unique); // The uniqueness has to be in the user as well as the target, because macOS ignores the @@ -204,13 +201,19 @@ fn run_probe() -> KeychainStatus { let entry = match build_entry(&target, KEYCHAIN_SERVICE, &probe_user) { Ok(entry) => entry, Err(e) => { - early_log(log::Level::Warn, format!("Credential store probe could not create an entry: {}", e)); + early_log( + log::Level::Warn, + format!("Credential store probe could not create an entry: {}", e), + ); return KeychainStatus::Unavailable; } }; if let Err(e) = entry.set_password(&canary) { - early_log(log::Level::Warn, format!("Credential store probe could not write: {}", e)); + early_log( + log::Level::Warn, + format!("Credential store probe could not write: {}", e), + ); return KeychainStatus::Unavailable; } @@ -218,19 +221,25 @@ fn run_probe() -> KeychainStatus { // reading back through the same one would pass against a store that persists nothing. // The value is unique per probe as well, so a stale entry cannot stand in for a live // write either. - let readback = - build_entry(&target, KEYCHAIN_SERVICE, &probe_user).and_then(|verify| verify.get_password()); + let readback = build_entry(&target, KEYCHAIN_SERVICE, &probe_user) + .and_then(|verify| verify.get_password()); let _ = entry.delete_credential(); match readback { Ok(value) if value == canary => KeychainStatus::Working, Ok(_) => { - early_log(log::Level::Warn, "Credential store probe read back a different value".to_string()); + early_log( + log::Level::Warn, + "Credential store probe read back a different value".to_string(), + ); KeychainStatus::Unavailable } Err(e) => { - early_log(log::Level::Warn, format!("Credential store probe could not read back: {}", e)); + early_log( + log::Level::Warn, + format!("Credential store probe could not read back: {}", e), + ); KeychainStatus::Unavailable } } @@ -238,7 +247,9 @@ fn run_probe() -> KeychainStatus { /// What the startup probe found. `Unavailable` until [`probe_keychain`] has run. pub fn keychain_status() -> KeychainStatus { - *KEYCHAIN_STATUS.get().unwrap_or(&KeychainStatus::Unavailable) + *KEYCHAIN_STATUS + .get() + .unwrap_or(&KeychainStatus::Unavailable) } /// Store a secret in the system keychain. @@ -280,7 +291,11 @@ pub fn store_api_key_in_keychain(key: &str) -> bool { /// Store cloud access token in system keychain pub fn store_cloud_token_in_keychain(token: &str) -> bool { - store_secret_in_keychain(create_cloud_keychain_entry, delete_cloud_token_from_keychain, token) + store_secret_in_keychain( + create_cloud_keychain_entry, + delete_cloud_token_from_keychain, + token, + ) } /// Retrieve a secret from the system keychain. @@ -289,13 +304,11 @@ pub fn get_secret_from_keychain( create_entry: fn() -> Result, ) -> Option { match create_entry() { - Ok(entry) => { - match entry.get_password() { - Ok(password) => Some(password), - Err(_) => None - } - } - Err(_) => None + Ok(entry) => match entry.get_password() { + Ok(password) => Some(password), + Err(_) => None, + }, + Err(_) => None, } } @@ -310,9 +323,7 @@ pub fn get_cloud_token_from_keychain() -> Option { } /// Delete a secret from the keychain. -pub fn delete_secret_from_keychain( - create_entry: fn() -> Result, -) { +pub fn delete_secret_from_keychain(create_entry: fn() -> Result) { if let Ok(entry) = create_entry() { let _ = entry.delete_credential(); } @@ -331,10 +342,10 @@ pub fn delete_cloud_token_from_keychain() { /// Derive a 256-bit key from machine-specific information /// This makes the encrypted data machine-bound (can't be decrypted on another machine) pub fn derive_machine_key() -> [u8; 32] { - use sha2::{Sha256, Digest}; - + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); - + // Add machine-specific data to the key derivation // This includes hostname and app directory path if let Ok(hostname) = std::env::var("COMPUTERNAME") @@ -343,13 +354,13 @@ pub fn derive_machine_key() -> [u8; 32] { { hasher.update(hostname.as_bytes()); } - + // Add app directory path (unique per user/installation) hasher.update(get_app_dir().to_string_lossy().as_bytes()); - + // Add a static salt hasher.update(b"StashpadAPIKeyEncryption2026"); - + let result = hasher.finalize(); let mut key = [0u8; 32]; key.copy_from_slice(&result); @@ -574,7 +585,11 @@ mod tests { let encoded = STANDARD.encode(&obfuscated); assert_eq!(decrypt_legacy_secret(&encoded), secret); - assert_eq!(decrypt_api_key(&encoded), "", "the live path must not open it"); + assert_eq!( + decrypt_api_key(&encoded), + "", + "the live path must not open it" + ); } /// Proves the thing the unit tests above cannot: that a *real* credential store is @@ -593,10 +608,17 @@ mod tests { ); let secret = "sk_stashpad_round_trip_check"; - assert!(store_api_key_in_keychain(secret), "the store refused a write"); + assert!( + store_api_key_in_keychain(secret), + "the store refused a write" + ); assert_eq!(get_api_key_from_keychain().as_deref(), Some(secret)); delete_api_key_from_keychain(); - assert_eq!(get_api_key_from_keychain(), None, "delete must actually remove it"); + assert_eq!( + get_api_key_from_keychain(), + None, + "delete must actually remove it" + ); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 225d1e5..7c010ff 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,44 +14,44 @@ use std::collections::HashMap; use std::fs; use std::sync::{Arc, Mutex}; -use std::time::Duration; use std::thread; +use std::time::Duration; use tauri::menu::Menu; // Only the macOS branch builds a custom menu; importing these unconditionally warns on // every other platform. +use active_win_pos_rs::get_active_window; #[cfg(target_os = "macos")] use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}; use tauri::Manager; -use active_win_pos_rs::get_active_window; -mod models; -mod state; -mod uierror; -mod utils; mod account_export; +mod contexts; +pub mod db; mod e2ee; mod e2ee_enrol; mod e2ee_session; mod envelope; mod keychain; mod localkey; +mod models; mod settings; -mod contexts; -mod sync; mod stashes; +mod state; +mod sync; mod transfer; -pub mod db; +mod uierror; +mod utils; +use db::DbManager; use models::{AppContext, Context}; -use state::{DbState, TrackerState, WsState, SettingsState, lock_or_recover}; +use settings::load_settings_from_disk; +use stashes::perform_startup_cleanup; +use state::{lock_or_recover, DbState, SettingsState, TrackerState, WsState}; use utils::{ - get_app_dir, ensure_storage_ready, apply_window_effects_to_window, apply_window_background, + apply_window_background, apply_window_effects_to_window, ensure_storage_ready, get_app_dir, get_system_prompt_path, }; -use settings::load_settings_from_disk; -use stashes::perform_startup_cleanup; -use db::DbManager; /// How often the active window is sampled for auto context detection. /// @@ -182,7 +182,7 @@ pub fn run() { let ws_state = Arc::new(WsState { task_handle: Mutex::new(None), }); - + // Perform startup cleanup. Settings are copied out first so the two locks are never // held at once - the same ordering every command uses. { @@ -190,12 +190,12 @@ pub fn run() { let mut db_lock = db_state.lock_db(); perform_startup_cleanup(&mut db_lock, &settings_snapshot); } - + let tracker_state_clone = tracker_state.clone(); let settings_state_clone = settings_state.clone(); // Clone db state for background thread let db_state_clone = db_state.clone(); - + // Start background polling. // // This thread used to hold the global DB mutex across `get_contexts()` *and* the @@ -390,7 +390,7 @@ pub fn run() { thread::spawn(move || { let path = get_system_prompt_path(); let mut last_mtime = fs::metadata(&path).and_then(|m| m.modified()).ok(); - + loop { thread::sleep(Duration::from_secs(2)); let current_mtime = fs::metadata(&path).and_then(|m| m.modified()).ok(); @@ -410,7 +410,6 @@ pub fn run() { .manage(settings_state) .manage(ws_state); - #[cfg(debug_assertions)] { builder = builder.plugin(devtools); @@ -444,39 +443,46 @@ pub fn run() { .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_autostart::init(tauri_plugin_autostart::MacosLauncher::LaunchAgent, Some(vec![]))) - .plugin(tauri_plugin_global_shortcut::Builder::new().with_handler(move |app, _shortcut, event| { - // Handle global shortcut (toggle window) - use tauri_plugin_global_shortcut::ShortcutState; - use tauri::Manager; // For get_webview_window - - if event.state == ShortcutState::Pressed { - if let Some(window) = app.get_webview_window("main") { - let is_shown = window.is_visible().unwrap_or(false) - && window.is_focused().unwrap_or(false) - && !window.is_minimized().unwrap_or(false); - if is_shown { - // On macOS, minimize instead of hide to stay in Cmd+Tab and dock - #[cfg(target_os = "macos")] - { - let _ = window.minimize(); - } - #[cfg(not(target_os = "macos"))] - { - let _ = window.hide(); + .plugin(tauri_plugin_autostart::init( + tauri_plugin_autostart::MacosLauncher::LaunchAgent, + Some(vec![]), + )) + .plugin( + tauri_plugin_global_shortcut::Builder::new() + .with_handler(move |app, _shortcut, event| { + // Handle global shortcut (toggle window) + use tauri::Manager; + use tauri_plugin_global_shortcut::ShortcutState; // For get_webview_window + + if event.state == ShortcutState::Pressed { + if let Some(window) = app.get_webview_window("main") { + let is_shown = window.is_visible().unwrap_or(false) + && window.is_focused().unwrap_or(false) + && !window.is_minimized().unwrap_or(false); + if is_shown { + // On macOS, minimize instead of hide to stay in Cmd+Tab and dock + #[cfg(target_os = "macos")] + { + let _ = window.minimize(); + } + #[cfg(not(target_os = "macos"))] + { + let _ = window.hide(); + } + } else { + // Restore: unminimize on macOS, show on all platforms + #[cfg(target_os = "macos")] + { + let _ = window.unminimize(); + } + let _ = window.show(); + let _ = window.set_focus(); } - } else { - // Restore: unminimize on macOS, show on all platforms - #[cfg(target_os = "macos")] - { - let _ = window.unminimize(); - } - let _ = window.show(); - let _ = window.set_focus(); } - } - } - }).build()) + } + }) + .build(), + ) .invoke_handler(tauri::generate_handler![ utils::get_previous_app_info, utils::get_smart_transfer_target, @@ -575,15 +581,13 @@ pub fn run() { // Anything that needs to happen at startup belongs in that one hook. .build(tauri::generate_context!()) .expect("error while building tauri application") - .run(|app_handle, event| { - match event { - tauri::RunEvent::Exit => { - println!("App exiting, cleaning up..."); - cleanup_websocket_state(app_handle); - cleanup_database_state(app_handle); - } - _ => {} + .run(|app_handle, event| match event { + tauri::RunEvent::Exit => { + println!("App exiting, cleaning up..."); + cleanup_websocket_state(app_handle); + cleanup_database_state(app_handle); } + _ => {} }); } diff --git a/src-tauri/src/localkey.rs b/src-tauri/src/localkey.rs index d5d4b5e..9d87a29 100644 --- a/src-tauri/src/localkey.rs +++ b/src-tauri/src/localkey.rs @@ -39,8 +39,8 @@ use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; use crate::state::lock_or_recover; -use crate::utils::get_app_dir; use crate::uierror::UiError; +use crate::utils::get_app_dir; /// Proves a passphrase is the right one without storing anything that reveals it. const VERIFIER_PLAINTEXT: &[u8] = b"stashpad-device-key-v1"; @@ -124,7 +124,13 @@ pub fn is_configured() -> bool { } /// Stretch a passphrase into the 32-byte device-protection key. -fn derive(passphrase: &str, salt: &[u8], m_cost: u32, t_cost: u32, p_cost: u32) -> Result, UiError> { +fn derive( + passphrase: &str, + salt: &[u8], + m_cost: u32, + t_cost: u32, + p_cost: u32, +) -> Result, UiError> { use argon2::{Algorithm, Argon2, Params, Version}; let params = Params::new(m_cost, t_cost, p_cost, Some(32)) @@ -212,7 +218,13 @@ pub fn set_passphrase(passphrase: &str, remember: bool) -> Result<(), UiError> { let mut salt = [0u8; 16]; rand::thread_rng().fill_bytes(&mut salt); - let key = derive(passphrase, &salt, DEFAULT_M_COST, DEFAULT_T_COST, DEFAULT_P_COST)?; + let key = derive( + passphrase, + &salt, + DEFAULT_M_COST, + DEFAULT_T_COST, + DEFAULT_P_COST, + )?; let verifier = seal_with(&key, VERIFIER_PLAINTEXT)?; write_key_file(&KeyFile { @@ -293,9 +305,9 @@ pub fn forget_remembered() { fn load_or_create_machine_key() -> bool { use base64::{engine::general_purpose::STANDARD, Engine as _}; - if let Some(existing) = crate::keychain::get_secret_from_keychain( - crate::keychain::create_local_key_entry, - ) { + if let Some(existing) = + crate::keychain::get_secret_from_keychain(crate::keychain::create_local_key_entry) + { if let Ok(bytes) = STANDARD.decode(existing.trim()) { if bytes.len() == 32 { let mut key = Zeroizing::new([0u8; 32]); @@ -308,7 +320,8 @@ fn load_or_create_machine_key() -> bool { // sealed with whatever the old value was, so overwriting it strands that file. crate::keychain::early_log( log::Level::Error, - "The local key in the credential store is not 32 bytes; refusing to replace it".to_string(), + "The local key in the credential store is not 32 bytes; refusing to replace it" + .to_string(), ); return false; } @@ -538,7 +551,10 @@ mod tests { fn a_secret_round_trips_under_a_key() { let k = key(7); let sealed = seal_with(&k, b"sk_stashpad_value").expect("seal"); - assert_eq!(open_with(&k, &sealed).as_deref(), Some(&b"sk_stashpad_value"[..])); + assert_eq!( + open_with(&k, &sealed).as_deref(), + Some(&b"sk_stashpad_value"[..]) + ); } #[test] diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 9da751f..af57df3 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -15,5 +15,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - app_lib::run(); + app_lib::run(); } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index ac87c3f..ed63377 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -73,7 +73,10 @@ pub struct StashItem { /// it goes when there is no other. A missing, `null` or empty value - from an older /// build, an older export, or a server that still stored NULL - reads as that one, /// which is also where the queue always showed such a stash. - #[serde(default = "default_context_id", deserialize_with = "context_or_default")] + #[serde( + default = "default_context_id", + deserialize_with = "context_or_default" + )] pub context_id: String, #[serde(default)] pub completed: bool, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index f326856..4d709f2 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -11,20 +11,20 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. // See the GNU Affero General Public License for more details. -use std::fs; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; -use tauri::{State, Manager}; -use crate::models::{Settings, CloudConfig, default_cloud_endpoint}; -use crate::utils::get_app_dir; use crate::keychain::{ - decrypt_api_key, decrypt_legacy_secret, get_api_key_from_keychain, get_cloud_token_from_keychain, - keychain_status, store_api_key_in_keychain, store_cloud_token_in_keychain, - KeychainStatus, + decrypt_api_key, decrypt_legacy_secret, get_api_key_from_keychain, + get_cloud_token_from_keychain, keychain_status, store_api_key_in_keychain, + store_cloud_token_in_keychain, KeychainStatus, }; use crate::localkey; -use crate::state::{SettingsState, lock_or_recover}; +use crate::models::{default_cloud_endpoint, CloudConfig, Settings}; +use crate::state::{lock_or_recover, SettingsState}; use crate::uierror::UiError; +use crate::utils::get_app_dir; +use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use tauri::{Manager, State}; pub fn get_settings_path() -> PathBuf { get_app_dir().join("settings.json") @@ -92,7 +92,7 @@ pub const MAX_PASTE_AS_ATTACHMENT_THRESHOLD: u32 = 100_000; /// This ensures robustness against manual edits or corruption of settings.json. pub fn validate_settings(mut settings: Settings) -> Settings { let defaults = Settings::default(); - + // Validate new_stash_position: must be "top" or "bottom" if settings.new_stash_position != "top" && settings.new_stash_position != "bottom" { println!( @@ -101,7 +101,7 @@ pub fn validate_settings(mut settings: Settings) -> Settings { ); settings.new_stash_position = defaults.new_stash_position.clone(); } - + // Validate clear_completed_strategy: must be "never", "on-close", or "after-n-days" let valid_strategies = ["never", "on-close", "after-n-days"]; if !valid_strategies.contains(&settings.clear_completed_strategy.as_str()) { @@ -111,7 +111,7 @@ pub fn validate_settings(mut settings: Settings) -> Settings { ); settings.clear_completed_strategy = defaults.clear_completed_strategy.clone(); } - + // Validate theme: must be "light", "dark", "system", or None if let Some(ref theme) = settings.theme { if !["light", "dark", "system"].contains(&theme.as_str()) { @@ -122,7 +122,7 @@ pub fn validate_settings(mut settings: Settings) -> Settings { settings.theme = None; } } - + // Validate clear_completed_days: must be at least 1 if strategy is after-n-days if settings.clear_completed_strategy == "after-n-days" && settings.clear_completed_days == 0 { println!( @@ -131,7 +131,7 @@ pub fn validate_settings(mut settings: Settings) -> Settings { ); settings.clear_completed_days = defaults.clear_completed_days; } - + // Validate paste_as_attachment_threshold: 0 is valid (ask user), so only cap the top // end. Clamp rather than reset - the user asked for "as large as possible", and // dropping them back to the default silently discards that intent. @@ -142,7 +142,7 @@ pub fn validate_settings(mut settings: Settings) -> Settings { ); settings.paste_as_attachment_threshold = MAX_PASTE_AS_ATTACHMENT_THRESHOLD; } - + // Discard update timestamps that sit implausibly far in the future. A clock that // jumped forward once would otherwise suppress every later update check - the 48h // deadline and the "remind me later" deadline would both never be reached again. @@ -155,7 +155,10 @@ pub fn validate_settings(mut settings: Settings) -> Settings { println!("Warning: last_update_check_at is in the future, resetting"); settings.last_update_check_at = None; } - if settings.update_remind_after.is_some_and(|t| t > horizon + 7 * 24 * 60 * 60 * 1000) { + if settings + .update_remind_after + .is_some_and(|t| t > horizon + 7 * 24 * 60 * 60 * 1000) + { println!("Warning: update_remind_after is implausibly far ahead, resetting"); settings.update_remind_after = None; } @@ -175,7 +178,7 @@ pub fn validate_settings(mut settings: Settings) -> Settings { last_sync_at: None, }); } - + settings } @@ -240,7 +243,10 @@ fn persist_secret( return String::new(); } KeychainStatus::Unavailable => { - log::info!("No credential store for {}, using the device passphrase", label); + log::info!( + "No credential store for {}, using the device passphrase", + label + ); } } } @@ -299,8 +305,12 @@ pub fn persist_settings_to_disk(settings: &Settings) { let api_key = ai_config.api_key.clone(); if !api_key.is_empty() { - ai_config.api_key = - persist_secret(&LAST_API_KEY, store_api_key_in_keychain, &api_key, "API key"); + ai_config.api_key = persist_secret( + &LAST_API_KEY, + store_api_key_in_keychain, + &api_key, + "API key", + ); } } @@ -374,7 +384,11 @@ pub async fn get_settings(state: State<'_, Arc>) -> Result>, mut settings: Settings) -> Result<(), UiError> { +pub async fn save_settings( + app: tauri::AppHandle, + state: State<'_, Arc>, + mut settings: Settings, +) -> Result<(), UiError> { // One critical section, not five. Each `lock_settings()` is a blocking acquire on // an async worker, and this command runs on every keystroke in the settings panel; // taking the lock five times per call multiplied that contention for no reason. @@ -546,7 +560,12 @@ pub fn migrate_secrets_into_keychain(state: &SettingsState) { } } if let (Some(raw), Some(cloud_config)) = (raw_token, settings.cloud_config.as_mut()) { - if cloud_config.access_token.as_deref().unwrap_or("").is_empty() { + if cloud_config + .access_token + .as_deref() + .unwrap_or("") + .is_empty() + { let recovered = decrypt_legacy_secret(&raw); if !recovered.is_empty() { log::info!("Recovered the cloud token from an older storage format"); diff --git a/src-tauri/src/stashes.rs b/src-tauri/src/stashes.rs index 248689b..ac7e936 100644 --- a/src-tauri/src/stashes.rs +++ b/src-tauri/src/stashes.rs @@ -11,20 +11,24 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. // See the GNU Affero General Public License for more details. +use crate::db::{DbManager, WriteOrigin}; +use crate::models::{Attachment, Context, SaveOptions, Settings, StashItem}; +use crate::state::{DbState, SettingsState}; +use crate::uierror::UiError; +use crate::utils::get_app_dir; +use rusqlite::params; +use rusqlite::OptionalExtension; use std::fs; use std::sync::Arc; use tauri::State; -use rusqlite::params; -use rusqlite::OptionalExtension; -use crate::models::{StashItem, SaveOptions, Attachment, Context, Settings}; -use crate::state::{DbState, SettingsState}; -use crate::utils::get_app_dir; -use crate::db::{DbManager, WriteOrigin}; -use crate::uierror::UiError; pub fn get_effective_position(invert: bool, default_pos: &str) -> &str { if invert { - if default_pos == "bottom" { "top" } else { "bottom" } + if default_pos == "bottom" { + "top" + } else { + "bottom" + } } else { default_pos } @@ -38,26 +42,26 @@ pub fn calculate_stash_update( ) -> (StashItem, Option) { let mut new_stash = stash.clone(); let position_val: Option; - + if let Some(old) = existing { let status_changed = old.completed != stash.completed; - + if status_changed { - if new_stash.completed { - new_stash.completed_at = Some(chrono::Utc::now().to_rfc3339()); - } else { - new_stash.completed_at = None; - } - // Status changed -> Move to top/bottom - if effective_position_str == "bottom" { - position_val = None; // Append to end - } else { - // Top: min pos - 1 - position_val = Some(min_pos.unwrap_or(0.0) - 1.0); - } + if new_stash.completed { + new_stash.completed_at = Some(chrono::Utc::now().to_rfc3339()); + } else { + new_stash.completed_at = None; + } + // Status changed -> Move to top/bottom + if effective_position_str == "bottom" { + position_val = None; // Append to end + } else { + // Top: min pos - 1 + position_val = Some(min_pos.unwrap_or(0.0) - 1.0); + } } else if new_stash.completed && new_stash.completed_at.is_none() { - new_stash.completed_at = old.completed_at.clone(); - position_val = None; // Keep existing pos + new_stash.completed_at = old.completed_at.clone(); + position_val = None; // Keep existing pos } else { position_val = None; // Keep existing pos } @@ -66,12 +70,12 @@ pub fn calculate_stash_update( if new_stash.completed && new_stash.completed_at.is_none() { new_stash.completed_at = Some(chrono::Utc::now().to_rfc3339()); } - + if effective_position_str == "bottom" { position_val = None; // Append } else { - // Top - position_val = Some(min_pos.unwrap_or(0.0) - 1.0); + // Top + position_val = Some(min_pos.unwrap_or(0.0) - 1.0); } } (new_stash, position_val) @@ -79,51 +83,63 @@ pub fn calculate_stash_update( #[tauri::command] pub async fn save_stash( - state: State<'_, Arc>, - settings_state: State<'_, Arc>, - options: SaveOptions + state: State<'_, Arc>, + settings_state: State<'_, Arc>, + options: SaveOptions, ) -> Result<(), UiError> { let stash = options.stash; let invert = options.invert_position; - + // Position Logic for DB let settings = settings_state.lock_settings(); let default_pos = settings.new_stash_position.clone(); - drop(settings); + drop(settings); let effective_position_str = get_effective_position(invert, &default_pos); - + let mut db = state.lock_db(); - + // 1. Get existing stash to check changes - let existing: Option = db.conn.query_row( - "SELECT id, completed, completed_at FROM stashes WHERE id = ?1", - params![stash.id], - |row| { - // Minimal struct for check - Ok(StashItem { - id: row.get(0)?, - context_id: crate::models::DEFAULT_CONTEXT_ID.to_string(), - content: "".into(), - enhanced_content: None, - files: vec![], - attachments: vec![], - created_at: "".into(), - completed: row.get(1)?, - completed_at: row.get(2)?, - updated_at: None, - deleted: false, - }) - } - ).optional().unwrap_or(None); - + let existing: Option = db + .conn + .query_row( + "SELECT id, completed, completed_at FROM stashes WHERE id = ?1", + params![stash.id], + |row| { + // Minimal struct for check + Ok(StashItem { + id: row.get(0)?, + context_id: crate::models::DEFAULT_CONTEXT_ID.to_string(), + content: "".into(), + enhanced_content: None, + files: vec![], + attachments: vec![], + created_at: "".into(), + completed: row.get(1)?, + completed_at: row.get(2)?, + updated_at: None, + deleted: false, + }) + }, + ) + .optional() + .unwrap_or(None); + let min_pos: Option = if effective_position_str == "top" { - db.conn.query_row("SELECT MIN(position) FROM stashes WHERE deleted=0", [], |row| row.get(0)).optional().unwrap_or(None) + db.conn + .query_row( + "SELECT MIN(position) FROM stashes WHERE deleted=0", + [], + |row| row.get(0), + ) + .optional() + .unwrap_or(None) } else { None }; - let (new_stash, position_val) = calculate_stash_update(&stash, existing.as_ref(), effective_position_str, min_pos); + let (new_stash, position_val) = + calculate_stash_update(&stash, existing.as_ref(), effective_position_str, min_pos); if let Err(e) = db.save_stash(&new_stash, position_val, WriteOrigin::LocalEdit) { println!("Failed to save stash: {}", e); @@ -137,18 +153,29 @@ pub async fn load_stashes(state: State<'_, Arc>) -> Result>) -> Result, UiError> { +pub async fn load_stashes_for_sync( + state: State<'_, Arc>, +) -> Result, UiError> { Ok(state.lock_db().get_stashes_for_sync().unwrap_or_default()) } #[tauri::command] -pub async fn get_contexts_for_sync(state: State<'_, Arc>) -> Result, UiError> { +pub async fn get_contexts_for_sync( + state: State<'_, Arc>, +) -> Result, UiError> { Ok(state.lock_db().get_contexts_for_sync().unwrap_or_default()) } #[tauri::command] -pub async fn import_stashes(state: State<'_, Arc>, stashes_list: Vec) -> Result<(), UiError> { - state.lock_db().import_stashes(&stashes_list).map_err(|e| e.to_string()).map_err(UiError::from) +pub async fn import_stashes( + state: State<'_, Arc>, + stashes_list: Vec, +) -> Result<(), UiError> { + state + .lock_db() + .import_stashes(&stashes_list) + .map_err(|e| e.to_string()) + .map_err(UiError::from) } pub fn get_stash_cache_path(id: &str, context_id: Option<&str>) -> std::path::PathBuf { @@ -163,23 +190,27 @@ pub fn get_stash_cache_path(id: &str, context_id: Option<&str>) -> std::path::Pa #[tauri::command] pub async fn delete_stash(state: State<'_, Arc>, id: String) -> Result<(), UiError> { let mut db = state.lock_db(); - + // File cleanup logic (requires querying stash first) // We can do a quick SELECT to get context_id - let stash_info: Option<(String, Option)> = db.conn.query_row( - "SELECT id, context_id FROM stashes WHERE id = ?1", - params![id], - |row| Ok((row.get(0)?, row.get(1)?)) - ).optional().unwrap_or(None); + let stash_info: Option<(String, Option)> = db + .conn + .query_row( + "SELECT id, context_id FROM stashes WHERE id = ?1", + params![id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .unwrap_or(None); if let Some((_, context_id)) = stash_info { let stash_path = get_stash_cache_path(&id, context_id.as_deref()); // delete directory recursively if stash_path.exists() { - if let Err(e) = fs::remove_dir_all(&stash_path) { - println!("Failed to delete stash attachments: {}", e); - } + if let Err(e) = fs::remove_dir_all(&stash_path) { + println!("Failed to delete stash attachments: {}", e); + } } // The files are gone, so the rows must stop claiming to hold them. Left as-is @@ -192,7 +223,7 @@ pub async fn delete_stash(state: State<'_, Arc>, id: String) -> Result< } if let Err(e) = db.delete_stash(&id) { - println!("Failed to delete stash from DB: {}", e); + println!("Failed to delete stash from DB: {}", e); } Ok(()) } @@ -203,7 +234,10 @@ fn safe_component(value: &str) -> String { } #[tauri::command] -pub async fn delete_completed_stashes(state: State<'_, Arc>, context_id: Option) -> Result<(), UiError> { +pub async fn delete_completed_stashes( + state: State<'_, Arc>, + context_id: Option, +) -> Result<(), UiError> { // Collect under the lock, delete files with the lock released, then write back. // // This used to hold the global database mutex across a `remove_dir_all` per stash - @@ -243,7 +277,9 @@ pub async fn delete_completed_stashes(state: State<'_, Arc>, context_id .iter() .map(|(id, ctx_id_opt)| { let ctx_id = ctx_id_opt.as_deref().unwrap_or("default"); - cache_dir.join(safe_component(ctx_id)).join(safe_component(id)) + cache_dir + .join(safe_component(ctx_id)) + .join(safe_component(id)) }) .collect(); @@ -366,7 +402,10 @@ pub fn perform_startup_cleanup(db: &mut DbManager, settings: &Settings) -> usize match settings.clear_completed_strategy.as_str() { "on-close" => { let stale = completed_stashes(db, None); - log::info!("Startup cleanup: clearing {} completed stash(es)", stale.len()); + log::info!( + "Startup cleanup: clearing {} completed stash(es)", + stale.len() + ); purge_stash_files(db, &stale); // One by one rather than `delete_completed_stashes`, which clears every // completed stash and would ignore the references that kept some back. @@ -433,7 +472,10 @@ pub async fn write_reference_file( } #[tauri::command] -pub async fn save_stashes(state: State<'_, Arc>, stashes_list: Vec) -> Result<(), UiError> { +pub async fn save_stashes( + state: State<'_, Arc>, + stashes_list: Vec, +) -> Result<(), UiError> { // This is used for REORDERING, which rewrites a row per visible stash. println!("Saving stash order ({} items)", stashes_list.len()); let mut db = state.lock_db(); @@ -442,9 +484,12 @@ pub async fn save_stashes(state: State<'_, Arc>, stashes_list: Vec>, settings_state: State<'_, Arc>) -> Result { +pub async fn trigger_auto_cleanup( + state: State<'_, Arc>, + settings_state: State<'_, Arc>, +) -> Result { // Copy the settings out before taking the database lock. Holding both at once was // the only place in the codebase that established the reverse ordering, and this // command fires every five minutes from the frontend, so it was a standing @@ -453,14 +498,14 @@ pub async fn trigger_auto_cleanup(state: State<'_, Arc>, settings_state let mut db = state.lock_db(); Ok(perform_startup_cleanup(&mut db, &settings) as u32) } - + /// Saves an asset file to the cache directory. -/// +/// /// Files are organized in a hierarchical folder structure: /// - If both context_id and stash_id are provided: `cache///` /// - If only context_id is provided: `cache//` /// - Otherwise: `cache/` (backwards compatibility) -/// +/// /// This structure prevents file name collisions and allows for proper cleanup /// when stashes or contexts are deleted. /// Metadata that travels alongside a raw asset upload. @@ -595,7 +640,9 @@ async fn write_asset( }; // Simple mime guess or default - let mime_type = mime_guess::from_path(&file_path).first().map(|m| m.to_string()); + let mime_type = mime_guess::from_path(&file_path) + .first() + .map(|m| m.to_string()); use uuid::Uuid; let att_id = Uuid::new_v4().to_string(); let created_at = chrono::Utc::now().to_rfc3339(); @@ -640,7 +687,7 @@ async fn write_asset( } /// Imports an asset from an external file path into the cache directory. -/// +/// /// Files are organized in a hierarchical folder structure: /// - If both context_id and stash_id are provided: `cache///` /// - If only context_id is provided: `cache//` @@ -648,13 +695,13 @@ async fn write_asset( #[tauri::command] pub async fn save_asset_from_path( state: State<'_, Arc>, - path: String, - context_id: Option, + path: String, + context_id: Option, stash_id: Option, - syntax: Option + syntax: Option, ) -> Result { println!( - "Importing asset from path: {} context: {:?} stash: {:?}", + "Importing asset from path: {} context: {:?} stash: {:?}", path, context_id, stash_id ); let source_path = std::path::Path::new(&path); @@ -728,7 +775,9 @@ pub async fn save_asset_from_path( }; // Simple mime guess or default - let mime_type = mime_guess::from_path(&dest_path).first().map(|m| m.to_string()); + let mime_type = mime_guess::from_path(&dest_path) + .first() + .map(|m| m.to_string()); use uuid::Uuid; let att_id = Uuid::new_v4().to_string(); let created_at = chrono::Utc::now().to_rfc3339(); @@ -750,9 +799,12 @@ pub async fn save_asset_from_path( ); if let Err(e) = res { - println!("Failed to save attachment metadata (likely due to missing stash parent): {}", e); - // Suppress error so frontend receives the Attachment object. - // The attachment will be saved to DB when save_stash is called. + println!( + "Failed to save attachment metadata (likely due to missing stash parent): {}", + e + ); + // Suppress error so frontend receives the Attachment object. + // The attachment will be saved to DB when save_stash is called. } } @@ -841,8 +893,7 @@ pub async fn delete_asset( } if file_path.exists() { - fs::remove_file(file_path) - .map_err(|e| format!("Failed to delete file: {}", e))?; + fs::remove_file(file_path).map_err(|e| format!("Failed to delete file: {}", e))?; } println!("Successfully deleted asset: {}", path); @@ -855,7 +906,9 @@ pub async fn delete_asset( /// - Text files: Returns first 10KB of content /// - Other: Returns unsupported type indicator #[tauri::command] -pub async fn read_file_for_preview(path: String) -> Result { +pub async fn read_file_for_preview( + path: String, +) -> Result { // On the blocking pool, not the async worker: this canonicalizes a path, reads a // whole file, and base64-encodes it into a String. A large screenshot is tens of // megabytes of allocation and encoding, and Tokio does not move a task that blocks @@ -868,16 +921,18 @@ pub async fn read_file_for_preview(path: String) -> Result Result { let file_path = std::path::Path::new(&path); - + // Security: validate that the path is within the cache directory // to prevent arbitrary file reads via IPC let cache_dir = get_app_dir().join("cache"); - let canonical_path = file_path.canonicalize().map_err(|_| "File does not exist")?; + let canonical_path = file_path + .canonicalize() + .map_err(|_| "File does not exist")?; let canonical_cache = cache_dir.canonicalize().unwrap_or(cache_dir); if !canonical_path.starts_with(&canonical_cache) { return Err("Access denied: file outside cache directory".into()); } - + if !file_path.exists() { return Err("File does not exist".into()); } @@ -906,7 +961,7 @@ fn read_file_for_preview_blocking(path: String) -> Result ("image", "image/svg+xml"), "bmp" => ("image", "image/bmp"), "ico" => ("image", "image/x-icon"), - + // Video types "mp4" => ("video", "video/mp4"), "webm" => ("video", "video/webm"), @@ -914,7 +969,7 @@ fn read_file_for_preview_blocking(path: String) -> Result ("video", "video/quicktime"), "avi" => ("video", "video/x-msvideo"), "mkv" => ("video", "video/x-matroska"), - + // Text and code types "txt" | "md" | "markdown" => ("text", "text/plain"), "json" => ("text", "application/json"), @@ -942,7 +997,7 @@ fn read_file_for_preview_blocking(path: String) -> Result ("text", "text/x-sql"), "svelte" => ("text", "text/x-svelte"), "vue" => ("text", "text/x-vue"), - + _ => ("unsupported", "application/octet-stream"), }; @@ -951,7 +1006,7 @@ fn read_file_for_preview_blocking(path: String) -> Result { - use base64::{Engine as _, engine::general_purpose}; + use base64::{engine::general_purpose, Engine as _}; let b64 = general_purpose::STANDARD.encode(&data); format!("data:{};base64,{}", mime_type, b64) } @@ -994,7 +1049,9 @@ fn read_file_for_preview_blocking(path: String) -> Result>) -> Result, UiError> { +pub async fn claim_pending_stashes( + state: State<'_, Arc>, +) -> Result, UiError> { Ok(state.lock_db().claim_pending_stashes().unwrap_or_default()) } @@ -1065,7 +1122,9 @@ mod reference_cleanup_tests { const C: &str = "cccccccc-0000-4000-8000-000000000003"; fn db() -> DbManager { - let db = DbManager { conn: Connection::open_in_memory().unwrap() }; + let db = DbManager { + conn: Connection::open_in_memory().unwrap(), + }; db.init_tables().unwrap(); db } @@ -1126,11 +1185,16 @@ mod reference_cleanup_tests { let db = db(); insert(&db, A, &format!("[b](stash:{B})"), false); insert(&db, B, "done", true); - db.conn.execute("UPDATE stashes SET deleted = 1 WHERE id = ?1", params![A]).unwrap(); + db.conn + .execute("UPDATE stashes SET deleted = 1 WHERE id = ?1", params![A]) + .unwrap(); assert_eq!(clearable(&db), vec![B.to_string()]); db.conn - .execute("UPDATE stashes SET deleted = 0, context_id = 'other' WHERE id = ?1", params![A]) + .execute( + "UPDATE stashes SET deleted = 0, context_id = 'other' WHERE id = ?1", + params![A], + ) .unwrap(); assert_eq!(clearable(&db), vec![B.to_string()]); } diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 3f0fbb0..b850200 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -11,9 +11,9 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. // See the GNU Affero General Public License for more details. -use std::sync::{Arc, Mutex, MutexGuard}; use crate::db::DbManager; use crate::models::{AppContext, Settings}; +use std::sync::{Arc, Mutex, MutexGuard}; /// Take a lock, recovering the guard if a previous holder panicked. /// diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index 87900fb..d2e87e6 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -11,17 +11,17 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. // See the GNU Affero General Public License for more details. -use std::sync::Arc; -use std::time::Duration; -use tauri::State; +use crate::models::{default_cloud_endpoint, Attachment, CloudConfig}; +use crate::settings::persist_settings_off_thread; +use crate::state::{lock_or_recover, DbState, SettingsState, WsState}; +use crate::uierror::UiError; +use crate::utils::get_app_dir; use rusqlite::params; use rusqlite::OptionalExtension; use std::fs; -use crate::models::{CloudConfig, Attachment, default_cloud_endpoint}; -use crate::state::{SettingsState, DbState, WsState, lock_or_recover}; -use crate::settings::persist_settings_off_thread; -use crate::utils::get_app_dir; -use crate::uierror::UiError; +use std::sync::Arc; +use std::time::Duration; +use tauri::State; /// How long a JSON API call may take before it is abandoned. /// @@ -128,7 +128,10 @@ pub async fn fetch_cloud_account( ) -> Result { let (endpoint, token) = { let settings = settings_state.lock_settings(); - let config = settings.cloud_config.as_ref().ok_or("Cloud config missing")?; + let config = settings + .cloud_config + .as_ref() + .ok_or("Cloud config missing")?; let token = config.access_token.clone().ok_or("Not authenticated")?; (config.endpoint.clone(), token) }; @@ -151,7 +154,9 @@ pub async fn fetch_cloud_account( absorb_refreshed_token(&settings_state, &response).await; - let account: serde_json::Value = response.json().await + let account: serde_json::Value = response + .json() + .await .map_err(|e| format!("Failed to parse account: {}", e))?; // Update local config with subscription info. The lock is dropped before the write: @@ -163,8 +168,12 @@ pub async fn fetch_cloud_account( return Err("Cloud config not found".into()); }; config.subscription_tier = account["subscriptionTier"].as_str().map(|s| s.to_string()); - config.subscription_status = account["subscriptionStatus"].as_str().map(|s| s.to_string()); - config.subscription_period_end = account["subscriptionPeriodEnd"].as_str().map(|s| s.to_string()); + config.subscription_status = account["subscriptionStatus"] + .as_str() + .map(|s| s.to_string()); + config.subscription_period_end = account["subscriptionPeriodEnd"] + .as_str() + .map(|s| s.to_string()); config.enterprise_owner_id = account["enterpriseOwnerId"].as_str().map(|s| s.to_string()); (config.clone(), settings.clone()) @@ -185,13 +194,19 @@ pub async fn exchange_link_code_api( ) -> Result { let endpoint = { let settings = settings_state.lock_settings(); - let config = settings.cloud_config.as_ref().ok_or("Cloud config missing")?; + let config = settings + .cloud_config + .as_ref() + .ok_or("Cloud config missing")?; config.endpoint.clone() }; let client = api_client()?; let response = client - .post(format!("{}/auth/exchange-token", endpoint.trim_end_matches('/'))) + .post(format!( + "{}/auth/exchange-token", + endpoint.trim_end_matches('/') + )) .header("Content-Type", "application/json") // The device id ties the issued token to this installation, so the account page // can revoke this instance on its own instead of every session at once. @@ -229,9 +244,7 @@ pub async fn exchange_link_code_api( .ok_or("Missing token in response")? .to_string(); - let user_id_val = data["user_id"] - .as_str() - .map(|s| s.to_string()); + let user_id_val = data["user_id"].as_str().map(|s| s.to_string()); // Same as above: mutate under the lock, then release it before the blocking write. let (config, snapshot) = { @@ -264,7 +277,6 @@ pub async fn exchange_link_code_api( Ok(return_config) } - #[tauri::command] pub async fn sync_stashes_api( settings_state: State<'_, Arc>, @@ -272,7 +284,10 @@ pub async fn sync_stashes_api( ) -> Result { let (endpoint, token, user_id) = { let settings = settings_state.lock_settings(); - let config = settings.cloud_config.as_ref().ok_or("Cloud config missing")?; + let config = settings + .cloud_config + .as_ref() + .ok_or("Cloud config missing")?; let token = config.access_token.clone().ok_or("Not authenticated")?; ( config.endpoint.clone(), @@ -358,7 +373,10 @@ pub async fn e2ee_get( ) -> Result { let (endpoint, token) = { let settings = settings_state.lock_settings(); - let config = settings.cloud_config.as_ref().ok_or("Cloud config missing")?; + let config = settings + .cloud_config + .as_ref() + .ok_or("Cloud config missing")?; let token = config.access_token.clone().ok_or("Not authenticated")?; (config.endpoint.clone(), token) }; @@ -391,7 +409,10 @@ pub async fn e2ee_post( ) -> Result { let (endpoint, token) = { let settings = settings_state.lock_settings(); - let config = settings.cloud_config.as_ref().ok_or("Cloud config missing")?; + let config = settings + .cloud_config + .as_ref() + .ok_or("Cloud config missing")?; let token = config.access_token.clone().ok_or("Not authenticated")?; (config.endpoint.clone(), token) }; @@ -431,7 +452,10 @@ pub async fn upload_attachment_to_cloud( ) -> Result { let (endpoint, token) = { let settings = settings_state.lock_settings(); - let config = settings.cloud_config.as_ref().ok_or("Cloud config missing")?; + let config = settings + .cloud_config + .as_ref() + .ok_or("Cloud config missing")?; let token = config.access_token.clone().ok_or("Not authenticated")?; (config.endpoint.clone(), token) }; @@ -455,8 +479,10 @@ pub async fn upload_attachment_to_cloud( }, uploaded_at.is_some(), )) - }).optional().map_err(|e| e.to_string())? - .ok_or_else(|| "Attachment not found".to_string())? + }) + .optional() + .map_err(|e| e.to_string())? + .ok_or_else(|| "Attachment not found".to_string())? }; // Idempotency: without this every sync re-PUT the full body of every attachment @@ -533,7 +559,7 @@ pub async fn upload_attachment_to_cloud( } let client = transfer_client()?; - + // 1. Read the file, and seal it when this account is encrypted. // // This has to happen before presigning. `file_size` is what `confirm_upload` compares @@ -545,7 +571,10 @@ pub async fn upload_attachment_to_cloud( .await .map_err(|e| format!("Attachment read task failed: {}", e))? .map_err(|e| { - let msg = format!("Failed to read attachment file {}: {}", attachment.file_path, e); + let msg = format!( + "Failed to read attachment file {}: {}", + attachment.file_path, e + ); log::error!("[Attachment] {}", msg); msg })?; @@ -598,7 +627,10 @@ pub async fn upload_attachment_to_cloud( }); let upload_url_resp = client - .post(format!("{}/attachments/upload", endpoint.trim_end_matches('/'))) + .post(format!( + "{}/attachments/upload", + endpoint.trim_end_matches('/') + )) .header("Authorization", format!("Bearer {}", token)) .json(&upload_req) .send() @@ -606,19 +638,25 @@ pub async fn upload_attachment_to_cloud( .map_err(|e| format!("Failed to get upload URL: {}", e))?; let status = upload_url_resp.status(); - let resp_text = upload_url_resp.text().await + let resp_text = upload_url_resp + .text() + .await .map_err(|e| format!("Failed to read upload URL response: {}", e))?; if !status.is_success() { - let msg = format!("Cloud rejected upload request for {}: {} - {}", attachment.id, status, resp_text); + let msg = format!( + "Cloud rejected upload request for {}: {} - {}", + attachment.id, status, resp_text + ); log::error!("[Attachment] {}", msg); return Err(msg.into()); } let upload_data: serde_json::Value = serde_json::from_str(&resp_text) .map_err(|e| format!("Failed to parse upload URL response: {}", e))?; - - let upload_url = upload_data["uploadUrl"].as_str() + + let upload_url = upload_data["uploadUrl"] + .as_str() .ok_or_else(|| "No upload URL in response".to_string())?; // The bytes were read and sealed above, before presigning, because the size @@ -637,7 +675,11 @@ pub async fn upload_attachment_to_cloud( .map_err(|e| format!("Failed to upload file to storage: {}", e))?; if !put_resp.status().is_success() { - let msg = format!("Storage rejected the file for {}: {}", attachment.id, put_resp.status()); + let msg = format!( + "Storage rejected the file for {}: {}", + attachment.id, + put_resp.status() + ); log::error!("[Attachment] {}", msg); return Err(msg.into()); } @@ -666,7 +708,11 @@ pub async fn upload_attachment_to_cloud( return Err(msg.into()); } - log::info!("[Attachment] Uploaded {} ({})", attachment.id, attachment.file_name); + log::info!( + "[Attachment] Uploaded {} ({})", + attachment.id, + attachment.file_name + ); // 5. Record locally so we never re-upload these bytes. { @@ -695,7 +741,10 @@ pub async fn download_attachment_from_cloud( ) -> Result { let (endpoint, token) = { let settings = settings_state.lock_settings(); - let config = settings.cloud_config.as_ref().ok_or("Cloud config missing")?; + let config = settings + .cloud_config + .as_ref() + .ok_or("Cloud config missing")?; let token = config.access_token.clone().ok_or("Not authenticated")?; (config.endpoint.clone(), token) }; @@ -752,8 +801,8 @@ pub async fn download_attachment_from_cloud( return Err(format!("Cloud rejected download request: {} - {}", status, body).into()); } - let data: serde_json::Value = - serde_json::from_str(&body).map_err(|e| format!("Failed to parse download response: {}", e))?; + let data: serde_json::Value = serde_json::from_str(&body) + .map_err(|e| format!("Failed to parse download response: {}", e))?; let download_url = data["downloadUrl"] .as_str() .ok_or_else(|| "No download URL in response".to_string())?; @@ -789,7 +838,8 @@ pub async fn download_attachment_from_cloud( .unwrap_or_default() }; - let details = crate::e2ee_session::open_attachment_metadata(&file_name, &attachment_id, &user_id)?; + let details = + crate::e2ee_session::open_attachment_metadata(&file_name, &attachment_id, &user_id)?; let (bytes, file_name) = match details { Some(details) => { @@ -836,8 +886,8 @@ pub async fn download_attachment_from_cloud( let write_temp = temp.clone(); let write_dir = dir.clone(); let desired_name = file_name.clone(); - let target = tauri::async_runtime::spawn_blocking( - move || -> Result { + let target = + tauri::async_runtime::spawn_blocking(move || -> Result { fs::write(&write_temp, &bytes) .map_err(|e| format!("Failed to write attachment: {}", e))?; @@ -846,8 +896,8 @@ pub async fn download_attachment_from_cloud( // look like a finished attachment to every `exists()` check in the codebase. // Downloading straight onto `dir.join(&file_name)` was the other way two // attachments of one name came to share a single file. - let target = crate::utils::reserve_unique_path(&write_dir, &desired_name) - .map_err(|e| { + let target = + crate::utils::reserve_unique_path(&write_dir, &desired_name).map_err(|e| { let _ = fs::remove_file(&write_temp); format!("Failed to reserve attachment path: {}", e) })?; @@ -858,10 +908,9 @@ pub async fn download_attachment_from_cloud( return Err(format!("Failed to finalise attachment: {}", e).into()); } Ok(target) - }, - ) - .await - .map_err(|e| format!("Attachment write task failed: {}", e))??; + }) + .await + .map_err(|e| format!("Attachment write task failed: {}", e))??; let path_str = target.to_string_lossy().to_string(); @@ -887,7 +936,10 @@ pub async fn sync_contexts_api( ) -> Result { let (endpoint, token, user_id) = { let settings = settings_state.lock_settings(); - let config = settings.cloud_config.as_ref().ok_or("Cloud config missing")?; + let config = settings + .cloud_config + .as_ref() + .ok_or("Cloud config missing")?; let token = config.access_token.clone().ok_or("Not authenticated")?; ( config.endpoint.clone(), @@ -967,8 +1019,15 @@ pub async fn connect_websocket( let (endpoint, token, enabled) = { let settings = settings_state.lock_settings(); - let config = settings.cloud_config.as_ref().ok_or("Cloud config missing")?; - (config.endpoint.clone(), config.access_token.clone(), config.enabled) + let config = settings + .cloud_config + .as_ref() + .ok_or("Cloud config missing")?; + ( + config.endpoint.clone(), + config.access_token.clone(), + config.enabled, + ) }; if !enabled || token.is_none() { @@ -980,9 +1039,13 @@ pub async fn connect_websocket( let ws_endpoint = endpoint .replace("http://", "ws://") .replace("https://", "wss://"); - + // Append the token to the URL query string - let ws_url = format!("{}/ws?token={}", ws_endpoint.trim_end_matches('/'), urlencoding::encode(&token)); + let ws_url = format!( + "{}/ws?token={}", + ws_endpoint.trim_end_matches('/'), + urlencoding::encode(&token) + ); // Spawn a persistent task for the WebSocket connection with reconnect logic let task_app = app.clone(); @@ -992,8 +1055,8 @@ pub async fn connect_websocket( let task_settings: Arc = (*settings_state).clone(); let handle = tauri::async_runtime::spawn(async move { use futures_util::{SinkExt, StreamExt}; - use tokio_tungstenite::connect_async; use tauri::Emitter; + use tokio_tungstenite::connect_async; // How often to ping the server. Idle WebSockets through a NAT or proxy are // dropped silently; without traffic the client believes it is still connected @@ -1017,10 +1080,12 @@ pub async fn connect_websocket( // fell back to the 15-minute poll, which reads as sync being broken. let attempt = tokio::time::timeout(WS_CONNECT_TIMEOUT, connect_async(ws_url.clone())); match attempt.await.unwrap_or_else(|_| { - Err(tokio_tungstenite::tungstenite::Error::Io(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "WebSocket handshake timed out", - ))) + Err(tokio_tungstenite::tungstenite::Error::Io( + std::io::Error::new( + std::io::ErrorKind::TimedOut, + "WebSocket handshake timed out", + ), + )) }) { Ok((ws_stream, _)) => { log::info!("[WebSocket] Connected successfully"); @@ -1041,9 +1106,9 @@ pub async fn connect_websocket( match crate::e2ee_enrol::unlock_this_installation(&unlock_settings) .await { - Ok(true) => log::info!( - "Content key opened after the connection came back" - ), + Ok(true) => { + log::info!("Content key opened after the connection came back") + } Ok(false) => {} Err(e) => log::warn!( "Could not open the content key after reconnecting: {}", @@ -1154,7 +1219,10 @@ pub async fn fetch_cloud_usage( ) -> Result { let (endpoint, token) = { let settings = settings_state.lock_settings(); - let config = settings.cloud_config.as_ref().ok_or("Cloud config missing")?; + let config = settings + .cloud_config + .as_ref() + .ok_or("Cloud config missing")?; let token = config.access_token.clone().ok_or("Not authenticated")?; (config.endpoint.clone(), token) }; @@ -1219,7 +1287,10 @@ mod tests { assert!(!body.is_char_boundary(ERROR_SNIPPET_CHARS)); let snippet = error_snippet(&body); - assert_eq!(snippet.chars().filter(|c| *c == '€').count(), ERROR_SNIPPET_CHARS); + assert_eq!( + snippet.chars().filter(|c| *c == '€').count(), + ERROR_SNIPPET_CHARS + ); } #[test] @@ -1277,8 +1348,7 @@ enum PlaintextSource { } fn plaintext_source(local_path: Option<&str>, damaged: bool) -> PlaintextSource { - match local_path.filter(|path| !path.trim().is_empty() && std::path::Path::new(path).exists()) - { + match local_path.filter(|path| !path.trim().is_empty() && std::path::Path::new(path).exists()) { Some(path) => PlaintextSource::Local(path.to_string()), None if damaged => PlaintextSource::Unrecoverable, None => PlaintextSource::Server, @@ -1332,7 +1402,11 @@ pub async fn convert_attachments_to_encrypted( let mut offset: i64 = 0; let mut first_page = true; loop { - let page = e2ee_get(&settings, &format!("/attachments/unsealed?offset={}", offset)).await?; + let page = e2ee_get( + &settings, + &format!("/attachments/unsealed?offset={}", offset), + ) + .await?; if !page["encrypted"].as_bool().unwrap_or(false) { ATTACHMENTS_SETTLED.store(true, Ordering::Relaxed); @@ -1380,8 +1454,10 @@ pub async fn convert_attachments_to_encrypted( .map_err(|e| e.to_string())? }; - let source = - plaintext_source(local_path.as_deref(), item["damaged"].as_bool().unwrap_or(false)); + let source = plaintext_source( + local_path.as_deref(), + item["damaged"].as_bool().unwrap_or(false), + ); if source == PlaintextSource::Unrecoverable { report.unrecoverable += 1; skipped += 1; @@ -1427,10 +1503,12 @@ async fn reencrypt_attachment( let id = item["id"].as_str().ok_or("attachment without an id")?; let plaintext = match source { - PlaintextSource::Local(path) => tauri::async_runtime::spawn_blocking(move || fs::read(path)) - .await - .map_err(|e| format!("Attachment read task failed: {}", e))? - .map_err(|e| format!("Could not read the local copy: {}", e))?, + PlaintextSource::Local(path) => { + tauri::async_runtime::spawn_blocking(move || fs::read(path)) + .await + .map_err(|e| format!("Attachment read task failed: {}", e))? + .map_err(|e| format!("Could not read the local copy: {}", e))? + } PlaintextSource::Server => { let link = e2ee_get(settings, &format!("/attachments/{}", id)).await?; let url = link["downloadUrl"] @@ -1512,9 +1590,15 @@ mod conversion_tests { fs::write(&file, b"x").unwrap(); let path = file.to_string_lossy().to_string(); - assert_eq!(plaintext_source(Some(&path), false), PlaintextSource::Local(path.clone())); + assert_eq!( + plaintext_source(Some(&path), false), + PlaintextSource::Local(path.clone()) + ); // Even for sealed bytes whose key was lost: the local copy is the rescue. - assert_eq!(plaintext_source(Some(&path), true), PlaintextSource::Local(path.clone())); + assert_eq!( + plaintext_source(Some(&path), true), + PlaintextSource::Local(path.clone()) + ); fs::remove_file(&file).ok(); } @@ -1522,13 +1606,19 @@ mod conversion_tests { fn without_a_local_copy_only_plaintext_can_come_from_the_server() { assert_eq!(plaintext_source(None, false), PlaintextSource::Server); assert_eq!(plaintext_source(Some(""), false), PlaintextSource::Server); - assert_eq!(plaintext_source(Some("/no/such/file"), false), PlaintextSource::Server); + assert_eq!( + plaintext_source(Some("/no/such/file"), false), + PlaintextSource::Server + ); assert_eq!(plaintext_source(None, true), PlaintextSource::Unrecoverable); } #[test] fn a_sealed_upload_tells_storage_nothing_about_its_type() { - assert_eq!(upload_content_type(true, Some("image/png")), "application/octet-stream"); + assert_eq!( + upload_content_type(true, Some("image/png")), + "application/octet-stream" + ); assert_eq!(upload_content_type(false, Some("image/png")), "image/png"); assert_eq!(upload_content_type(false, None), "application/octet-stream"); } diff --git a/src-tauri/src/transfer.rs b/src-tauri/src/transfer.rs index caf8349..5c348a6 100644 --- a/src-tauri/src/transfer.rs +++ b/src-tauri/src/transfer.rs @@ -36,8 +36,8 @@ use uuid::Uuid; use crate::models::{Attachment, Context, StashItem}; use crate::stashes::get_stash_cache_path; use crate::state::DbState; -use crate::utils::get_app_dir; use crate::uierror::UiError; +use crate::utils::get_app_dir; /// Name of the markdown document inside an archive. const MARKDOWN_ENTRY: &str = "export.md"; @@ -259,8 +259,7 @@ fn build_markdown( ) -> String { let mut out = String::new(); - let frontmatter = - serde_yaml::to_string(metadata).unwrap_or_else(|_| "name: ''\n".to_string()); + let frontmatter = serde_yaml::to_string(metadata).unwrap_or_else(|_| "name: ''\n".to_string()); out.push_str("---\n"); out.push_str(frontmatter.trim()); out.push_str("\n---\n\n"); @@ -286,7 +285,10 @@ fn build_markdown( out.push_str(&format!("## {} Stashes ({})\n\n", title, group.len())); for stash in group.iter() { - out.push_str(&format!("### {}\n\n", format_heading_date(&stash.created_at))); + out.push_str(&format!( + "### {}\n\n", + format_heading_date(&stash.created_at) + )); // The id, so references between stashes in this archive can be pointed at // the new ids an import gives them. A comment renders as nothing, so the // document still reads cleanly, and older builds import it as invisible text. @@ -378,7 +380,10 @@ const STASH_ID_MARKER: &str = "")?; + let id = line + .trim() + .strip_prefix(STASH_ID_MARKER)? + .strip_suffix(" -->")?; (!id.is_empty() && !id.contains(char::is_whitespace)).then_some(id) } @@ -578,7 +583,11 @@ fn archived_reference(extract_dir: &Path, reference: &str) -> Option<(PathBuf, S // --------------------------------------------------------------------------- fn normalise(content: &str) -> String { - content.split_whitespace().collect::>().join(" ").to_lowercase() + content + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() } /// Jaccard similarity over word sets - the same measure the webview used, moved here @@ -627,10 +636,12 @@ fn transfer_temp_root() -> PathBuf { /// Reject an archive entry whose name would escape the directory it is extracted into. fn safe_entry_path(base: &Path, name: &str) -> Option { let candidate = Path::new(name); - if candidate - .components() - .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::RootDir)) - { + if candidate.components().any(|c| { + matches!( + c, + std::path::Component::ParentDir | std::path::Component::RootDir + ) + }) { return None; } Some(base.join(candidate)) @@ -660,9 +671,7 @@ pub async fn export_context_archive( let all = db.get_stashes().map_err(|e| e.to_string())?; let selected: Vec = all .into_iter() - .filter(|s| { - s.context_id == context_id && wanted.contains(&s.id) - }) + .filter(|s| s.context_id == context_id && wanted.contains(&s.id)) .collect(); (context, selected) @@ -808,7 +817,8 @@ fn write_zip(path: &Path, markdown: &str, files: &[(String, String)]) -> Result< fs::File::open(source).map_err(|e| format!("Failed to read {}: {}", source, e))?; zip.start_file(entry.as_str(), options) .map_err(|e| e.to_string())?; - std::io::copy(&mut src, &mut zip).map_err(|e| format!("Failed to read {}: {}", source, e))?; + std::io::copy(&mut src, &mut zip) + .map_err(|e| format!("Failed to read {}: {}", source, e))?; } zip.finish().map_err(|e| e.to_string())?; @@ -838,12 +848,13 @@ pub async fn read_import_archive( // Inflating the archive to disk is blocking work, so it runs on the blocking pool. let extract_dir = temp_dir.clone(); let markdown = tauri::async_runtime::spawn_blocking(move || -> Result { - fs::create_dir_all(&extract_dir) - .map_err(|e| format!("Failed to prepare import: {}", e))?; + fs::create_dir_all(&extract_dir).map_err(|e| format!("Failed to prepare import: {}", e))?; if is_zip { extract_archive(&source, &extract_dir) } else { - fs::read_to_string(&source).map_err(|e| format!("Failed to read file: {}", e)).map_err(UiError::from) + fs::read_to_string(&source) + .map_err(|e| format!("Failed to read file: {}", e)) + .map_err(UiError::from) } }) .await @@ -909,7 +920,10 @@ fn extract_archive(source: &Path, dest: &Path) -> Result { // Attachments are written out under their archive names; the parser refers to // them by the same names, minus the stash-id prefix. let Some(target) = safe_entry_path(dest, &name) else { - log::warn!("[Import] refusing archive entry with a traversing path: {}", name); + log::warn!( + "[Import] refusing archive entry with a traversing path: {}", + name + ); continue; }; @@ -920,7 +934,8 @@ fn extract_archive(source: &Path, dest: &Path) -> Result { std::io::copy(&mut entry, &mut out).map_err(|e| e.to_string())?; } - markdown.ok_or_else(|| "The archive contains no markdown document".to_string()) + markdown + .ok_or_else(|| "The archive contains no markdown document".to_string()) .map_err(UiError::from) } @@ -1111,7 +1126,12 @@ mod tests { let b = "bbbbbbbb-0000-4000-8000-000000000002"; let outside = "cccccccc-0000-4000-8000-000000000003"; let stashes = vec![ - stash(a, &format!("see [b](stash:{b}) and [c](stash:{outside})"), "2026-08-18T10:00:00Z", false), + stash( + a, + &format!("see [b](stash:{b}) and [c](stash:{outside})"), + "2026-08-18T10:00:00Z", + false, + ), stash(b, "the target", "2026-08-17T09:30:00Z", true), ]; @@ -1138,7 +1158,10 @@ mod tests { // A genuine prefix is still stripped. assert_eq!(strip_archive_prefix("abcdef12_shot.png"), "shot.png"); // Eight characters that are not hex are left alone. - assert_eq!(strip_archive_prefix("zzzzzzzz_shot.png"), "zzzzzzzz_shot.png"); + assert_eq!( + strip_archive_prefix("zzzzzzzz_shot.png"), + "zzzzzzzz_shot.png" + ); // The attachment-id prefix archives are written with now. assert_eq!( strip_archive_prefix("0cdf01cd-f5be-49a2-840b-cd1d12f43a42_shot.png"), @@ -1177,10 +1200,17 @@ mod tests { #[test] fn attachment_names_round_trip_without_their_archive_prefix() { let mut item = stash("abcdef12", "has a file", "2026-08-18T10:00:00Z", false); - item.attachments.push(attachment(ATT_A, "abcdef12", "shot.png")); + item.attachments + .push(attachment(ATT_A, "abcdef12", "shot.png")); let items = [item]; - let md = build_markdown("Work", &metadata(), &items, Some(&archived_for(&items)), Utc::now()); + let md = build_markdown( + "Work", + &metadata(), + &items, + Some(&archived_for(&items)), + Utc::now(), + ); let entry = format!("attachments/{}_shot.png", ATT_A); assert!(md.contains(&format!("- [shot.png]({})", entry)), "{}", md); @@ -1191,7 +1221,10 @@ mod tests { let (source, name) = archived_reference(Path::new("/tmp/import"), &entry).unwrap(); assert_eq!(name, "shot.png"); - assert_eq!(source, Path::new("/tmp/import/attachments").join(format!("{}_shot.png", ATT_A))); + assert_eq!( + source, + Path::new("/tmp/import/attachments").join(format!("{}_shot.png", ATT_A)) + ); } /// Two pasted `image.png`s in one stash used to share an entry, and the zip writer @@ -1199,8 +1232,10 @@ mod tests { #[test] fn two_attachments_of_one_name_get_their_own_entries() { let mut item = stash("b53d26f6", "two images", "2026-09-25T12:49:10Z", false); - item.attachments.push(attachment(ATT_A, "b53d26f6", "image.png")); - item.attachments.push(attachment(ATT_B, "b53d26f6", "image.png")); + item.attachments + .push(attachment(ATT_A, "b53d26f6", "image.png")); + item.attachments + .push(attachment(ATT_B, "b53d26f6", "image.png")); let files = archived_files(&item); assert_eq!(files.len(), 2); @@ -1210,8 +1245,14 @@ mod tests { #[test] fn an_entry_name_cannot_make_a_directory() { - assert_eq!(archive_entry_name(ATT_A, "../a/b\\c.png"), format!("{}_.._a_b_c.png", ATT_A)); - assert_eq!(archive_entry_name(ATT_A, " "), format!("{}_attachment", ATT_A)); + assert_eq!( + archive_entry_name(ATT_A, "../a/b\\c.png"), + format!("{}_.._a_b_c.png", ATT_A) + ); + assert_eq!( + archive_entry_name(ATT_A, " "), + format!("{}_attachment", ATT_A) + ); } /// Archives written before this change link `<8 chars of stash id>_`. @@ -1255,8 +1296,14 @@ old fs::write(&second, b"second").unwrap(); let files = vec![ - (first.to_string_lossy().into_owned(), format!("attachments/{}_image.png", ATT_A)), - (second.to_string_lossy().into_owned(), format!("attachments/{}_image.png", ATT_B)), + ( + first.to_string_lossy().into_owned(), + format!("attachments/{}_image.png", ATT_A), + ), + ( + second.to_string_lossy().into_owned(), + format!("attachments/{}_image.png", ATT_B), + ), ]; let out = dir.join("export.zip"); write_zip(&out, "# doc", &files).unwrap(); @@ -1264,7 +1311,10 @@ old let mut zip = zip::ZipArchive::new(fs::File::open(&out).unwrap()).unwrap(); assert_eq!(zip.len(), 3); let mut body = String::new(); - zip.by_name(&files[1].1).unwrap().read_to_string(&mut body).unwrap(); + zip.by_name(&files[1].1) + .unwrap() + .read_to_string(&mut body) + .unwrap(); assert_eq!(body, "second"); let _ = fs::remove_dir_all(&dir); @@ -1287,13 +1337,22 @@ old // These are what JavaScript's toLocaleString() produced, which is what every // archive exported before this change contains. let en_us = parse_heading_date("8/20/2026, 10:14:32 AM").expect("en-US must parse"); - assert_eq!(en_us.format("%Y-%m-%d %H:%M:%S").to_string(), "2026-08-20 10:14:32"); + assert_eq!( + en_us.format("%Y-%m-%d %H:%M:%S").to_string(), + "2026-08-20 10:14:32" + ); let de_de = parse_heading_date("20.8.2026, 10:14:32").expect("de-DE must parse"); - assert_eq!(de_de.format("%Y-%m-%d %H:%M:%S").to_string(), "2026-08-20 10:14:32"); + assert_eq!( + de_de.format("%Y-%m-%d %H:%M:%S").to_string(), + "2026-08-20 10:14:32" + ); let current = parse_heading_date("2026-08-20 10:14:32").expect("current format must parse"); - assert_eq!(current.format("%Y-%m-%d %H:%M:%S").to_string(), "2026-08-20 10:14:32"); + assert_eq!( + current.format("%Y-%m-%d %H:%M:%S").to_string(), + "2026-08-20 10:14:32" + ); } #[test] @@ -1312,10 +1371,25 @@ old #[test] fn duplicate_detection_matches_only_near_identical_content() { - let existing = vec![stash("e1", "buy milk and eggs today", "2026-08-18T10:00:00Z", false)]; + let existing = vec![stash( + "e1", + "buy milk and eggs today", + "2026-08-18T10:00:00Z", + false, + )]; let incoming = vec![ - stash("i1", "buy milk and eggs today", "2026-08-18T10:00:00Z", false), - stash("i2", "completely unrelated content here", "2026-08-18T10:00:00Z", false), + stash( + "i1", + "buy milk and eggs today", + "2026-08-18T10:00:00Z", + false, + ), + stash( + "i2", + "completely unrelated content here", + "2026-08-18T10:00:00Z", + false, + ), ]; let dupes = find_duplicates(&incoming, &existing); diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index cbcb94a..1285b74 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -11,13 +11,13 @@ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. // See the GNU Affero General Public License for more details. +use crate::models::AppContext; +use crate::state::{lock_or_recover, TrackerState}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::OnceLock; use std::sync::{Arc, Mutex}; use tauri::State; -use crate::models::AppContext; -use crate::state::{TrackerState, lock_or_recover}; /// Run blocking work on the blocking pool and flatten the join error away. /// @@ -37,11 +37,11 @@ where } // Window vibrancy effects (Windows and macOS only) +use crate::uierror::UiError; #[cfg(target_os = "windows")] use window_vibrancy::{apply_acrylic, apply_mica, clear_acrylic, clear_mica}; #[cfg(target_os = "macos")] use window_vibrancy::{apply_vibrancy, NSVisualEffectMaterial}; -use crate::uierror::UiError; pub fn get_app_dir() -> PathBuf { dirs::home_dir() @@ -194,7 +194,10 @@ pub fn reserve_unique_path(dir: &Path, desired_name: &str) -> std::io::Result, _theme: Option<&str>) { +pub fn apply_window_effects_to_window( + window: &tauri::WebviewWindow, + enabled: Option, + _theme: Option<&str>, +) { #[cfg(target_os = "linux")] { let _ = window; let _ = _theme; } let should_enable = enabled.unwrap_or(true); - + if should_enable { // Apply OS-specific vibrancy effects #[cfg(target_os = "windows")] @@ -291,7 +298,7 @@ pub fn apply_window_effects_to_window(window: &tauri::WebviewWindow, enabled: Op // "system" (and an absent setting) has to be resolved against the OS, or a // light desktop gets a dark native backdrop underneath a light UI. let is_dark = resolve_is_dark(window, _theme); - + // Choose Acrylic background color based on theme // Dark: zinc-900 (18, 18, 18), Light: zinc-50 (249, 250, 251) let acrylic_color = if is_dark { @@ -299,11 +306,11 @@ pub fn apply_window_effects_to_window(window: &tauri::WebviewWindow, enabled: Op } else { (249, 250, 251, 200) }; - + // Clear any existing effects first to ensure color change takes effect let _ = clear_mica(window); let _ = clear_acrylic(window); - + // Try Mica first (Windows 11) - Mica respects system theme automatically match apply_mica(window, Some(is_dark)) { Ok(_) => { @@ -311,7 +318,10 @@ pub fn apply_window_effects_to_window(window: &tauri::WebviewWindow, enabled: Op } Err(_) => { // Mica not available (Windows 10 or earlier), try Acrylic - println!("Mica not available, trying Acrylic (Windows 10, dark={})…", is_dark); + println!( + "Mica not available, trying Acrylic (Windows 10, dark={})…", + is_dark + ); match apply_acrylic(window, Some(acrylic_color)) { Ok(_) => { println!("Applied Acrylic effect (Windows 10, dark={})", is_dark); @@ -324,22 +334,17 @@ pub fn apply_window_effects_to_window(window: &tauri::WebviewWindow, enabled: Op } } } - + #[cfg(target_os = "macos")] { // Apply vibrancy with a dark appearance - if let Err(e) = apply_vibrancy( - window, - NSVisualEffectMaterial::HudWindow, - None, - None, - ) { + if let Err(e) = apply_vibrancy(window, NSVisualEffectMaterial::HudWindow, None, None) { println!("Failed to apply vibrancy effect: {:?}", e); } else { println!("Applied vibrancy effect (macOS)"); } } - + // Linux: No window-vibrancy support, transparency handled by compositor #[cfg(target_os = "linux")] { @@ -354,14 +359,14 @@ pub fn apply_window_effects_to_window(window: &tauri::WebviewWindow, enabled: Op let _ = clear_acrylic(window); println!("Cleared window effects (Windows)"); } - + #[cfg(target_os = "macos")] { // On macOS, vibrancy can't be easily cleared programmatically, // but the CSS will show an opaque background when effects are disabled println!("macOS: Visual effects disabled (CSS will handle opaque background)"); } - + #[cfg(target_os = "linux")] { println!("Linux: Visual effects disabled"); @@ -419,7 +424,9 @@ fn device_id_path() -> PathBuf { /// A device id is a UUID we wrote ourselves; anything else in the file is not one. fn looks_like_device_id(value: &str) -> bool { let value = value.trim(); - !value.is_empty() && value.len() <= 64 && value.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') + !value.is_empty() + && value.len() <= 64 + && value.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') } /// The stable identifier the server knows this installation by. @@ -453,8 +460,7 @@ pub async fn get_device_id(migrate_from: Option) -> Result &'static str { // Microsoft Store installs live in WindowsApps *and* carry an Appx manifest. A bare // WindowsApps hit without one is winget's execution-alias directory, which is a // normal winget install and updates with a different command. - if path.contains("\\program files\\windowsapps\\") || path.contains("/program files/windowsapps/") + if path.contains("\\program files\\windowsapps\\") + || path.contains("/program files/windowsapps/") { if signals.appx_manifest { return "msstore"; @@ -560,7 +567,9 @@ fn resolve_installation_source() -> String { #[tauri::command] pub fn get_installation_source() -> String { - INSTALL_SOURCE.get_or_init(resolve_installation_source).clone() + INSTALL_SOURCE + .get_or_init(resolve_installation_source) + .clone() } /// Put text on the system clipboard. @@ -585,7 +594,10 @@ pub async fn copy_to_clipboard(text: String) -> Result<(), UiError> { pub async fn read_clipboard_text() -> Result { run_blocking(|| { let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; - clipboard.get_text().map_err(|e| e.to_string()).map_err(UiError::from) + clipboard + .get_text() + .map_err(|e| e.to_string()) + .map_err(UiError::from) }) .await } @@ -608,17 +620,25 @@ pub fn start_drag(window: tauri::Window, text: String, files: Vec) -> Re // Create temporary text file for text-only stashes let cache_dir = get_app_dir().join("cache").join("drags"); let _ = fs::create_dir_all(&cache_dir); - + // Use a hash or sanitized content for filename - let safe_name = text.chars().take(20).filter(|c| c.is_alphanumeric()).collect::(); - let filename = if safe_name.is_empty() { "stash.txt".to_string() } else { format!("{}.txt", safe_name) }; + let safe_name = text + .chars() + .take(20) + .filter(|c| c.is_alphanumeric()) + .collect::(); + let filename = if safe_name.is_empty() { + "stash.txt".to_string() + } else { + format!("{}.txt", safe_name) + }; let temp_path = cache_dir.join(filename); - + if let Err(e) = fs::write(&temp_path, &text) { - println!("Failed to write temp drag file: {}", e); - return Err("Failed to create drag data".into()); + println!("Failed to write temp drag file: {}", e); + return Err("Failed to create drag data".into()); } - + drag::DragItem::Files(vec![temp_path]) }; @@ -688,7 +708,7 @@ fn show_in_folder_blocking(path: String) { Err(_) => return, // Silently fail if path doesn't exist }; let safe_path = canonical.to_string_lossy(); - + #[cfg(target_os = "windows")] { let _ = std::process::Command::new("explorer") @@ -704,9 +724,7 @@ fn show_in_folder_blocking(path: String) { #[cfg(target_os = "linux")] { if let Some(parent) = canonical.parent() { - let _ = std::process::Command::new("xdg-open") - .arg(parent) - .spawn(); + let _ = std::process::Command::new("xdg-open").arg(parent).spawn(); } } } @@ -764,9 +782,9 @@ pub async fn is_windows_10() -> bool { fn detect_windows_10() -> bool { #[cfg(target_os = "windows")] { - use std::process::Command; use std::os::windows::process::CommandExt; - + use std::process::Command; + const CREATE_NO_WINDOW: u32 = 0x08000000; let output = Command::new("cmd") .args(["/c", "ver"]) @@ -782,10 +800,13 @@ fn detect_windows_10() -> bool { let rest = &s[start..]; // rest starts with build number, e.g. "19045.3693]" // find the next dot or closing bracket - let end = rest.find('.').or_else(|| rest.find(']')).unwrap_or(rest.len()); + let end = rest + .find('.') + .or_else(|| rest.find(']')) + .unwrap_or(rest.len()); if let Ok(build) = rest[..end].parse::() { - // Windows 11 starts at build 22000 - return build < 22000; + // Windows 11 starts at build 22000 + return build < 22000; } } } @@ -833,7 +854,7 @@ pub async fn get_autostart_enabled(app: tauri::AppHandle) -> Result Result { } #[tauri::command] -pub async fn apple_intelligence_enhance(content: String, system_prompt: String) -> Result { +pub async fn apple_intelligence_enhance( + content: String, + system_prompt: String, +) -> Result { #[cfg(all(target_os = "macos", feature = "macos-apple-intelligence"))] { - use fm_rs::{SystemLanguageModel, Session, GenerationOptions}; + use fm_rs::{GenerationOptions, Session, SystemLanguageModel}; let model = SystemLanguageModel::new().map_err(|e| e.to_string())?; - let session = Session::with_instructions(&model, &system_prompt).map_err(|e| e.to_string())?; - let response = session.respond(&content, &GenerationOptions::default()).map_err(|e| e.to_string())?; + let session = + Session::with_instructions(&model, &system_prompt).map_err(|e| e.to_string())?; + let response = session + .respond(&content, &GenerationOptions::default()) + .map_err(|e| e.to_string())?; Ok(response.content().to_string()) } #[cfg(any(not(target_os = "macos"), not(feature = "macos-apple-intelligence")))] @@ -933,7 +960,6 @@ pub fn get_previous_app_info(state: State>>) -> AppConte } } - /// Record an error the webview could not handle itself. /// /// In a release build the webview console is discarded, so a render error that killed @@ -965,7 +991,10 @@ mod reserve_unique_path_tests { let dir = tempfile::tempdir().unwrap(); let path = reserve_unique_path(dir.path(), "image.png").unwrap(); assert_eq!(name_of(&path), "image.png"); - assert!(path.exists(), "the reservation is a real file, not just a name"); + assert!( + path.exists(), + "the reservation is a real file, not just a name" + ); } #[test] @@ -1029,8 +1058,15 @@ mod reserve_unique_path_tests { let first = reserve_unique_path(dir.path(), &long).unwrap(); let first_name = name_of(&first); - assert!(first_name.chars().count() <= 200, "got {} chars", first_name.chars().count()); - assert!(first_name.ends_with(".png"), "the extension decides the mime type"); + assert!( + first_name.chars().count() <= 200, + "got {} chars", + first_name.chars().count() + ); + assert!( + first_name.ends_with(".png"), + "the extension decides the mime type" + ); // The truncated name now collides with itself, which is the case that would fail // with a "file name too long" error rather than retrying. @@ -1073,7 +1109,9 @@ mod tests { #[test] fn plain_install_is_standalone() { assert_eq!( - classify_installation(&signals("/applications/stashpad.app/contents/macos/stashpad")), + classify_installation(&signals( + "/applications/stashpad.app/contents/macos/stashpad" + )), "standalone" ); assert_eq!( @@ -1104,7 +1142,9 @@ mod tests { #[test] fn package_managers_are_recognised() { assert_eq!( - classify_installation(&signals(r"c:\users\nico\scoop\apps\stashpad\current\stashpad.exe")), + classify_installation(&signals( + r"c:\users\nico\scoop\apps\stashpad\current\stashpad.exe" + )), "scoop" ); assert_eq!( From dfbff210493bd5fa2e1225e5b14bd65509c2aed2 Mon Sep 17 00:00:00 2001 From: Nico Wiedemann Date: Sun, 27 Sep 2026 16:51:09 +0200 Subject: [PATCH 2/3] Clear every Clippy finding and make both Rust lints block Clippy and fmt ran on every push but were advisory, so their failures showed as two red annotations on a green pipeline and nobody acted on them. This clears all 20 findings - 14 in the lib, 6 in tests: flatten instead of `if let Ok`, a slice instead of `&Vec`, a useless `.max(0)` on a u32, a `?` for an if-let chain, a match that only builds the handshake timeout error, and the like. None changes behaviour. The one Linux-only finding, an unused `safe_path` in show_in_folder, moves the conversion into the two branches that use it. The workflow now runs `cargo clippy --all-targets -- -D warnings` so tests are covered too, and drops continue-on-error from both steps. Checked: clippy --all-targets and fmt --check clean, 169 cargo tests and 248 vitest pass. Co-Authored-By: Claude Opus 5 (1M context) --- .git-blame-ignore-revs | 6 ++++ .github/workflows/test.yml | 32 +++++++++----------- src-tauri/src/db.rs | 60 +++++++++++++++----------------------- src-tauri/src/keychain.rs | 8 +---- src-tauri/src/lib.rs | 5 ++-- src-tauri/src/stashes.rs | 2 +- src-tauri/src/sync.rs | 10 ++++--- src-tauri/src/transfer.rs | 4 +-- src-tauri/src/utils.rs | 10 ++++--- 9 files changed, 59 insertions(+), 78 deletions(-) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..c121e5d --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,6 @@ +# Commits `git blame` should look through: whole-tree mechanical changes that touch +# every line and change nothing. GitHub reads this file; locally, run once: +# git config blame.ignoreRevsFile .git-blame-ignore-revs + +# Format the Rust backend with rustfmt +b0b3fe18e1d81c7ea620ac79e1e62788a22d9964 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6cbb6c1..0dea2a7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -170,33 +170,27 @@ jobs: working-directory: src-tauri run: cargo test --verbose - # Both lint steps below are ADVISORY and named so, because this suite now decides whether - # a merge to main ships a binary and "Tests passed" must not be read as "lint passed". + # Both lints gate. They were advisory while the tree was not clean - and an advisory step + # that fails on every run is noise nobody reads, so findings piled up: by v1.9.0 clippy had + # 14 in the lib and 6 in its tests, and fmt 324 diffs, each run showing two red "exit code" + # annotations on a green pipeline. The tree is clean now, so a new finding fails the job. # - # Neither is currently clean: `cargo clippy -- -D warnings` reports 11 findings in the lib - # and 14 in lib tests across 10 lint families (len_zero, single_match, manual_flatten, - # ptr_arg, result_large_err and friends - all cosmetic, no correctness issues), and - # `cargo fmt --check` reports 231 diffs, meaning the tree has never been rustfmt'd. + # `--all-targets` so test code is held to the same bar: without it clippy checks only the + # lib and bins, and the test findings above were never reported at all. # - # Gating them is the right end state and it is cheap - one `cargo fmt` commit plus a pass - # over ten lint families - but it is a tree-wide reformat that would bury `git blame`, so - # it belongs in its own commit rather than riding along with a CI change. When that lands, - # drop the `continue-on-error` and the "(advisory)" from both names. - # - # One OS, not three. Neither lint is platform-dependent, and clippy's check-mode - # artifacts carry different fingerprints from the test build's - so on every leg it - # was a second near-full compile, for a result the other two legs had already given. - - name: Run Clippy (advisory, non-gating) + # One OS, not three. Both lints are mostly platform-independent, and clippy's check-mode + # artifacts carry different fingerprints from the test build's - so on every leg it was a + # second near-full compile. The price: code behind `#[cfg(target_os = "windows")]` or + # `"macos"` is not linted here. Run `cargo clippy --all-targets` locally on those. + - name: Run Clippy if: matrix.os == 'ubuntu-latest' working-directory: src-tauri - run: cargo clippy -- -D warnings - continue-on-error: true + run: cargo clippy --all-targets -- -D warnings - - name: Check formatting (advisory, non-gating) + - name: Check formatting if: matrix.os == 'ubuntu-latest' working-directory: src-tauri run: cargo fmt --check - continue-on-error: true type-check: name: TypeScript Type Check diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 063de45..48d956e 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -532,10 +532,8 @@ impl DbManager { })?; let mut stashes_to_migrate = Vec::new(); - for r in rows { - if let Ok(val) = r { - stashes_to_migrate.push(val); - } + for val in rows.flatten() { + stashes_to_migrate.push(val); } if stashes_to_migrate.is_empty() { @@ -562,7 +560,7 @@ impl DbManager { .unwrap_or_default() .to_string_lossy() .to_string(); - let metadata = std::fs::metadata(&path); + let metadata = std::fs::metadata(path); let file_size = metadata.map(|m| m.len()).unwrap_or(0) as i64; // Generate ID (simple UUID v4 like) @@ -570,7 +568,7 @@ impl DbManager { let att_id = Uuid::new_v4().to_string(); // Extension mime guess - let mime_type = mime_guess::from_path(&path).first().map(|m| m.to_string()); + let mime_type = mime_guess::from_path(path).first().map(|m| m.to_string()); self.conn.execute( "INSERT OR IGNORE INTO attachments (id, stash_id, file_path, file_name, file_size, mime_type, syntax, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", @@ -811,13 +809,11 @@ impl DbManager { // Group by stash_id let mut attachments_map: std::collections::HashMap> = std::collections::HashMap::new(); - for att in att_rows { - if let Ok(a) = att { - attachments_map - .entry(a.stash_id.clone()) - .or_default() - .push(a); - } + for a in att_rows.flatten() { + attachments_map + .entry(a.stash_id.clone()) + .or_default() + .push(a); } // 3. Assign attachments to stashes @@ -892,13 +888,11 @@ impl DbManager { let mut attachments_map: std::collections::HashMap> = std::collections::HashMap::new(); - for att in att_rows { - if let Ok(a) = att { - attachments_map - .entry(a.stash_id.clone()) - .or_default() - .push(a); - } + for a in att_rows.flatten() { + attachments_map + .entry(a.stash_id.clone()) + .or_default() + .push(a); } for stash in &mut stashes { @@ -1324,7 +1318,7 @@ impl DbManager { /// /// Only rows whose position actually changes are stamped, so re-persisting an /// unchanged list is free and does not queue a pointless push. - pub fn update_stash_positions(&mut self, stashes: &Vec) -> Result<()> { + pub fn update_stash_positions(&mut self, stashes: &[StashItem]) -> Result<()> { let tx = self.conn.transaction()?; let now = now_ts(); for (i, stash) in stashes.iter().enumerate() { @@ -1536,7 +1530,7 @@ mod tests { // Default context should be created automatically let contexts = db.get_contexts().expect("Failed to get contexts"); - assert!(contexts.len() >= 1, "Should have at least default context"); + assert!(!contexts.is_empty(), "Should have at least default context"); let default_ctx = contexts.iter().find(|c| c.id == "default"); assert!(default_ctx.is_some(), "Default context should exist"); @@ -1850,7 +1844,7 @@ mod tests { assert!(saved.is_some(), "Stash should be saved"); assert_eq!(saved.unwrap().content, "Test stash content"); - assert_eq!(saved.unwrap().completed, false); + assert!(!saved.unwrap().completed); } #[test] @@ -2268,12 +2262,8 @@ mod tests { db.save_stash(&stash, Some(0.0), WriteOrigin::SyncImport) .expect("save should succeed"); - db.update_stash_positions(&vec![stash_with_updated_at( - "s-still", - "content", - Some(1_000), - )]) - .expect("reorder should succeed"); + db.update_stash_positions(&[stash_with_updated_at("s-still", "content", Some(1_000))]) + .expect("reorder should succeed"); let pending: i64 = db .conn @@ -2302,7 +2292,7 @@ mod tests { ) .expect("save should succeed"); - db.update_stash_positions(&vec![ + db.update_stash_positions(&[ stash_with_updated_at("filler", "", None), stash_with_updated_at("s-race", "content", Some(1_000)), ]) @@ -2312,12 +2302,8 @@ mod tests { assert_eq!(claimed.len(), 1); // The user moves it again while the push is in flight. - db.update_stash_positions(&vec![stash_with_updated_at( - "s-race", - "content", - Some(1_000), - )]) - .expect("second reorder should succeed"); + db.update_stash_positions(&[stash_with_updated_at("s-race", "content", Some(1_000))]) + .expect("second reorder should succeed"); db.mark_positions_synced(&["s-race".to_string()]) .expect("ack should succeed"); @@ -2716,7 +2702,7 @@ mod tests { #[test] fn conversion_progress_reaches_zero_once_the_server_has_everything() { - let mut db = create_test_db(); + let db = create_test_db(); // Whatever the starter stashes left pending, acknowledge all of it. db.conn .execute("UPDATE stashes SET pending_sync = 0", []) diff --git a/src-tauri/src/keychain.rs b/src-tauri/src/keychain.rs index 614ac12..0dca326 100644 --- a/src-tauri/src/keychain.rs +++ b/src-tauri/src/keychain.rs @@ -303,13 +303,7 @@ pub fn store_cloud_token_in_keychain(token: &str) -> bool { pub fn get_secret_from_keychain( create_entry: fn() -> Result, ) -> Option { - match create_entry() { - Ok(entry) => match entry.get_password() { - Ok(password) => Some(password), - Err(_) => None, - }, - Err(_) => None, - } + create_entry().ok()?.get_password().ok() } /// Retrieve API key from system keychain diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7c010ff..9d6b519 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -581,13 +581,12 @@ pub fn run() { // Anything that needs to happen at startup belongs in that one hook. .build(tauri::generate_context!()) .expect("error while building tauri application") - .run(|app_handle, event| match event { - tauri::RunEvent::Exit => { + .run(|app_handle, event| { + if let tauri::RunEvent::Exit = event { println!("App exiting, cleaning up..."); cleanup_websocket_state(app_handle); cleanup_database_state(app_handle); } - _ => {} }); } diff --git a/src-tauri/src/stashes.rs b/src-tauri/src/stashes.rs index ac7e936..33906f6 100644 --- a/src-tauri/src/stashes.rs +++ b/src-tauri/src/stashes.rs @@ -420,7 +420,7 @@ pub fn perform_startup_cleanup(db: &mut DbManager, settings: &Settings) -> usize // Previously an empty stub: the setting existed, was selectable, and did // nothing at all. A setting that silently has no effect is worse than one // that is not offered. - let days = settings.clear_completed_days.max(0) as i64; + let days = settings.clear_completed_days as i64; let cutoff = (chrono::Utc::now() - chrono::Duration::days(days)).to_rfc3339(); let stale = completed_stashes(db, Some(&cutoff)); diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index d2e87e6..e4c633c 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -1079,14 +1079,16 @@ pub async fn connect_websocket( // stopped looping: the app kept believing a connection was pending and // fell back to the 15-minute poll, which reads as sync being broken. let attempt = tokio::time::timeout(WS_CONNECT_TIMEOUT, connect_async(ws_url.clone())); - match attempt.await.unwrap_or_else(|_| { - Err(tokio_tungstenite::tungstenite::Error::Io( + let connected = match attempt.await { + Ok(result) => result, + Err(_elapsed) => Err(tokio_tungstenite::tungstenite::Error::Io( std::io::Error::new( std::io::ErrorKind::TimedOut, "WebSocket handshake timed out", ), - )) - }) { + )), + }; + match connected { Ok((ws_stream, _)) => { log::info!("[WebSocket] Connected successfully"); diff --git a/src-tauri/src/transfer.rs b/src-tauri/src/transfer.rs index 5c348a6..b61cf0a 100644 --- a/src-tauri/src/transfer.rs +++ b/src-tauri/src/transfer.rs @@ -533,10 +533,8 @@ fn parse_section_header(line: &str) -> Option { let rest = line.strip_prefix("## ")?; let (kind, tail) = if let Some(t) = rest.strip_prefix("Active Stashes (") { (false, t) - } else if let Some(t) = rest.strip_prefix("Completed Stashes (") { - (true, t) } else { - return None; + (true, rest.strip_prefix("Completed Stashes (")?) }; let count = tail.strip_suffix(')')?; diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 1285b74..f4ce77d 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -487,8 +487,8 @@ pub struct InstallSignals<'a> { /// Decide which install channel the signals describe. /// /// Order matters. The App Store checks come first because a store copy also lives at a -/// perfectly ordinary path - `/Applications/stashpad.app`, `C:\Program Files\WindowsApps\...` -/// - and misreading one as standalone would offer it a self-update that replaces a signed, +/// perfectly ordinary path (`/Applications/stashpad.app`, `C:\Program Files\WindowsApps\...`), +/// and misreading one as standalone would offer it a self-update that replaces a signed, /// sandboxed bundle and gets the app killed on next launch. pub fn classify_installation(signals: &InstallSignals) -> &'static str { let path = signals.exe_path; @@ -707,16 +707,19 @@ fn show_in_folder_blocking(path: String) { Ok(p) => p, Err(_) => return, // Silently fail if path doesn't exist }; - let safe_path = canonical.to_string_lossy(); + // Windows and macOS select the file itself; Linux has no portable way to, so it opens + // the folder instead and needs no string form of the path. #[cfg(target_os = "windows")] { + let safe_path = canonical.to_string_lossy(); let _ = std::process::Command::new("explorer") .args(["/select,", &safe_path]) .spawn(); } #[cfg(target_os = "macos")] { + let safe_path = canonical.to_string_lossy(); let _ = std::process::Command::new("open") .args(["-R", &safe_path]) .spawn(); @@ -854,7 +857,6 @@ pub async fn get_autostart_enabled(app: tauri::AppHandle) -> Result Date: Sun, 27 Sep 2026 21:56:53 +0200 Subject: [PATCH 3/3] Stop CodeQL flagging the passphrase salt as hard-coded CodeQL's rust/hard-coded-cryptographic-value query flagged the Argon2id salt in localkey.rs: it reads a zeroed array literal and doesn't follow the fill_bytes call right after it that overwrites the array with random bytes, so it sees the zero bytes as the salt actually used. Collapse every salt and nonce built this way to a single `let x: [u8; N] = rand::random();`, so the randomness is the declaration's own value with no later mutation for the scanner to miss. Same thread CSPRNG, same output distribution - only the shape of the code changes. Left the key buffers that are Zeroizing wrappers on fill_bytes, since filling them in place is what keeps an unzeroed copy of the key off the stack. Dropped the now-unused rand::RngCore imports. No user-visible behavior changes, so no CHANGELOG entry. Checked: cargo fmt, cargo clippy --all-targets -D warnings, and cargo test (169 passed) all clean. Co-Authored-By: Claude Sonnet 5 --- src-tauri/src/e2ee.rs | 15 +++++---------- src-tauri/src/e2ee_session.rs | 8 ++------ src-tauri/src/envelope.rs | 4 +--- src-tauri/src/keychain.rs | 4 +--- src-tauri/src/localkey.rs | 7 ++----- 5 files changed, 11 insertions(+), 27 deletions(-) diff --git a/src-tauri/src/e2ee.rs b/src-tauri/src/e2ee.rs index 6a0fe0d..ffe134f 100644 --- a/src-tauri/src/e2ee.rs +++ b/src-tauri/src/e2ee.rs @@ -179,8 +179,7 @@ pub fn wrap_to_device( let key = derive_wrap_key(shared.as_bytes(), &info); let cipher = XChaCha20Poly1305::new_from_slice(key.as_slice()).map_err(|_| "bad key length")?; - let mut nonce = [0u8; 24]; - rand::thread_rng().fill_bytes(&mut nonce); + let nonce: [u8; 24] = rand::random(); let ciphertext = cipher .encrypt(XNonce::from_slice(&nonce), content_key.as_slice()) .map_err(|_| "could not seal the content key".to_string())?; @@ -375,8 +374,7 @@ pub fn wrap_to_recovery( user_id: &str, epoch: u32, ) -> Result<(String, String), UiError> { - let mut salt = [0u8; 16]; - rand::thread_rng().fill_bytes(&mut salt); + let salt: [u8; 16] = rand::random(); let key = derive_recovery_key(code.secret.as_slice(), &salt, user_id, epoch); let wrapped = seal_key(&key, content_key)?; Ok((STANDARD.encode(salt), wrapped)) @@ -421,8 +419,7 @@ pub fn wrap_to_api_key( ) -> Result<(String, String), UiError> { use hkdf::Hkdf; - let mut salt = [0u8; 16]; - rand::thread_rng().fill_bytes(&mut salt); + let salt: [u8; 16] = rand::random(); let mut info = Vec::with_capacity(64); info.extend_from_slice(b"stashpad/e2ee/v1/apikey"); @@ -455,8 +452,7 @@ pub fn make_verifier(content_key: &ContentKey) -> Result { XChaCha20Poly1305, XNonce, }; let cipher = XChaCha20Poly1305::new_from_slice(key.as_slice()).map_err(|_| "bad key")?; - let mut nonce = [0u8; 24]; - rand::thread_rng().fill_bytes(&mut nonce); + let nonce: [u8; 24] = rand::random(); let ct = cipher .encrypt(XNonce::from_slice(&nonce), VERIFIER_PLAINTEXT) .map_err(|_| "could not build the verifier".to_string())?; @@ -507,8 +503,7 @@ fn seal_key(key: &[u8; 32], content_key: &ContentKey) -> Result XChaCha20Poly1305, XNonce, }; let cipher = XChaCha20Poly1305::new_from_slice(key).map_err(|_| "bad key length")?; - let mut nonce = [0u8; 24]; - rand::thread_rng().fill_bytes(&mut nonce); + let nonce: [u8; 24] = rand::random(); let ct = cipher .encrypt(XNonce::from_slice(&nonce), content_key.as_slice()) .map_err(|_| "could not seal the content key".to_string())?; diff --git a/src-tauri/src/e2ee_session.rs b/src-tauri/src/e2ee_session.rs index e07555f..833961b 100644 --- a/src-tauri/src/e2ee_session.rs +++ b/src-tauri/src/e2ee_session.rs @@ -539,12 +539,8 @@ pub fn seal_attachment( let epoch = *lock_or_recover(&EPOCH); let mut file_key = Zeroizing::new([0u8; 32]); - let mut nonce = [0u8; 24]; - { - let mut rng = rand::thread_rng(); - rng.fill_bytes(file_key.as_mut()); - rng.fill_bytes(&mut nonce); - } + rand::thread_rng().fill_bytes(file_key.as_mut()); + let nonce: [u8; 24] = rand::random(); let cipher = XChaCha20Poly1305::new_from_slice(file_key.as_slice()) .map_err(|_| "bad file key".to_string())?; diff --git a/src-tauri/src/envelope.rs b/src-tauri/src/envelope.rs index 0cf5b66..ef3bc4c 100644 --- a/src-tauri/src/envelope.rs +++ b/src-tauri/src/envelope.rs @@ -303,14 +303,12 @@ pub fn seal( aead::{Aead, KeyInit, Payload}, XChaCha20Poly1305, XNonce, }; - use rand::RngCore; let key = field_key(content_key, binding.kind, binding.field); let cipher = XChaCha20Poly1305::new_from_slice(&key) .map_err(|_| EnvelopeError::Malformed("bad key length"))?; - let mut nonce = [0u8; NONCE_LEN]; - rand::thread_rng().fill_bytes(&mut nonce); + let nonce: [u8; NONCE_LEN] = rand::random(); let aad = binding.aad(); let ciphertext = cipher diff --git a/src-tauri/src/keychain.rs b/src-tauri/src/keychain.rs index 0dca326..657312a 100644 --- a/src-tauri/src/keychain.rs +++ b/src-tauri/src/keychain.rs @@ -507,11 +507,9 @@ mod tests { Aes256Gcm, Nonce, }; use base64::{engine::general_purpose::STANDARD, Engine as _}; - use rand::RngCore; let cipher = Aes256Gcm::new_from_slice(&derive_machine_key()).expect("32-byte key"); - let mut nonce = [0u8; 12]; - rand::thread_rng().fill_bytes(&mut nonce); + let nonce: [u8; 12] = rand::random(); let ciphertext = cipher .encrypt(Nonce::from_slice(&nonce), secret.as_bytes()) .expect("encrypt"); diff --git a/src-tauri/src/localkey.rs b/src-tauri/src/localkey.rs index 9d87a29..14484f2 100644 --- a/src-tauri/src/localkey.rs +++ b/src-tauri/src/localkey.rs @@ -34,7 +34,6 @@ use std::path::PathBuf; use std::sync::Mutex; use base64::{engine::general_purpose::STANDARD, Engine as _}; -use rand::RngCore; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; @@ -152,8 +151,7 @@ fn seal_with(key: &[u8; 32], plaintext: &[u8]) -> Result { }; let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?; - let mut nonce_bytes = [0u8; 12]; - rand::thread_rng().fill_bytes(&mut nonce_bytes); + let nonce_bytes: [u8; 12] = rand::random(); let ciphertext = cipher .encrypt(Nonce::from_slice(&nonce_bytes), plaintext) .map_err(|_| "Encryption failed".to_string())?; @@ -215,8 +213,7 @@ pub fn set_passphrase(passphrase: &str, remember: bool) -> Result<(), UiError> { )); } - let mut salt = [0u8; 16]; - rand::thread_rng().fill_bytes(&mut salt); + let salt: [u8; 16] = rand::random(); let key = derive( passphrase,