use std::{ env, fmt, fs, io::{BufRead, BufReader, Read, Write}, path::PathBuf, str, sync::{Mutex, OnceLock}, time::{Duration, Instant}, }; pub mod nonblocking; use std::hash::Hasher; use crate::hash::Fnv1aHasher; /// Cache for process scanning results to avoid expensive `/proc` reads on every /// store operation. TTL of 5 seconds balances freshness with performance. struct ProcessCache { last_scan: Instant, excluded_app: Option, } impl ProcessCache { const TTL: Duration = Duration::from_secs(5); /// Check cache for recently active excluded app. /// Only caches positive results (when an excluded app IS found). /// Negative results (no excluded apps) are never cached to ensure /// we don't miss exclusions when users switch apps. fn get(excluded_apps: &[String]) -> Option { static CACHE: OnceLock> = OnceLock::new(); let cache = CACHE.get_or_init(|| { Mutex::new(ProcessCache { last_scan: Instant::now().checked_sub(Self::TTL).unwrap(), /* Expire immediately on * first use */ excluded_app: None, }) }); if let Ok(mut cache) = cache.lock() { // Check if we have a valid cached positive result if cache.last_scan.elapsed() < Self::TTL && let Some(ref app) = cache.excluded_app { // Verify the cached app is still in the exclusion list if app_matches_exclusion(app, excluded_apps) { return Some(app.clone()); } } // No valid cache, scan and only cache positive results let result = get_recently_active_excluded_app_uncached(excluded_apps); if result.is_some() { cache.last_scan = Instant::now(); cache.excluded_app = result.clone(); } else { // Don't cache negative results. We expire cache immediately so next // call will rescan. This ensures we don't miss exclusions when user // switches from non-excluded to excluded app. cache.last_scan = Instant::now().checked_sub(Self::TTL).unwrap(); cache.excluded_app = None; } result } else { // Lock poisoned - fall back to uncached get_recently_active_excluded_app_uncached(excluded_apps) } } } use base64::prelude::*; use log::{debug, error, info, warn}; use mime_sniffer::MimeTypeSniffer; use regex::Regex; use rusqlite::{Connection, OptionalExtension, params}; use serde::{Deserialize, Serialize}; use thiserror::Error; pub const DEFAULT_MAX_ENTRY_SIZE: usize = 5_000_000; /// Query builder helper for list operations. /// Centralizes WHERE clause and ORDER BY generation to avoid duplication. struct ListQueryBuilder { include_expired: bool, reverse: bool, search_pattern: Option, limit: Option, offset: Option, } impl ListQueryBuilder { fn new(include_expired: bool, reverse: bool) -> Self { Self { include_expired, reverse, search_pattern: None, limit: None, offset: None, } } fn with_search(mut self, pattern: Option<&str>) -> Self { self.search_pattern = pattern.map(|s| { let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_"); format!("%{escaped}%") }); self } fn with_pagination(mut self, offset: usize, limit: usize) -> Self { self.offset = Some(offset); self.limit = Some(limit); self } fn where_clause(&self) -> String { let mut conditions = Vec::new(); if !self.include_expired { conditions.push("(is_expired IS NULL OR is_expired = 0)"); } if self.search_pattern.is_some() { conditions .push("(LOWER(CAST(contents AS TEXT)) LIKE LOWER(?1) ESCAPE '!')"); } if conditions.is_empty() { String::new() } else { format!("WHERE {}", conditions.join(" AND ")) } } fn order_clause(&self) -> String { let order = if self.reverse { "ASC" } else { "DESC" }; format!("ORDER BY COALESCE(last_accessed, 0) {order}, id {order}") } fn pagination_clause(&self) -> String { match (self.limit, self.offset) { (Some(limit), Some(offset)) => format!("LIMIT {limit} OFFSET {offset}"), _ => String::new(), } } fn select_star_query(&self) -> String { let where_clause = self.where_clause(); let order_clause = self.order_clause(); let pagination = self.pagination_clause(); format!( "SELECT id, contents, mime FROM clipboard {where_clause} {order_clause} \ {pagination}" ) .trim() .to_string() } fn count_query(&self) -> String { let where_clause = self.where_clause(); format!("SELECT COUNT(*) FROM clipboard {where_clause}") .trim() .to_string() } fn search_param(&self) -> Option<&str> { self.search_pattern.as_deref() } } #[derive(Error, Debug)] pub enum StashError { #[error("Input is empty or too large, skipping store.")] EmptyOrTooLarge, #[error("Input is all whitespace, skipping store.")] AllWhitespace, #[error("Entry too small (min size: {0} bytes), skipping store.")] TooSmall(usize), #[error("Entry too large (max size: {0} bytes), skipping store.")] TooLarge(usize), #[error("Failed to store entry: {0}")] Store(Box), #[error("Entry excluded by app filter: {0}")] ExcludedByApp(Box), #[error("Error reading entry during deduplication: {0}")] DeduplicationRead(Box), #[error("Error decoding entry during deduplication: {0}")] DeduplicationDecode(Box), #[error("Failed to remove entry during deduplication: {0}")] DeduplicationRemove(Box), #[error("Failed to trim entry: {0}")] Trim(Box), #[error("No entries to delete")] NoEntriesToDelete, #[error("Failed to delete last entry: {0}")] DeleteLast(Box), #[error("Failed to wipe database: {0}")] Wipe(Box), #[error("Failed to decode entry during list: {0}")] ListDecode(Box), #[error("Failed to read input for decode: {0}")] DecodeRead(Box), #[error("Failed to extract id for decode: {0}")] DecodeExtractId(Box), #[error("Failed to get entry for decode: {0}")] DecodeGet(Box), #[error("Failed to write decoded entry: {0}")] DecodeWrite(Box), #[error("Failed to delete entry during query delete: {0}")] QueryDelete(Box), #[error("Failed to delete entry with id {0}: {1}")] DeleteEntry(i64, Box), } pub trait ClipboardDb { /// Store a new clipboard entry. /// /// # Arguments /// * `input` - Reader for the clipboard content /// * `max_dedupe_search` - Maximum number of recent entries to check for /// duplicates /// * `max_items` - Maximum total entries to keep in database /// * `excluded_apps` - List of app names to exclude /// * `min_size` - Minimum content size (None for no minimum) /// * `max_size` - Maximum content size /// * `content_hash` - Optional pre-computed content hash (avoids re-hashing) /// * `mime_types` - Optional list of all MIME types offered (for persistence) #[allow(clippy::too_many_arguments)] fn store_entry( &self, input: impl Read, max_dedupe_search: u64, max_items: u64, excluded_apps: Option<&[String]>, min_size: Option, max_size: usize, content_hash: Option, mime_types: Option<&[String]>, ) -> Result; fn deduplicate_by_hash( &self, content_hash: i64, max: u64, ) -> Result; fn trim_db(&self, max_items: u64) -> Result<(), StashError>; fn delete_last(&self) -> Result<(), StashError>; fn wipe_db(&self) -> Result<(), StashError>; fn list_entries( &self, out: impl Write, preview_width: u32, include_expired: bool, reverse: bool, ) -> Result; fn decode_entry( &self, input: impl Read, out: impl Write, id_hint: Option, ) -> Result<(), StashError>; fn delete_query(&self, query: &str) -> Result; fn delete_entries(&self, input: impl Read) -> Result; fn copy_entry( &self, id: i64, ) -> Result<(i64, Vec, Option), StashError>; } #[derive(Serialize, Deserialize)] pub struct Entry { pub contents: Vec, pub mime: Option, } impl fmt::Display for Entry { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let preview = preview_entry(&self.contents, self.mime.as_deref(), 100); write!(f, "{preview}") } } pub struct SqliteClipboardDb { pub conn: Connection, pub db_path: PathBuf, } impl SqliteClipboardDb { pub fn new( mut conn: Connection, db_path: PathBuf, ) -> Result { conn .pragma_update(None, "synchronous", "OFF") .map_err(|e| { StashError::Store( format!("Failed to set synchronous pragma: {e}").into(), ) })?; conn .pragma_update(None, "journal_mode", "MEMORY") .map_err(|e| { StashError::Store( format!("Failed to set journal_mode pragma: {e}").into(), ) })?; conn.pragma_update(None, "cache_size", "-256") // 256KB cache .map_err(|e| StashError::Store(format!("Failed to set cache_size pragma: {e}").into()))?; conn .pragma_update(None, "temp_store", "memory") .map_err(|e| { StashError::Store( format!("Failed to set temp_store pragma: {e}").into(), ) })?; conn.pragma_update(None, "mmap_size", "0") // disable mmap .map_err(|e| StashError::Store(format!("Failed to set mmap_size pragma: {e}").into()))?; conn.pragma_update(None, "page_size", "512") // small(er) pages .map_err(|e| StashError::Store(format!("Failed to set page_size pragma: {e}").into()))?; let tx = conn.transaction().map_err(|e| { StashError::Store( format!("Failed to begin migration transaction: {e}").into(), ) })?; let schema_version: i64 = tx .pragma_query_value(None, "user_version", |row| row.get(0)) .map_err(|e| { StashError::Store(format!("Failed to read schema version: {e}").into()) })?; if schema_version == 0 { tx.execute_batch( "CREATE TABLE IF NOT EXISTS clipboard ( id INTEGER PRIMARY KEY AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT );", ) .map_err(|e| { StashError::Store( format!("Failed to create clipboard table: {e}").into(), ) })?; tx.execute("PRAGMA user_version = 1", []).map_err(|e| { StashError::Store(format!("Failed to set schema version: {e}").into()) })?; } // Add content_hash column if it doesn't exist. Migration MUST be done to // avoid breaking existing installations. if schema_version < 2 { let has_content_hash: bool = tx .query_row( "SELECT sql FROM sqlite_master WHERE type='table' AND \ name='clipboard'", [], |row| { let sql: String = row.get(0)?; Ok(sql.to_lowercase().contains("content_hash")) }, ) .unwrap_or(false); if !has_content_hash { tx.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", []) .map_err(|e| { StashError::Store( format!("Failed to add content_hash column: {e}").into(), ) })?; } // Create index for content_hash if it doesn't exist tx.execute( "CREATE INDEX IF NOT EXISTS idx_content_hash ON \ clipboard(content_hash)", [], ) .map_err(|e| { StashError::Store( format!("Failed to create content_hash index: {e}").into(), ) })?; tx.execute("PRAGMA user_version = 2", []).map_err(|e| { StashError::Store(format!("Failed to set schema version: {e}").into()) })?; } // Add last_accessed column if it doesn't exist if schema_version < 3 { let has_last_accessed: bool = tx .query_row( "SELECT sql FROM sqlite_master WHERE type='table' AND \ name='clipboard'", [], |row| { let sql: String = row.get(0)?; Ok(sql.to_lowercase().contains("last_accessed")) }, ) .unwrap_or(false); if !has_last_accessed { tx.execute("ALTER TABLE clipboard ADD COLUMN last_accessed INTEGER", [ ]) .map_err(|e| { StashError::Store( format!("Failed to add last_accessed column: {e}").into(), ) })?; } // Create index for last_accessed if it doesn't exist tx.execute( "CREATE INDEX IF NOT EXISTS idx_last_accessed ON \ clipboard(last_accessed)", [], ) .map_err(|e| { StashError::Store( format!("Failed to create last_accessed index: {e}").into(), ) })?; tx.execute("PRAGMA user_version = 3", []).map_err(|e| { StashError::Store(format!("Failed to set schema version: {e}").into()) })?; } // Add expires_at column if it doesn't exist (v4) if schema_version < 4 { let has_expires_at: bool = tx .query_row( "SELECT sql FROM sqlite_master WHERE type='table' AND \ name='clipboard'", [], |row| { let sql: String = row.get(0)?; Ok(sql.to_lowercase().contains("expires_at")) }, ) .unwrap_or(false); if !has_expires_at { tx.execute("ALTER TABLE clipboard ADD COLUMN expires_at REAL", []) .map_err(|e| { StashError::Store( format!("Failed to add expires_at column: {e}").into(), ) })?; } // Create partial index for expires_at (only index non-NULL values) tx.execute( "CREATE INDEX IF NOT EXISTS idx_expires_at ON clipboard(expires_at) \ WHERE expires_at IS NOT NULL", [], ) .map_err(|e| { StashError::Store( format!("Failed to create expires_at index: {e}").into(), ) })?; tx.execute("PRAGMA user_version = 4", []).map_err(|e| { StashError::Store(format!("Failed to set schema version: {e}").into()) })?; } // Add is_expired column if it doesn't exist (v5) if schema_version < 5 { let has_is_expired: bool = tx .query_row( "SELECT sql FROM sqlite_master WHERE type='table' AND \ name='clipboard'", [], |row| { let sql: String = row.get(0)?; Ok(sql.to_lowercase().contains("is_expired")) }, ) .unwrap_or(false); if !has_is_expired { tx.execute( "ALTER TABLE clipboard ADD COLUMN is_expired INTEGER DEFAULT 0", [], ) .map_err(|e| { StashError::Store( format!("Failed to add is_expired column: {e}").into(), ) })?; } // Create index for is_expired (for filtering) tx.execute( "CREATE INDEX IF NOT EXISTS idx_is_expired ON clipboard(is_expired) \ WHERE is_expired = 1", [], ) .map_err(|e| { StashError::Store( format!("Failed to create is_expired index: {e}").into(), ) })?; tx.execute("PRAGMA user_version = 5", []).map_err(|e| { StashError::Store(format!("Failed to set schema version: {e}").into()) })?; } // Add mime_types column if it doesn't exist (v6) // Stores all MIME types offered by the source application as JSON array. // Needed for clipboard persistence to re-offer the same types. if schema_version < 6 { let has_mime_types: bool = tx .query_row( "SELECT sql FROM sqlite_master WHERE type='table' AND \ name='clipboard'", [], |row| { let sql: String = row.get(0)?; Ok(sql.to_lowercase().contains("mime_types")) }, ) .unwrap_or(false); if !has_mime_types { tx.execute("ALTER TABLE clipboard ADD COLUMN mime_types TEXT", []) .map_err(|e| { StashError::Store( format!("Failed to add mime_types column: {e}").into(), ) })?; } tx.execute("PRAGMA user_version = 6", []).map_err(|e| { StashError::Store(format!("Failed to set schema version: {e}").into()) })?; } tx.commit().map_err(|e| { StashError::Store( format!("Failed to commit migration transaction: {e}").into(), ) })?; // Initialize Wayland state in background thread. This will be used to track // focused window state. #[cfg(feature = "use-toplevel")] crate::wayland::init_wayland_state(); Ok(Self { conn, db_path }) } } impl SqliteClipboardDb { pub fn list_json( &self, include_expired: bool, reverse: bool, ) -> Result { let builder = ListQueryBuilder::new(include_expired, reverse); let query = builder.select_star_query(); let mut stmt = self .conn .prepare(&query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut entries = Vec::new(); while let Some(row) = rows .next() .map_err(|e| StashError::ListDecode(e.to_string().into()))? { let id: i64 = row .get(0) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let contents: Vec = row .get(1) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mime: Option = row .get(2) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let contents_str = match mime.as_deref() { Some(m) if m.starts_with("text/") || m == "application/json" => { String::from_utf8_lossy(&contents).into_owned() }, _ => base64::prelude::BASE64_STANDARD.encode(&contents), }; entries.push(serde_json::json!({ "id": id, "contents": contents_str, "mime": mime, })); } serde_json::to_string_pretty(&entries) .map_err(|e| StashError::ListDecode(e.to_string().into())) } } impl ClipboardDb for SqliteClipboardDb { fn store_entry( &self, mut input: impl Read, max_dedupe_search: u64, max_items: u64, excluded_apps: Option<&[String]>, min_size: Option, max_size: usize, content_hash: Option, mime_types: Option<&[String]>, ) -> Result { let mut buf = Vec::new(); if input.read_to_end(&mut buf).is_err() || buf.is_empty() { return Err(StashError::EmptyOrTooLarge); } let size = buf.len(); if let Some(min) = min_size && size < min { return Err(StashError::TooSmall(min)); } if size > max_size { return Err(StashError::TooLarge(max_size)); } if buf.iter().all(u8::is_ascii_whitespace) { return Err(StashError::AllWhitespace); } // Use pre-computed hash if provided, otherwise calculate it let content_hash = content_hash.unwrap_or_else(|| { let mut hasher = Fnv1aHasher::new(); hasher.write(&buf); #[allow(clippy::cast_possible_wrap)] let hash = hasher.finish() as i64; hash }); let mime = crate::mime::detect_mime(&buf); // Try to load regex from systemd credential file, then env var let regex = load_sensitive_regex(); if let Some(re) = regex { // Only check text data if let Ok(s) = std::str::from_utf8(&buf) && re.is_match(s) { warn!("Clipboard entry matches sensitive regex, skipping store."); return Err(StashError::Store("Filtered by sensitive regex".into())); } } // Check if clipboard should be excluded based on running apps if should_exclude_by_app(excluded_apps) { warn!("Clipboard entry excluded by app filter"); return Err(StashError::ExcludedByApp( "Clipboard entry from excluded app".into(), )); } self.deduplicate_by_hash(content_hash, max_dedupe_search)?; let mime_types_json: Option = match mime_types { Some(types) => { Some( serde_json::to_string(&types) .map_err(|e| StashError::Store(e.to_string().into()))?, ) }, None => None, }; self .conn .execute( "INSERT INTO clipboard (contents, mime, content_hash, last_accessed, \ mime_types) VALUES (?1, ?2, ?3, ?4, ?5)", params![ buf, mime, content_hash, std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("Time went backwards") .as_secs() as i64, mime_types_json ], ) .map_err(|e| StashError::Store(e.to_string().into()))?; let id = self .conn .query_row("SELECT last_insert_rowid()", [], |row| row.get(0)) .map_err(|e| StashError::Store(e.to_string().into()))?; self.trim_db(max_items)?; Ok(id) } fn deduplicate_by_hash( &self, content_hash: i64, max: u64, ) -> Result { let mut stmt = self .conn .prepare( "SELECT id FROM clipboard WHERE content_hash = ?1 ORDER BY id DESC \ LIMIT ?2", ) .map_err(|e| StashError::DeduplicationRead(e.to_string().into()))?; let mut rows = stmt .query(params![ content_hash, i64::try_from(max).unwrap_or(i64::MAX) ]) .map_err(|e| StashError::DeduplicationRead(e.to_string().into()))?; let mut deduped = 0; while let Some(row) = rows .next() .map_err(|e| StashError::DeduplicationRead(e.to_string().into()))? { let id: i64 = row .get(0) .map_err(|e| StashError::DeduplicationDecode(e.to_string().into()))?; self .conn .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) .map_err(|e| StashError::DeduplicationRemove(e.to_string().into()))?; deduped += 1; } Ok(deduped) } fn trim_db(&self, max: u64) -> Result<(), StashError> { let count: i64 = self .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) .map_err(|e| StashError::Trim(e.to_string().into()))?; let max_i64 = i64::try_from(max).unwrap_or(i64::MAX); if count > max_i64 { let to_delete = count - max_i64; #[allow(clippy::useless_conversion)] self .conn .execute( "DELETE FROM clipboard WHERE id IN (SELECT id FROM clipboard ORDER \ BY id ASC LIMIT ?1)", params![i64::try_from(to_delete).unwrap_or(i64::MAX)], ) .map_err(|e| StashError::Trim(e.to_string().into()))?; } Ok(()) } fn delete_last(&self) -> Result<(), StashError> { let id: Option = self .conn .query_row( "SELECT id FROM clipboard ORDER BY id DESC LIMIT 1", [], |row| row.get(0), ) .optional() .map_err(|e| StashError::DeleteLast(e.to_string().into()))?; if let Some(id) = id { self .conn .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) .map_err(|e| StashError::DeleteLast(e.to_string().into()))?; Ok(()) } else { Err(StashError::NoEntriesToDelete) } } fn wipe_db(&self) -> Result<(), StashError> { self .conn .execute("DELETE FROM clipboard", []) .map_err(|e| StashError::Wipe(e.to_string().into()))?; self .conn .execute("DELETE FROM sqlite_sequence WHERE name = 'clipboard'", []) .map_err(|e| StashError::Wipe(e.to_string().into()))?; Ok(()) } fn list_entries( &self, mut out: impl Write, preview_width: u32, include_expired: bool, reverse: bool, ) -> Result { let builder = ListQueryBuilder::new(include_expired, reverse); let query = builder.select_star_query(); let mut stmt = self .conn .prepare(&query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = stmt .query([]) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut listed = 0; while let Some(row) = rows .next() .map_err(|e| StashError::ListDecode(e.to_string().into()))? { let id: i64 = row .get(0) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let contents: Vec = row .get(1) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mime: Option = row .get(2) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let preview = preview_entry(&contents, mime.as_deref(), preview_width); if writeln!(out, "{id}\t{preview}").is_ok() { listed += 1; } } Ok(listed) } fn decode_entry( &self, input: impl Read, mut out: impl Write, id_hint: Option, ) -> Result<(), StashError> { let input_str = if let Some(s) = id_hint { s } else { let mut input = BufReader::new(input); let mut buf = String::new(); input .read_to_string(&mut buf) .map_err(|e| StashError::DecodeExtractId(e.to_string().into()))?; buf }; let id: i64 = extract_id(&input_str) .map_err(|e| StashError::DecodeExtractId(e.into()))?; let (contents, _mime): (Vec, Option) = self .conn .query_row( "SELECT contents, mime FROM clipboard WHERE id = ?1", params![id], |row| Ok((row.get(0)?, row.get(1)?)), ) .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; out .write_all(&contents) .map_err(|e| StashError::DecodeWrite(e.to_string().into()))?; log::info!("decoded entry with id {id}"); Ok(()) } fn delete_query(&self, query: &str) -> Result { let mut stmt = self .conn .prepare("SELECT id, contents FROM clipboard") .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; let mut rows = stmt .query([]) .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; let mut deleted = 0; while let Some(row) = rows .next() .map_err(|e| StashError::QueryDelete(e.to_string().into()))? { let id: i64 = row .get(0) .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; let contents: Vec = row .get(1) .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; if contents.windows(query.len()).any(|w| w == query.as_bytes()) { self .conn .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; deleted += 1; } } Ok(deleted) } fn delete_entries(&self, in_: impl Read) -> Result { let reader = BufReader::new(in_); let mut deleted = 0; for line in reader.lines().map_while(Result::ok) { if let Ok(id) = extract_id(&line) { self .conn .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) .map_err(|e| StashError::DeleteEntry(id, e.to_string().into()))?; deleted += 1; } } Ok(deleted) } fn copy_entry( &self, id: i64, ) -> Result<(i64, Vec, Option), StashError> { let (contents, mime, content_hash): (Vec, Option, Option) = self .conn .query_row( "SELECT contents, mime, content_hash FROM clipboard WHERE id = ?1", params![id], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; if let Some(hash) = content_hash { let most_recent_id: Option = self .conn .query_row( "SELECT id FROM clipboard WHERE content_hash = ?1 AND last_accessed \ = (SELECT MAX(last_accessed) FROM clipboard WHERE content_hash = \ ?1)", params![hash], |row| row.get(0), ) .optional() .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; if most_recent_id != Some(id) { self .conn .execute( "UPDATE clipboard SET last_accessed = CAST(strftime('%s', 'now') \ AS INTEGER) WHERE id = ?1", params![id], ) .map_err(|e| StashError::Store(e.to_string().into()))?; } } Ok((id, contents, mime)) } } impl SqliteClipboardDb { /// Count visible clipboard entries, with respect to `include_expired` and /// optional search filter. pub fn count_entries( &self, include_expired: bool, search: Option<&str>, ) -> Result { let builder = ListQueryBuilder::new(include_expired, false).with_search(search); let query = builder.count_query(); let count: i64 = if let Some(pattern) = builder.search_param() { self.conn.query_row(&query, [pattern], |r| r.get(0)) } else { self.conn.query_row(&query, [], |r| r.get(0)) } .map_err(|e| StashError::ListDecode(e.to_string().into()))?; Ok(count.max(0) as usize) } /// Fetch a window of entries for TUI virtual scrolling. /// /// Returns `(id, preview_string, mime_string)` tuples for at most /// `limit` rows starting at `offset` (0-indexed) in the canonical /// display order (most-recently-accessed first, then id DESC). /// Optionally filters by search query in a case-insensitive nabber on text /// content. pub fn fetch_entries_window( &self, include_expired: bool, offset: usize, limit: usize, preview_width: u32, search: Option<&str>, reverse: bool, ) -> Result, StashError> { let builder = ListQueryBuilder::new(include_expired, reverse) .with_search(search) .with_pagination(offset, limit); let query = builder.select_star_query(); let mut stmt = self .conn .prepare(&query) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mut rows = if let Some(pattern) = builder.search_param() { stmt .query(rusqlite::params![pattern]) .map_err(|e| StashError::ListDecode(e.to_string().into()))? } else { stmt .query([]) .map_err(|e| StashError::ListDecode(e.to_string().into()))? }; let mut window = Vec::with_capacity(limit); while let Some(row) = rows .next() .map_err(|e| StashError::ListDecode(e.to_string().into()))? { let id: i64 = row .get(0) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let contents: Vec = row .get(1) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let mime: Option = row .get(2) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let preview = preview_entry(&contents, mime.as_deref(), preview_width); let mime_str = mime.unwrap_or_default(); window.push((id, preview, mime_str)); } Ok(window) } /// Get current Unix timestamp with sub-second precision pub fn now() -> f64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_secs_f64() } /// Clean up all expired entries. Returns count deleted. pub fn cleanup_expired(&self) -> Result { let now = Self::now(); self .conn .execute( "DELETE FROM clipboard WHERE expires_at IS NOT NULL AND expires_at <= \ ?1", [now], ) .map_err(|e| StashError::Trim(e.to_string().into())) } /// Set expiration timestamp for an entry pub fn set_expiration( &self, id: i64, expires_at: f64, ) -> Result<(), StashError> { self .conn .execute( "UPDATE clipboard SET expires_at = ?2 WHERE id = ?1", params![id, expires_at], ) .map_err(|e| StashError::Store(e.to_string().into()))?; Ok(()) } /// Optimize database using VACUUM pub fn vacuum(&self) -> Result<(), StashError> { self .conn .execute("VACUUM", []) .map_err(|e| StashError::Store(e.to_string().into()))?; Ok(()) } /// Get database statistics pub fn stats(&self) -> Result { let total: i64 = self .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let expired: i64 = self .conn .query_row( "SELECT COUNT(*) FROM clipboard WHERE is_expired = 1", [], |row| row.get(0), ) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let active = total - expired; let with_expiration: i64 = self .conn .query_row( "SELECT COUNT(*) FROM clipboard WHERE expires_at IS NOT NULL AND \ (is_expired IS NULL OR is_expired = 0)", [], |row| row.get(0), ) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; // Get database file size let page_count: i64 = self .conn .query_row("PRAGMA page_count", [], |row| row.get(0)) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let page_size: i64 = self .conn .query_row("PRAGMA page_size", [], |row| row.get(0)) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; let size_bytes = page_count * page_size; let size_mb = size_bytes as f64 / 1024.0 / 1024.0; Ok(format!( "Database Statistics:\n\nEntries:\nTotal: {total}\nActive: \ {active}\nExpired: {expired}\nWith TTL: \ {with_expiration}\n\nStorage:\nSize: {size_mb:.2} MB \ ({size_bytes} bytes)\nPages: {page_count}\nPage size: \ {page_size} bytes" )) } } /// Try to load a sensitive regex from systemd credential or env. /// /// # Returns /// /// `Some(Regex)` if present and valid, `None` otherwise. /// /// # Note /// /// This function checks environment variables on every call to pick up /// changes made after daemon startup. Regex compilation is cached by /// pattern to avoid recompilation. fn load_sensitive_regex() -> Option { // Get the current pattern from env vars let pattern = if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") { let file = format!("{regex_path}/clipboard_filter"); fs::read_to_string(&file).ok().map(|s| s.trim().to_string()) } else { env::var("STASH_SENSITIVE_REGEX").ok() }?; // Cache compiled regexes by pattern to avoid recompilation static REGEX_CACHE: OnceLock< Mutex>, > = OnceLock::new(); let cache = REGEX_CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new())); // Check cache first if let Ok(cache) = cache.lock() && let Some(regex) = cache.get(&pattern) { return Some(regex.clone()); } // Compile and cache Regex::new(&pattern).ok().inspect(|regex| { if let Ok(mut cache) = cache.lock() { cache.insert(pattern.clone(), regex.clone()); } }) } pub fn extract_id(input: &str) -> Result { let id_str = input.split('\t').next().unwrap_or(""); id_str.parse().map_err(|_| "invalid id") } pub fn preview_entry(data: &[u8], mime: Option<&str>, width: u32) -> String { if let Some(mime) = mime { if mime.starts_with("image/") { return format!("[[ binary data {} {} ]]", size_str(data.len()), mime); } else if mime == "application/json" || mime.starts_with("text/") { let Ok(s) = str::from_utf8(data) else { return format!("[[ invalid UTF-8 {} ]]", size_str(data.len())); }; let trimmed = s.trim(); if trimmed.len() <= width as usize && !trimmed.chars().any(|c| c.is_whitespace() && c != ' ') { return trimmed.to_string(); } // Only allocate new string if we need to replace whitespace let mut result = String::with_capacity(width as usize + 1); for (char_count, c) in trimmed.chars().enumerate() { if char_count >= width as usize { result.push('…'); break; } if c.is_whitespace() { result.push(' '); } else { result.push(c); } } return result; } } // For non-text/non-image data, try to sniff the MIME type if let Some(sniffed) = data.sniff_mime_type() { return format!("[[ binary data {} {} ]]", size_str(data.len()), sniffed); } // Shouldn't reach here if MIME is properly set, but just in case info!("Mimetype sniffing failed, omitting"); format!("[[ binary data {} ]]", size_str(data.len())) } pub fn size_str(size: usize) -> String { let units = ["B", "KiB", "MiB"]; let mut fsize = if let Ok(val) = u32::try_from(size) { f64::from(val) } else { error!("Clipboard entry size too large for display: {size}"); f64::from(u32::MAX) }; let mut i = 0; while fsize >= 1024.0 && i < units.len() - 1 { fsize /= 1024.0; i += 1; } format!("{:.0} {}", fsize, units[i]) } /// Check if clipboard should be excluded based on excluded apps configuration. /// Uses timing correlation and focused window detection to identify source app. fn should_exclude_by_app(excluded_apps: Option<&[String]>) -> bool { let excluded = match excluded_apps { Some(apps) if !apps.is_empty() => apps, _ => return false, }; // Try multiple detection strategies if detect_excluded_app_activity(excluded) { return true; } false } /// Detect if clipboard likely came from an excluded app using multiple /// strategies. fn detect_excluded_app_activity(excluded_apps: &[String]) -> bool { debug!("Checking clipboard exclusion against: {excluded_apps:?}"); // Strategy 1: Check focused window (compositor-dependent) if let Some(focused_app) = get_focused_window_app() { debug!("Focused window detected: {focused_app}"); if app_matches_exclusion(&focused_app, excluded_apps) { debug!("Clipboard excluded: focused window matches {focused_app}"); return true; } } else { debug!("No focused window detected"); } // Strategy 2: Check recently active processes (timing correlation) // Use cached results to avoid expensive /proc scanning if let Some(active_app) = ProcessCache::get(excluded_apps) { debug!("Clipboard excluded: recent activity from {active_app}"); return true; } debug!("No recently active excluded apps found"); debug!("Clipboard not excluded"); false } /// Try to get the currently focused window application name. fn get_focused_window_app() -> Option { // Try Wayland protocol first #[cfg(feature = "use-toplevel")] if let Some(app) = crate::wayland::get_focused_window_app() { return Some(app); } // Fallback: Check WAYLAND_CLIENT_NAME environment variable if let Ok(client) = env::var("WAYLAND_CLIENT_NAME") && !client.is_empty() { debug!("Found WAYLAND_CLIENT_NAME: {client}"); return Some(client); } debug!("No focused window detection method worked"); None } /// Check for recently active excluded apps using CPU and I/O activity. /// This is the uncached version - use `ProcessCache::get()` for cached access. fn get_recently_active_excluded_app_uncached( excluded_apps: &[String], ) -> Option { let proc_dir = std::path::Path::new("/proc"); if !proc_dir.exists() { return None; } let mut candidates = Vec::new(); if let Ok(entries) = std::fs::read_dir(proc_dir) { for entry in entries.flatten() { if let Ok(pid) = entry.file_name().to_string_lossy().parse::() && let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) { let process_name = comm.trim(); // Check process name against exclusion list if app_matches_exclusion(process_name, excluded_apps) && has_recent_activity(pid) { candidates .push((process_name.to_string(), get_process_activity_score(pid))); } } } } // Return the most active excluded app candidates .into_iter() .max_by_key(|(_, score)| *score) .map(|(name, _)| name) } /// Check if a process has had recent activity (simple heuristic). fn has_recent_activity(pid: u32) -> bool { // Check /proc/PID/stat for recent CPU usage if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { let fields: Vec<&str> = stat.split_whitespace().collect(); if fields.len() > 14 { // Fields 14 and 15 are utime and stime if let (Ok(utime), Ok(stime)) = (fields[13].parse::(), fields[14].parse::()) { let total_time = utime + stime; // Simple heuristic: if process has any significant CPU time, consider // it active return total_time > 100; // arbitrary threshold } } } // Check /proc/PID/io for recent I/O activity if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) { for line in io_stats.lines() { if (line.starts_with("write_bytes:") || line.starts_with("read_bytes:")) && let Some(value_str) = line.split(':').nth(1) && let Ok(value) = value_str.trim().parse::() && value > 1024 * 1024 { // 1MB threshold return true; } } } false } /// Get a simple activity score for process prioritization. fn get_process_activity_score(pid: u32) -> u64 { let mut score = 0; // Add CPU time to score if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { let fields: Vec<&str> = stat.split_whitespace().collect(); if fields.len() > 14 && let (Ok(utime), Ok(stime)) = (fields[13].parse::(), fields[14].parse::()) { score += utime + stime; } } // Add I/O activity to score if let Ok(io_stats) = fs::read_to_string(format!("/proc/{pid}/io")) { for line in io_stats.lines() { if (line.starts_with("write_bytes:") || line.starts_with("read_bytes:")) && let Some(value_str) = line.split(':').nth(1) && let Ok(value) = value_str.trim().parse::() { score += value / 1024; // convert to KB } } } score } /// Check if an app name matches any in the exclusion list. /// Supports basic string matching and simple regex patterns. fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool { debug!("Checking if '{app_name}' matches exclusion list: {excluded_apps:?}"); for excluded in excluded_apps { // Basic string matching (case-insensitive) if app_name.to_lowercase() == excluded.to_lowercase() { debug!("Matched exact string: {app_name} == {excluded}"); return true; } // Simple pattern matching for common cases if excluded.starts_with('^') && excluded.ends_with('$') { // Exact match pattern like ^AppName$ let pattern = &excluded[1..excluded.len() - 1]; if app_name == pattern { debug!("Matched exact pattern: {app_name} == {pattern}"); return true; } } else if excluded.contains('*') { // Simple wildcard matching let pattern = excluded.replace('*', ".*"); if let Ok(regex) = regex::Regex::new(&pattern) && regex.is_match(app_name) { debug!("Matched wildcard pattern: {app_name} matches {excluded}"); return true; } } } debug!("No match found for '{app_name}'"); false } #[cfg(test)] mod tests { use rusqlite::Connection; use super::*; /// Create an in-memory test database with full schema. fn test_db() -> SqliteClipboardDb { let conn = Connection::open_in_memory().expect("Failed to open in-memory db"); SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) .expect("Failed to create test database") } fn get_schema_version(conn: &Connection) -> rusqlite::Result { conn.pragma_query_value(None, "user_version", |row| row.get(0)) } fn table_column_exists(conn: &Connection, table: &str, column: &str) -> bool { let query = format!( "SELECT sql FROM sqlite_master WHERE type='table' AND name='{}'", table ); match conn.query_row(&query, [], |row| row.get::<_, String>(0)) { Ok(sql) => sql.contains(column), Err(_) => false, } } fn index_exists(conn: &Connection, index: &str) -> bool { let query = "SELECT name FROM sqlite_master WHERE type='index' AND name=?1"; conn .query_row(query, [index], |row| row.get::<_, String>(0)) .is_ok() } #[test] fn test_fresh_database_v3_schema() { let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let db_path = temp_dir.path().join("test_fresh.db"); let conn = Connection::open(&db_path).expect("Failed to open database"); let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) .expect("Failed to create database"); assert_eq!( get_schema_version(&db.conn).expect("Failed to get schema version"), 6 ); assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); assert!(table_column_exists(&db.conn, "clipboard", "mime_types")); assert!(index_exists(&db.conn, "idx_content_hash")); assert!(index_exists(&db.conn, "idx_last_accessed")); db.conn .execute( "INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \ VALUES (x'010203', 'text/plain', 12345, 1704067200)", [], ) .expect("Failed to insert test data"); let count: i64 = db .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) .expect("Failed to count"); assert_eq!(count, 1); } #[test] fn test_migration_from_v0() { let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let db_path = temp_dir.path().join("test_v0.db"); let conn = Connection::open(&db_path).expect("Failed to open database"); conn .execute_batch( "CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \ AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT);", ) .expect("Failed to create table"); conn .execute_batch( "INSERT INTO clipboard (contents, mime) VALUES (x'010203', \ 'text/plain')", ) .expect("Failed to insert data"); assert_eq!(get_schema_version(&conn).expect("Failed to get version"), 0); let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) .expect("Failed to create database"); assert_eq!( get_schema_version(&db.conn) .expect("Failed to get version after migration"), 6 ); assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); assert!(table_column_exists(&db.conn, "clipboard", "mime_types")); let count: i64 = db .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) .expect("Failed to count"); assert_eq!(count, 1, "Existing data should be preserved"); } #[test] fn test_migration_from_v1() { let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let db_path = temp_dir.path().join("test_v1.db"); let conn = Connection::open(&db_path).expect("Failed to open database"); conn .execute_batch( "CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \ AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT);", ) .expect("Failed to create table"); conn .pragma_update(None, "user_version", 1i64) .expect("Failed to set version"); conn .execute_batch( "INSERT INTO clipboard (contents, mime) VALUES (x'010203', \ 'text/plain')", ) .expect("Failed to insert data"); let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) .expect("Failed to create database"); assert_eq!( get_schema_version(&db.conn) .expect("Failed to get version after migration"), 6 ); assert!(table_column_exists(&db.conn, "clipboard", "content_hash")); assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); assert!(table_column_exists(&db.conn, "clipboard", "mime_types")); let count: i64 = db .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) .expect("Failed to count"); assert_eq!(count, 1, "Existing data should be preserved"); } #[test] fn test_migration_from_v2() { let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let db_path = temp_dir.path().join("test_v2.db"); let conn = Connection::open(&db_path).expect("Failed to open database"); conn .execute_batch( "CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \ AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT, content_hash \ INTEGER);", ) .expect("Failed to create table"); conn .pragma_update(None, "user_version", 2i64) .expect("Failed to set version"); conn .execute_batch( "INSERT INTO clipboard (contents, mime, content_hash) VALUES \ (x'010203', 'text/plain', 12345)", ) .expect("Failed to insert data"); let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) .expect("Failed to create database"); assert_eq!( get_schema_version(&db.conn) .expect("Failed to get version after migration"), 6 ); assert!(table_column_exists(&db.conn, "clipboard", "last_accessed")); assert!(index_exists(&db.conn, "idx_last_accessed")); assert!(table_column_exists(&db.conn, "clipboard", "mime_types")); let count: i64 = db .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) .expect("Failed to count"); assert_eq!(count, 1, "Existing data should be preserved"); } #[test] fn test_idempotent_migration() { let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let db_path = temp_dir.path().join("test_idempotent.db"); let conn = Connection::open(&db_path).expect("Failed to open database"); conn .execute_batch( "CREATE TABLE IF NOT EXISTS clipboard (id INTEGER PRIMARY KEY \ AUTOINCREMENT, contents BLOB NOT NULL, mime TEXT);", ) .expect("Failed to create table"); let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) .expect("Failed to create database"); let version_after_first = get_schema_version(&db.conn).expect("Failed to get version"); let db2 = SqliteClipboardDb::new(db.conn, db.db_path) .expect("Failed to create database again"); let version_after_second = get_schema_version(&db2.conn).expect("Failed to get version"); assert_eq!(version_after_first, version_after_second); assert_eq!(version_after_first, 6); } #[test] fn test_store_and_retrieve_with_new_columns() { let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let db_path = temp_dir.path().join("test_store.db"); let conn = Connection::open(&db_path).expect("Failed to open database"); let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:")) .expect("Failed to create database"); let test_data = b"Hello, World!"; let cursor = std::io::Cursor::new(test_data.to_vec()); let _id = db .store_entry( cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None, None, ) .expect("Failed to store entry"); let count: i64 = db .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) .expect("Failed to count"); assert_eq!(count, 1, "Existing data should be preserved"); } #[test] fn test_store_uri_list_content() { let db = test_db(); let data = b"file:///home/user/document.pdf\nfile:///home/user/image.png"; let id = db .store_entry( std::io::Cursor::new(data.to_vec()), 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None, None, ) .expect("Failed to store URI list"); let mime: Option = db .conn .query_row("SELECT mime FROM clipboard WHERE id = ?1", [id], |row| { row.get(0) }) .expect("Failed to get mime"); assert_eq!(mime, Some("text/uri-list".to_string())); } #[test] fn test_store_binary_image() { let db = test_db(); // Minimal PNG header let data: Vec = vec![ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature 0x00, 0x00, 0x00, 0x0D, // IHDR chunk length 0x49, 0x48, 0x44, 0x52, // "IHDR" 0x00, 0x00, 0x00, 0x01, // width: 1 0x00, 0x00, 0x00, 0x01, // height: 1 0x08, 0x02, 0x00, 0x00, 0x00, // bit depth, color, etc. 0x90, 0x77, 0x53, 0xDE, // CRC ]; let id = db .store_entry( std::io::Cursor::new(data.clone()), 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None, None, ) .expect("Failed to store image"); let (contents, mime): (Vec, Option) = db .conn .query_row( "SELECT contents, mime FROM clipboard WHERE id = ?1", [id], |row| Ok((row.get(0)?, row.get(1)?)), ) .expect("Failed to get stored entry"); assert_eq!(contents, data); assert_eq!(mime, Some("image/png".to_string())); } #[test] fn test_deduplication() { let db = test_db(); let data = b"duplicate content"; let id1 = db .store_entry( std::io::Cursor::new(data.to_vec()), 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None, None, ) .expect("Failed to store first"); let _id2 = db .store_entry( std::io::Cursor::new(data.to_vec()), 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None, None, ) .expect("Failed to store second"); // First entry should have been removed by deduplication let count: i64 = db .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) .expect("Failed to count"); assert_eq!(count, 1, "Deduplication should keep only one copy"); // The original id should be gone let exists: bool = db .conn .query_row( "SELECT COUNT(*) FROM clipboard WHERE id = ?1", [id1], |row| row.get::<_, i64>(0), ) .map(|c| c > 0) .unwrap_or(false); assert!(!exists, "Old entry should be removed"); } #[test] fn test_trim_excess_entries() { let db = test_db(); for i in 0..5 { let data = format!("entry {i}"); db.store_entry( std::io::Cursor::new(data.into_bytes()), 100, 3, // max 3 items None, None, DEFAULT_MAX_ENTRY_SIZE, None, None, ) .expect("Failed to store"); } let count: i64 = db .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) .expect("Failed to count"); assert!(count <= 3, "Trim should keep at most max_items entries"); } #[test] fn test_reject_empty_input() { let db = test_db(); let result = db.store_entry( std::io::Cursor::new(Vec::new()), 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None, None, ); assert!(matches!(result, Err(StashError::EmptyOrTooLarge))); } #[test] fn test_reject_whitespace_input() { let db = test_db(); let result = db.store_entry( std::io::Cursor::new(b" \n\t ".to_vec()), 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None, None, ); assert!(matches!(result, Err(StashError::AllWhitespace))); } #[test] fn test_reject_oversized_input() { let db = test_db(); // 5MB + 1 byte let data = vec![b'a'; 5 * 1_000_000 + 1]; let result = db.store_entry( std::io::Cursor::new(data), 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None, None, ); assert!(matches!(result, Err(StashError::TooLarge(5000000)))); } #[test] fn test_delete_entries_by_id() { let db = test_db(); let id = db .store_entry( std::io::Cursor::new(b"to delete".to_vec()), 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None, None, ) .expect("Failed to store"); let input = format!("{id}\tpreview text\n"); let deleted = db .delete_entries(std::io::Cursor::new(input.into_bytes())) .expect("Failed to delete"); assert_eq!(deleted, 1); let count: i64 = db .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) .expect("Failed to count"); assert_eq!(count, 0); } #[test] fn test_delete_query_matching() { let db = test_db(); db.store_entry( std::io::Cursor::new(b"secret password 123".to_vec()), 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None, None, ) .expect("Failed to store"); db.store_entry( std::io::Cursor::new(b"normal text".to_vec()), 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None, None, ) .expect("Failed to store"); let deleted = db .delete_query("secret password") .expect("Failed to delete query"); assert_eq!(deleted, 1); let count: i64 = db .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) .expect("Failed to count"); assert_eq!(count, 1); } #[test] fn test_wipe_db() { let db = test_db(); for i in 0..3 { let data = format!("entry {i}"); db.store_entry( std::io::Cursor::new(data.into_bytes()), 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None, None, ) .expect("Failed to store"); } db.wipe_db().expect("Failed to wipe"); let count: i64 = db .conn .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) .expect("Failed to count"); assert_eq!(count, 0); } #[test] fn test_extract_id_valid() { assert_eq!(extract_id("42\tsome preview"), Ok(42)); assert_eq!(extract_id("1"), Ok(1)); assert_eq!(extract_id("999\t"), Ok(999)); } #[test] fn test_extract_id_invalid() { assert!(extract_id("abc\tpreview").is_err()); assert!(extract_id("").is_err()); assert!(extract_id("\tpreview").is_err()); } #[test] fn test_preview_entry_text() { let data = b"Hello, world!"; let preview = preview_entry(data, Some("text/plain"), 100); assert_eq!(preview, "Hello, world!"); } #[test] fn test_preview_entry_image() { let data = vec![0x89, 0x50, 0x4E, 0x47]; // PNG-ish bytes let preview = preview_entry(&data, Some("image/png"), 100); assert!(preview.contains("binary data")); assert!(preview.contains("image/png")); } #[test] fn test_preview_entry_truncation() { let data = b"This is a rather long piece of text that should be truncated"; let preview = preview_entry(data, Some("text/plain"), 10); assert!(preview.len() <= 15); // 10 chars + ellipsis (multi-byte) assert!(preview.ends_with('…')); } #[test] fn test_size_str_formatting() { assert_eq!(size_str(0), "0 B"); assert_eq!(size_str(512), "512 B"); assert_eq!(size_str(1024), "1 KiB"); assert_eq!(size_str(1024 * 1024), "1 MiB"); } #[test] fn test_preview_entry_binary_sniffed() { // PDF magic bytes let data = b"%PDF-1.4 fake pdf content here for testing"; let preview = preview_entry(data, None, 100); assert!(preview.contains("binary data")); assert!(preview.contains("application/pdf")); } #[test] fn test_copy_entry_returns_data() { let db = test_db(); let data = b"copy me"; let id = db .store_entry( std::io::Cursor::new(data.to_vec()), 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None, None, ) .expect("Failed to store"); let (returned_id, contents, mime) = db.copy_entry(id).expect("Failed to copy"); assert_eq!(returned_id, id); assert_eq!(contents, data.to_vec()); assert_eq!(mime, Some("text/plain".to_string())); } #[test] fn test_fnv1a_hasher_deterministic() { // Same input should produce same hash let data = b"test data"; let mut hasher1 = Fnv1aHasher::new(); hasher1.write(data); let hash1 = hasher1.finish(); let mut hasher2 = Fnv1aHasher::new(); hasher2.write(data); let hash2 = hasher2.finish(); assert_eq!(hash1, hash2, "FNV-1a should produce deterministic hashes"); } #[test] fn test_fnv1a_hasher_different_input() { // Different inputs should (almost certainly) produce different hashes let data1 = b"test data 1"; let data2 = b"test data 2"; let mut hasher1 = Fnv1aHasher::new(); hasher1.write(data1); let hash1 = hasher1.finish(); let mut hasher2 = Fnv1aHasher::new(); hasher2.write(data2); let hash2 = hasher2.finish(); assert_ne!( hash1, hash2, "Different data should produce different hashes" ); } #[test] fn test_fnv1a_hasher_known_values() { // Test against known FNV-1a hash values let mut hasher = Fnv1aHasher::new(); hasher.write(b""); assert_eq!( hasher.finish(), 0xCBF29CE484222325, "Empty string hash mismatch" ); let mut hasher = Fnv1aHasher::new(); hasher.write(b"a"); assert_eq!( hasher.finish(), 0xAF63DC4C8601EC8C, "Single byte hash mismatch" ); let mut hasher = Fnv1aHasher::new(); hasher.write(b"hello"); assert_eq!(hasher.finish(), 0xA430D84680AABD0B, "Hello hash mismatch"); } #[test] fn test_fnv1a_hash_stored_in_db() { // Verify hash is stored correctly and can be retrieved let db = test_db(); let data = b"test content for hashing"; let id = db .store_entry( std::io::Cursor::new(data.to_vec()), 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None, None, ) .expect("Failed to store"); // Retrieve the stored hash let stored_hash: i64 = db .conn .query_row( "SELECT content_hash FROM clipboard WHERE id = ?1", [id], |row| row.get(0), ) .expect("Failed to get hash"); // Calculate hash independently let mut hasher = Fnv1aHasher::new(); hasher.write(data); let calculated_hash = hasher.finish() as i64; assert_eq!( stored_hash, calculated_hash, "Stored hash should match calculated hash" ); // Verify round-trip: convert back to u64 and compare let stored_hash_u64 = stored_hash as u64; let calculated_hash_u64 = hasher.finish(); assert_eq!( stored_hash_u64, calculated_hash_u64, "Bit pattern should be preserved in i64/u64 conversion" ); } /// Verify that regex loading picks up env var changes. This was broken /// because CHECKED flag prevented re-checking after first call #[test] fn test_sensitive_regex_env_var_change_detection() { // XXX: This test manipulates environment variables which affects // parallel tests. We use a unique pattern to avoid conflicts. use std::sync::atomic::{AtomicUsize, Ordering}; static TEST_COUNTER: AtomicUsize = AtomicUsize::new(0); let test_id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); // Test 1: No env var set initially let var_name = format!("STASH_SENSITIVE_REGEX_TEST_{}", test_id); unsafe { env::remove_var(&var_name); } // Temporarily override the function to use our test var // Since we can't easily mock env::var, we test the logic indirectly // by verifying the new implementation checks every time // Call multiple times, ensure no panic and behavior is // consistent let _ = load_sensitive_regex(); let _ = load_sensitive_regex(); let _ = load_sensitive_regex(); // If we got here without deadlocks or panics, the caching logic works // The actual env var change detection is verified by the implementation: // - Preivously CHECKED atomic prevented re-checking // - Now we check env vars every call, only caches compiled Regex objects } /// Test that regex compilation is cached by pattern #[test] fn test_sensitive_regex_caching_by_pattern() { // This test verifies that the regex cache works correctly // by ensuring multiple calls don't cause issues. // Call multiple times, should use cache after first compilation let result1 = load_sensitive_regex(); let result2 = load_sensitive_regex(); let result3 = load_sensitive_regex(); // All results should be consistent assert_eq!( result1.is_some(), result2.is_some(), "Regex loading should be deterministic" ); assert_eq!( result2.is_some(), result3.is_some(), "Regex loading should be deterministic" ); } }