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/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..48d956e 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,50 +520,57 @@ 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(); - 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() { 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 +583,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 +674,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 +758,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,26 +792,28 @@ 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(); - for att in att_rows { - if let Ok(a) = att { - attachments_map.entry(a.stash_id.clone()).or_default().push(a); - } + let mut attachments_map: std::collections::HashMap> = + std::collections::HashMap::new(); + for a in att_rows.flatten() { + attachments_map + .entry(a.stash_id.clone()) + .or_default() + .push(a); } // 3. Assign attachments to stashes @@ -783,11 +835,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,25 +872,27 @@ 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(); - for att in att_rows { - if let Ok(a) = att { - attachments_map.entry(a.stash_id.clone()).or_default().push(a); - } + let mut attachments_map: std::collections::HashMap> = + std::collections::HashMap::new(); + for a in att_rows.flatten() { + attachments_map + .entry(a.stash_id.clone()) + .or_default() + .push(a); } for stash in &mut stashes { @@ -1062,7 +1116,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 +1165,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 +1233,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 +1265,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 +1306,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. /// @@ -1257,29 +1318,29 @@ 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 \ + 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() { + 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 +1402,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 +1410,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 +1527,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"); - + 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"); assert_eq!(default_ctx.unwrap().name, "Default"); @@ -1534,7 +1595,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 +1607,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 +1625,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 +1636,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 +1658,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 +1676,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 +1784,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 +1808,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 +1835,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); + assert!(!saved.unwrap().completed); } - + #[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 +1864,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 +1894,7 @@ mod tests { updated_at: None, deleted: false, }; - + let active = StashItem { id: "active-1".to_string(), context_id: "default".to_string(), @@ -1817,22 +1908,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 +1946,7 @@ mod tests { updated_at: None, deleted: false, }; - + let stash2 = StashItem { id: "pos-2".to_string(), context_id: "default".to_string(), @@ -1860,53 +1960,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 +2125,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 +2190,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 +2221,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 +2246,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,7 +2262,7 @@ 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))]) + db.update_stash_positions(&[stash_with_updated_at("s-still", "content", Some(1_000))]) .expect("reorder should succeed"); let pending: i64 = db @@ -2144,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)), ]) @@ -2154,7 +2302,7 @@ 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))]) + 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()]) @@ -2251,7 +2399,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 +2472,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,12 +2694,15 @@ 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] 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", []) @@ -2576,7 +2727,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..ffe134f 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,10 +178,8 @@ 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 mut nonce = [0u8; 24]; - rand::thread_rng().fill_bytes(&mut nonce); + let cipher = XChaCha20Poly1305::new_from_slice(key.as_slice()).map_err(|_| "bad key length")?; + 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())?; @@ -223,8 +221,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 +251,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("-") } } @@ -373,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)) @@ -419,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"); @@ -453,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())?; @@ -505,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())?; @@ -648,7 +645,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 +723,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 +749,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 +833,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..833961b 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; } } @@ -527,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())?; @@ -703,7 +711,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 +803,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 +860,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 +1015,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 +1034,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 +1093,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..ef3bc4c 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 { @@ -299,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 @@ -384,7 +386,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 +399,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 +414,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 +452,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 +471,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 +490,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 +508,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 +539,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..657312a 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. @@ -288,15 +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 @@ -310,9 +317,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 +336,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 +348,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); @@ -502,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"); @@ -574,7 +577,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 +600,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..9d6b519 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, @@ -576,13 +582,10 @@ pub fn run() { .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); - } - _ => {} + 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/localkey.rs b/src-tauri/src/localkey.rs index d5d4b5e..14484f2 100644 --- a/src-tauri/src/localkey.rs +++ b/src-tauri/src/localkey.rs @@ -34,13 +34,12 @@ 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; 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 +123,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)) @@ -146,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())?; @@ -209,10 +213,15 @@ 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, &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 +302,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 +317,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 +548,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..33906f6 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. @@ -381,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)); @@ -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..e4c633c 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 @@ -1016,12 +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(std::io::Error::new( - std::io::ErrorKind::TimedOut, - "WebSocket handshake timed out", - ))) - }) { + 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"); @@ -1041,9 +1108,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 +1221,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 +1289,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 +1350,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 +1404,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 +1456,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 +1505,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 +1592,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 +1608,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..b61cf0a 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) } @@ -528,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(')')?; @@ -578,7 +581,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 +634,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 +669,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 +815,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 +846,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 +918,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 +932,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 +1124,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 +1156,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 +1198,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 +1219,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 +1230,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 +1243,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 +1294,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 +1309,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 +1335,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 +1369,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..f4ce77d 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 { /// 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; @@ -496,7 +502,8 @@ pub fn classify_installation(signals: &InstallSignals) -> &'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]) }; @@ -687,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(); @@ -704,9 +727,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 +785,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 +803,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 +857,6 @@ 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 +962,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 +993,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 +1060,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 +1111,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 +1144,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!(