stash: refactor error handling and entry deduplication

This includes breaking changes to the database entries, where we have
started deduplicating based on hashes instead of full entries. Entry
collisions are possible, but highly unlikely.

Additionally we use `Box<str>` for error variants to reduce allocations.
This is *yet* to give me a non-marginal performance benefit but doesn't
hurt to be more correct.

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6a6a6964d0a33392da61372214ca3088551564ac
This commit is contained in:
raf 2025-09-19 13:46:09 +03:00
commit a41d72fb6b
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
7 changed files with 348 additions and 258 deletions

View file

@ -26,7 +26,7 @@ impl DecodeCommand for SqliteClipboardDb {
let mut buf = String::new(); let mut buf = String::new();
in_ in_
.read_to_string(&mut buf) .read_to_string(&mut buf)
.map_err(|e| StashError::DecodeRead(e.to_string()))?; .map_err(|e| StashError::DecodeRead(e.to_string().into()))?;
buf buf
}; };
@ -38,18 +38,18 @@ impl DecodeCommand for SqliteClipboardDb {
{ {
let mut buf = Vec::new(); let mut buf = Vec::new();
reader.read_to_end(&mut buf).map_err(|e| { reader.read_to_end(&mut buf).map_err(|e| {
StashError::DecodeRead(format!( StashError::DecodeRead(
"Failed to read clipboard for relay: {e}" format!("Failed to read clipboard for relay: {e}").into(),
)) )
})?; })?;
out.write_all(&buf).map_err(|e| { out.write_all(&buf).map_err(|e| {
StashError::DecodeWrite(format!( StashError::DecodeWrite(
"Failed to write clipboard relay: {e}" format!("Failed to write clipboard relay: {e}").into(),
)) )
})?; })?;
} else { } else {
return Err(StashError::DecodeGet( return Err(StashError::DecodeGet(
"Failed to get clipboard contents for relay".to_string(), "Failed to get clipboard contents for relay".into(),
)); ));
} }
return Ok(()); return Ok(());
@ -69,14 +69,14 @@ impl DecodeCommand for SqliteClipboardDb {
{ {
let mut buf = Vec::new(); let mut buf = Vec::new();
reader.read_to_end(&mut buf).map_err(|err| { reader.read_to_end(&mut buf).map_err(|err| {
StashError::DecodeRead(format!( StashError::DecodeRead(
"Failed to read clipboard for relay: {err}" format!("Failed to read clipboard for relay: {err}").into(),
)) )
})?; })?;
out.write_all(&buf).map_err(|err| { out.write_all(&buf).map_err(|err| {
StashError::DecodeWrite(format!( StashError::DecodeWrite(
"Failed to write clipboard relay: {err}" format!("Failed to write clipboard relay: {err}").into(),
)) )
})?; })?;
Ok(()) Ok(())
} else { } else {

View file

@ -27,19 +27,19 @@ impl ImportCommand for SqliteClipboardDb {
let mut imported = 0; let mut imported = 0;
for (lineno, line) in reader.lines().enumerate() { for (lineno, line) in reader.lines().enumerate() {
let line = line.map_err(|e| { let line = line.map_err(|e| {
StashError::Store(format!("Failed to read line {lineno}: {e}")) StashError::Store(format!("Failed to read line {lineno}: {e}").into())
})?; })?;
let mut parts = line.splitn(2, '\t'); let mut parts = line.splitn(2, '\t');
let (Some(id_str), Some(val)) = (parts.next(), parts.next()) else { let (Some(id_str), Some(val)) = (parts.next(), parts.next()) else {
return Err(StashError::Store(format!( return Err(StashError::Store(
"Malformed TSV line {lineno}: {line:?}" format!("Malformed TSV line {lineno}: {line:?}").into(),
))); ));
}; };
let Ok(_id) = id_str.parse::<u64>() else { let Ok(_id) = id_str.parse::<u64>() else {
return Err(StashError::Store(format!( return Err(StashError::Store(
"Failed to parse id from line {lineno}: {id_str}" format!("Failed to parse id from line {lineno}: {id_str}").into(),
))); ));
}; };
let entry = Entry { let entry = Entry {
@ -54,9 +54,9 @@ impl ImportCommand for SqliteClipboardDb {
rusqlite::params![entry.contents, entry.mime], rusqlite::params![entry.contents, entry.mime],
) )
.map_err(|e| { .map_err(|e| {
StashError::Store(format!( StashError::Store(
"Failed to insert entry at line {lineno}: {e}" format!("Failed to insert entry at line {lineno}: {e}").into(),
)) )
})?; })?;
imported += 1; imported += 1;
} }

View file

@ -47,27 +47,27 @@ impl SqliteClipboardDb {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC")
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut rows = stmt let mut rows = stmt
.query([]) .query([])
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut entries: Vec<(u64, String, String)> = Vec::new(); let mut entries: Vec<(u64, String, String)> = Vec::new();
let mut max_id_width = 2; let mut max_id_width = 2;
let mut max_mime_width = 8; let mut max_mime_width = 8;
while let Some(row) = rows while let Some(row) = rows
.next() .next()
.map_err(|e| StashError::ListDecode(e.to_string()))? .map_err(|e| StashError::ListDecode(e.to_string().into()))?
{ {
let id: u64 = row let id: u64 = row
.get(0) .get(0)
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let contents: Vec<u8> = row let contents: Vec<u8> = row
.get(1) .get(1)
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mime: Option<String> = row let mime: Option<String> = row
.get(2) .get(2)
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let preview = let preview =
crate::db::preview_entry(&contents, mime.as_deref(), preview_width); crate::db::preview_entry(&contents, mime.as_deref(), preview_width);
let mime_str = mime.as_deref().unwrap_or("").to_string(); let mime_str = mime.as_deref().unwrap_or("").to_string();
@ -77,13 +77,14 @@ impl SqliteClipboardDb {
entries.push((id, preview, mime_str)); entries.push((id, preview, mime_str));
} }
enable_raw_mode().map_err(|e| StashError::ListDecode(e.to_string()))?; enable_raw_mode()
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut stdout = stdout(); let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture) execute!(stdout, EnterAlternateScreen, EnableMouseCapture)
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend) let mut terminal = Terminal::new(backend)
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut state = ListState::default(); let mut state = ListState::default();
if !entries.is_empty() { if !entries.is_empty() {
@ -225,13 +226,13 @@ impl SqliteClipboardDb {
f.render_stateful_widget(list, area, &mut state); f.render_stateful_widget(list, area, &mut state);
}) })
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
if event::poll(std::time::Duration::from_millis(250)) if event::poll(std::time::Duration::from_millis(250))
.map_err(|e| StashError::ListDecode(e.to_string()))? .map_err(|e| StashError::ListDecode(e.to_string().into()))?
{ {
if let Event::Key(key) = if let Event::Key(key) = event::read()
event::read().map_err(|e| StashError::ListDecode(e.to_string()))? .map_err(|e| StashError::ListDecode(e.to_string().into()))?
{ {
match key.code { match key.code {
KeyCode::Char('q') | KeyCode::Esc => break, KeyCode::Char('q') | KeyCode::Esc => break,

View file

@ -1,9 +1,14 @@
use std::{io::Read, time::Duration}; use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
io::Read,
time::Duration,
};
use smol::Timer; use smol::Timer;
use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents}; use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents};
use crate::db::{ClipboardDb, Entry, SqliteClipboardDb}; use crate::db::{ClipboardDb, SqliteClipboardDb};
pub trait WatchCommand { pub trait WatchCommand {
fn watch( fn watch(
@ -24,11 +29,18 @@ impl WatchCommand for SqliteClipboardDb {
smol::block_on(async { smol::block_on(async {
log::info!("Starting clipboard watch daemon"); log::info!("Starting clipboard watch daemon");
// Preallocate buffer for clipboard contents // We use hashes for comparison instead of storing full contents
let mut last_contents: Option<Vec<u8>> = None; let mut last_hash: Option<u64> = None;
let mut buf = Vec::with_capacity(4096); // reasonable default, hopefully let mut buf = Vec::with_capacity(4096);
// Initialize with current clipboard to avoid duplicating on startup // Helper to hash clipboard contents
let hash_contents = |data: &[u8]| -> u64 {
let mut hasher = DefaultHasher::new();
data.hash(&mut hasher);
hasher.finish()
};
// Initialize with current clipboard
if let Ok((mut reader, _)) = get_contents( if let Ok((mut reader, _)) = get_contents(
ClipboardType::Regular, ClipboardType::Regular,
Seat::Unspecified, Seat::Unspecified,
@ -36,7 +48,7 @@ impl WatchCommand for SqliteClipboardDb {
) { ) {
buf.clear(); buf.clear();
if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() { if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
last_contents = Some(buf.clone()); last_hash = Some(hash_contents(&buf));
} }
} }
@ -46,7 +58,7 @@ impl WatchCommand for SqliteClipboardDb {
Seat::Unspecified, Seat::Unspecified,
wl_clipboard_rs::paste::MimeType::Any, wl_clipboard_rs::paste::MimeType::Any,
) { ) {
Ok((mut reader, mime_type)) => { Ok((mut reader, _mime_type)) => {
buf.clear(); buf.clear();
if let Err(e) = reader.read_to_end(&mut buf) { if let Err(e) = reader.read_to_end(&mut buf) {
log::error!("Failed to read clipboard contents: {e}"); log::error!("Failed to read clipboard contents: {e}");
@ -55,38 +67,35 @@ impl WatchCommand for SqliteClipboardDb {
} }
// Only store if changed and not empty // Only store if changed and not empty
if !buf.is_empty() && (last_contents.as_ref() != Some(&buf)) { if !buf.is_empty() {
let new_contents = std::mem::take(&mut buf); let current_hash = hash_contents(&buf);
let mime = Some(mime_type.to_string()); if last_hash != Some(current_hash) {
let entry = Entry { let id = self.next_sequence();
contents: new_contents.clone(), match self.store_entry(
mime, &buf[..],
}; max_dedupe_search,
let id = self.next_sequence(); max_items,
match self.store_entry( Some(excluded_apps),
&entry.contents[..], ) {
max_dedupe_search, Ok(_) => {
max_items, log::info!("Stored new clipboard entry (id: {id})");
Some(excluded_apps), last_hash = Some(current_hash);
) { },
Ok(_) => { Err(crate::db::StashError::ExcludedByApp(_)) => {
log::info!("Stored new clipboard entry (id: {id})"); log::info!("Clipboard entry excluded by app filter");
last_contents = Some(new_contents); last_hash = Some(current_hash);
}, },
Err(crate::db::StashError::ExcludedByApp(_)) => { Err(crate::db::StashError::Store(ref msg))
log::info!("Clipboard entry excluded by app filter"); if msg.contains("Excluded by app filter") =>
last_contents = Some(new_contents); {
}, log::info!("Clipboard entry excluded by app filter");
Err(crate::db::StashError::Store(ref msg)) last_hash = Some(current_hash);
if msg.contains("Excluded by app filter") => },
{ Err(e) => {
log::info!("Clipboard entry excluded by app filter"); log::error!("Failed to store clipboard entry: {e}");
last_contents = Some(new_contents); last_hash = Some(current_hash);
}, },
Err(e) => { }
log::error!("Failed to store clipboard entry: {e}");
last_contents = Some(new_contents);
},
} }
} }
}, },

View file

@ -1,18 +1,20 @@
use std::{ use std::{
collections::hash_map::DefaultHasher,
env, env,
fmt, fmt,
fs, fs,
hash::{Hash, Hasher},
io::{BufRead, BufReader, Read, Write}, io::{BufRead, BufReader, Read, Write},
str, str,
sync::OnceLock,
}; };
use base64::{Engine, engine::general_purpose::STANDARD}; use base64::prelude::*;
use imagesize::{ImageSize, ImageType}; use imagesize::ImageType;
use log::{debug, error, info, warn}; use log::{debug, error, warn};
use regex::Regex; use regex::Regex;
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json;
use thiserror::Error; use thiserror::Error;
#[derive(Error, Debug)] #[derive(Error, Debug)]
@ -23,38 +25,38 @@ pub enum StashError {
AllWhitespace, AllWhitespace,
#[error("Failed to store entry: {0}")] #[error("Failed to store entry: {0}")]
Store(String), Store(Box<str>),
#[error("Entry excluded by app filter: {0}")] #[error("Entry excluded by app filter: {0}")]
ExcludedByApp(String), ExcludedByApp(Box<str>),
#[error("Error reading entry during deduplication: {0}")] #[error("Error reading entry during deduplication: {0}")]
DeduplicationRead(String), DeduplicationRead(Box<str>),
#[error("Error decoding entry during deduplication: {0}")] #[error("Error decoding entry during deduplication: {0}")]
DeduplicationDecode(String), DeduplicationDecode(Box<str>),
#[error("Failed to remove entry during deduplication: {0}")] #[error("Failed to remove entry during deduplication: {0}")]
DeduplicationRemove(String), DeduplicationRemove(Box<str>),
#[error("Failed to trim entry: {0}")] #[error("Failed to trim entry: {0}")]
Trim(String), Trim(Box<str>),
#[error("No entries to delete")] #[error("No entries to delete")]
NoEntriesToDelete, NoEntriesToDelete,
#[error("Failed to delete last entry: {0}")] #[error("Failed to delete last entry: {0}")]
DeleteLast(String), DeleteLast(Box<str>),
#[error("Failed to wipe database: {0}")] #[error("Failed to wipe database: {0}")]
Wipe(String), Wipe(Box<str>),
#[error("Failed to decode entry during list: {0}")] #[error("Failed to decode entry during list: {0}")]
ListDecode(String), ListDecode(Box<str>),
#[error("Failed to read input for decode: {0}")] #[error("Failed to read input for decode: {0}")]
DecodeRead(String), DecodeRead(Box<str>),
#[error("Failed to extract id for decode: {0}")] #[error("Failed to extract id for decode: {0}")]
DecodeExtractId(String), DecodeExtractId(Box<str>),
#[error("Failed to get entry for decode: {0}")] #[error("Failed to get entry for decode: {0}")]
DecodeGet(String), DecodeGet(Box<str>),
#[error("Failed to write decoded entry: {0}")] #[error("Failed to write decoded entry: {0}")]
DecodeWrite(String), DecodeWrite(Box<str>),
#[error("Failed to delete entry during query delete: {0}")] #[error("Failed to delete entry during query delete: {0}")]
QueryDelete(String), QueryDelete(Box<str>),
#[error("Failed to delete entry with id {0}: {1}")] #[error("Failed to delete entry with id {0}: {1}")]
DeleteEntry(u64, String), DeleteEntry(u64, Box<str>),
} }
pub trait ClipboardDb { pub trait ClipboardDb {
@ -65,8 +67,13 @@ pub trait ClipboardDb {
max_items: u64, max_items: u64,
excluded_apps: Option<&[String]>, excluded_apps: Option<&[String]>,
) -> Result<u64, StashError>; ) -> Result<u64, StashError>;
fn deduplicate(&self, buf: &[u8], max: u64) -> Result<usize, StashError>;
fn trim_db(&self, max: u64) -> Result<(), StashError>; fn deduplicate_by_hash(
&self,
content_hash: i64,
max: u64,
) -> Result<usize, StashError>;
fn trim_db(&self, max_items: u64) -> Result<(), StashError>;
fn delete_last(&self) -> Result<(), StashError>; fn delete_last(&self) -> Result<(), StashError>;
fn wipe_db(&self) -> Result<(), StashError>; fn wipe_db(&self) -> Result<(), StashError>;
fn list_entries( fn list_entries(
@ -76,12 +83,12 @@ pub trait ClipboardDb {
) -> Result<usize, StashError>; ) -> Result<usize, StashError>;
fn decode_entry( fn decode_entry(
&self, &self,
in_: impl Read, input: impl Read,
out: impl Write, out: impl Write,
input: Option<String>, id_hint: Option<String>,
) -> Result<(), StashError>; ) -> Result<(), StashError>;
fn delete_query(&self, query: &str) -> Result<usize, StashError>; fn delete_query(&self, query: &str) -> Result<usize, StashError>;
fn delete_entries(&self, in_: impl Read) -> Result<usize, StashError>; fn delete_entries(&self, input: impl Read) -> Result<usize, StashError>;
fn next_sequence(&self) -> u64; fn next_sequence(&self) -> u64;
} }
@ -104,6 +111,34 @@ pub struct SqliteClipboardDb {
impl SqliteClipboardDb { impl SqliteClipboardDb {
pub fn new(conn: Connection) -> Result<Self, StashError> { pub fn new(conn: Connection) -> Result<Self, StashError> {
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()))?;
conn conn
.execute_batch( .execute_batch(
"CREATE TABLE IF NOT EXISTS clipboard ( "CREATE TABLE IF NOT EXISTS clipboard (
@ -112,8 +147,21 @@ impl SqliteClipboardDb {
mime TEXT mime TEXT
);", );",
) )
.map_err(|e| StashError::Store(e.to_string()))?; .map_err(|e| StashError::Store(e.to_string().into()))?;
// Initialize Wayland state in background thread
// Add content_hash column if it doesn't exist
// Migration MUST be done to avoid breaking existing installations.
let _ =
conn.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", []);
// Create index for content_hash if it doesn't exist
let _ = conn.execute(
"CREATE INDEX IF NOT EXISTS idx_content_hash ON clipboard(content_hash)",
[],
);
// Initialize Wayland state in background thread. This will be used to track
// focused window state.
#[cfg(feature = "use-toplevel")] #[cfg(feature = "use-toplevel")]
crate::wayland::init_wayland_state(); crate::wayland::init_wayland_state();
Ok(Self { conn }) Ok(Self { conn })
@ -125,33 +173,34 @@ impl SqliteClipboardDb {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC")
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut rows = stmt let mut rows = stmt
.query([]) .query([])
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut entries = Vec::new(); let mut entries = Vec::new();
while let Some(row) = rows while let Some(row) = rows
.next() .next()
.map_err(|e| StashError::ListDecode(e.to_string()))? .map_err(|e| StashError::ListDecode(e.to_string().into()))?
{ {
let id: u64 = row let id: u64 = row
.get(0) .get(0)
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let contents: Vec<u8> = row let contents: Vec<u8> = row
.get(1) .get(1)
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mime: Option<String> = row let mime: Option<String> = row
.get(2) .get(2)
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let contents_str = match mime.as_deref() { let contents_str = match mime.as_deref() {
Some(m) if m.starts_with("text/") || m == "application/json" => { Some(m) if m.starts_with("text/") || m == "application/json" => {
String::from_utf8_lossy(&contents).to_string() String::from_utf8_lossy(&contents).into_owned()
}, },
_ => STANDARD.encode(&contents), _ => base64::prelude::BASE64_STANDARD.encode(&contents),
}; };
entries.push(json!({ entries.push(serde_json::json!({
"id": id, "id": id,
"contents": contents_str, "contents": contents_str,
"mime": mime, "mime": mime,
@ -159,7 +208,7 @@ impl SqliteClipboardDb {
} }
serde_json::to_string_pretty(&entries) serde_json::to_string_pretty(&entries)
.map_err(|e| StashError::ListDecode(e.to_string())) .map_err(|e| StashError::ListDecode(e.to_string().into()))
} }
} }
@ -182,17 +231,13 @@ impl ClipboardDb for SqliteClipboardDb {
return Err(StashError::AllWhitespace); return Err(StashError::AllWhitespace);
} }
let mime = match detect_mime(&buf) { // Calculate content hash for deduplication
None => { let mut hasher = DefaultHasher::new();
// If valid UTF-8, treat as text/plain buf.hash(&mut hasher);
if std::str::from_utf8(&buf).is_ok() { #[allow(clippy::cast_possible_wrap)]
Some("text/plain".to_string()) let content_hash = hasher.finish() as i64;
} else {
None let mime = detect_mime_optimized(&buf);
}
},
other => other,
};
// Try to load regex from systemd credential file, then env var // Try to load regex from systemd credential file, then env var
let regex = load_sensitive_regex(); let regex = load_sensitive_regex();
@ -201,9 +246,7 @@ impl ClipboardDb for SqliteClipboardDb {
if let Ok(s) = std::str::from_utf8(&buf) { if let Ok(s) = std::str::from_utf8(&buf) {
if re.is_match(s) { if re.is_match(s) {
warn!("Clipboard entry matches sensitive regex, skipping store."); warn!("Clipboard entry matches sensitive regex, skipping store.");
return Err(StashError::Store( return Err(StashError::Store("Filtered by sensitive regex".into()));
"Filtered by sensitive regex".to_string(),
));
} }
} }
} }
@ -212,50 +255,56 @@ impl ClipboardDb for SqliteClipboardDb {
if should_exclude_by_app(excluded_apps) { if should_exclude_by_app(excluded_apps) {
warn!("Clipboard entry excluded by app filter"); warn!("Clipboard entry excluded by app filter");
return Err(StashError::ExcludedByApp( return Err(StashError::ExcludedByApp(
"Clipboard entry from excluded app".to_string(), "Clipboard entry from excluded app".into(),
)); ));
} }
self.deduplicate(&buf, max_dedupe_search)?; self.deduplicate_by_hash(content_hash, max_dedupe_search)?;
self self
.conn .conn
.execute( .execute(
"INSERT INTO clipboard (contents, mime) VALUES (?1, ?2)", "INSERT INTO clipboard (contents, mime, content_hash) VALUES (?1, ?2, \
params![buf, mime], ?3)",
params![buf, mime.map(|s| s.to_string()), content_hash],
) )
.map_err(|e| StashError::Store(e.to_string()))?; .map_err(|e| StashError::Store(e.to_string().into()))?;
self.trim_db(max_items)?; self.trim_db(max_items)?;
Ok(self.next_sequence()) Ok(self.next_sequence())
} }
fn deduplicate(&self, buf: &[u8], max: u64) -> Result<usize, StashError> { fn deduplicate_by_hash(
&self,
content_hash: i64,
max: u64,
) -> Result<usize, StashError> {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare("SELECT id, contents FROM clipboard ORDER BY id DESC LIMIT ?1") .prepare(
.map_err(|e| StashError::DeduplicationRead(e.to_string()))?; "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 let mut rows = stmt
.query(params![i64::try_from(max).unwrap_or(i64::MAX)]) .query(params![
.map_err(|e| StashError::DeduplicationRead(e.to_string()))?; content_hash,
i64::try_from(max).unwrap_or(i64::MAX)
])
.map_err(|e| StashError::DeduplicationRead(e.to_string().into()))?;
let mut deduped = 0; let mut deduped = 0;
while let Some(row) = rows while let Some(row) = rows
.next() .next()
.map_err(|e| StashError::DeduplicationRead(e.to_string()))? .map_err(|e| StashError::DeduplicationRead(e.to_string().into()))?
{ {
let id: u64 = row let id: u64 = row
.get(0) .get(0)
.map_err(|e| StashError::DeduplicationDecode(e.to_string()))?; .map_err(|e| StashError::DeduplicationDecode(e.to_string().into()))?;
let contents: Vec<u8> = row self
.get(1) .conn
.map_err(|e| StashError::DeduplicationDecode(e.to_string()))?; .execute("DELETE FROM clipboard WHERE id = ?1", params![id])
if contents == buf { .map_err(|e| StashError::DeduplicationRemove(e.to_string().into()))?;
self deduped += 1;
.conn
.execute("DELETE FROM clipboard WHERE id = ?1", params![id])
.map_err(|e| StashError::DeduplicationRemove(e.to_string()))?;
deduped += 1;
}
} }
Ok(deduped) Ok(deduped)
} }
@ -264,7 +313,7 @@ impl ClipboardDb for SqliteClipboardDb {
let count: u64 = self let count: u64 = self
.conn .conn
.query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0)) .query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0))
.map_err(|e| StashError::Trim(e.to_string()))?; .map_err(|e| StashError::Trim(e.to_string().into()))?;
if count > max { if count > max {
let to_delete = count - max; let to_delete = count - max;
self self
@ -274,7 +323,7 @@ impl ClipboardDb for SqliteClipboardDb {
BY id ASC LIMIT ?1)", BY id ASC LIMIT ?1)",
params![i64::try_from(to_delete).unwrap_or(i64::MAX)], params![i64::try_from(to_delete).unwrap_or(i64::MAX)],
) )
.map_err(|e| StashError::Trim(e.to_string()))?; .map_err(|e| StashError::Trim(e.to_string().into()))?;
} }
Ok(()) Ok(())
} }
@ -288,12 +337,12 @@ impl ClipboardDb for SqliteClipboardDb {
|row| row.get(0), |row| row.get(0),
) )
.optional() .optional()
.map_err(|e| StashError::DeleteLast(e.to_string()))?; .map_err(|e| StashError::DeleteLast(e.to_string().into()))?;
if let Some(id) = id { if let Some(id) = id {
self self
.conn .conn
.execute("DELETE FROM clipboard WHERE id = ?1", params![id]) .execute("DELETE FROM clipboard WHERE id = ?1", params![id])
.map_err(|e| StashError::DeleteLast(e.to_string()))?; .map_err(|e| StashError::DeleteLast(e.to_string().into()))?;
Ok(()) Ok(())
} else { } else {
Err(StashError::NoEntriesToDelete) Err(StashError::NoEntriesToDelete)
@ -304,11 +353,11 @@ impl ClipboardDb for SqliteClipboardDb {
self self
.conn .conn
.execute("DELETE FROM clipboard", []) .execute("DELETE FROM clipboard", [])
.map_err(|e| StashError::Wipe(e.to_string()))?; .map_err(|e| StashError::Wipe(e.to_string().into()))?;
self self
.conn .conn
.execute("DELETE FROM sqlite_sequence WHERE name = 'clipboard'", []) .execute("DELETE FROM sqlite_sequence WHERE name = 'clipboard'", [])
.map_err(|e| StashError::Wipe(e.to_string()))?; .map_err(|e| StashError::Wipe(e.to_string().into()))?;
Ok(()) Ok(())
} }
@ -320,24 +369,26 @@ impl ClipboardDb for SqliteClipboardDb {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC") .prepare("SELECT id, contents, mime FROM clipboard ORDER BY id DESC")
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut rows = stmt let mut rows = stmt
.query([]) .query([])
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut listed = 0; let mut listed = 0;
while let Some(row) = rows while let Some(row) = rows
.next() .next()
.map_err(|e| StashError::ListDecode(e.to_string()))? .map_err(|e| StashError::ListDecode(e.to_string().into()))?
{ {
let id: u64 = row let id: u64 = row
.get(0) .get(0)
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let contents: Vec<u8> = row let contents: Vec<u8> = row
.get(1) .get(1)
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mime: Option<String> = row let mime: Option<String> = row
.get(2) .get(2)
.map_err(|e| StashError::ListDecode(e.to_string()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let preview = preview_entry(&contents, mime.as_deref(), preview_width); let preview = preview_entry(&contents, mime.as_deref(), preview_width);
if writeln!(out, "{id}\t{preview}").is_ok() { if writeln!(out, "{id}\t{preview}").is_ok() {
listed += 1; listed += 1;
@ -348,21 +399,22 @@ impl ClipboardDb for SqliteClipboardDb {
fn decode_entry( fn decode_entry(
&self, &self,
mut in_: impl Read, input: impl Read,
mut out: impl Write, mut out: impl Write,
input: Option<String>, id_hint: Option<String>,
) -> Result<(), StashError> { ) -> Result<(), StashError> {
let s = if let Some(input) = input { let input_str = if let Some(s) = id_hint {
input s
} else { } else {
let mut input = BufReader::new(input);
let mut buf = String::new(); let mut buf = String::new();
in_ input
.read_to_string(&mut buf) .read_to_string(&mut buf)
.map_err(|e| StashError::DecodeRead(e.to_string()))?; .map_err(|e| StashError::DecodeExtractId(e.to_string().into()))?;
buf buf
}; };
let id = let id = extract_id(&input_str)
extract_id(&s).map_err(|e| StashError::DecodeExtractId(e.to_string()))?; .map_err(|e| StashError::DecodeExtractId(e.into()))?;
let (contents, _mime): (Vec<u8>, Option<String>) = self let (contents, _mime): (Vec<u8>, Option<String>) = self
.conn .conn
.query_row( .query_row(
@ -370,11 +422,11 @@ impl ClipboardDb for SqliteClipboardDb {
params![id], params![id],
|row| Ok((row.get(0)?, row.get(1)?)), |row| Ok((row.get(0)?, row.get(1)?)),
) )
.map_err(|e| StashError::DecodeGet(e.to_string()))?; .map_err(|e| StashError::DecodeGet(e.to_string().into()))?;
out out
.write_all(&contents) .write_all(&contents)
.map_err(|e| StashError::DecodeWrite(e.to_string()))?; .map_err(|e| StashError::DecodeWrite(e.to_string().into()))?;
info!("Decoded entry with id {id}"); log::info!("Decoded entry with id {id}");
Ok(()) Ok(())
} }
@ -382,26 +434,26 @@ impl ClipboardDb for SqliteClipboardDb {
let mut stmt = self let mut stmt = self
.conn .conn
.prepare("SELECT id, contents FROM clipboard") .prepare("SELECT id, contents FROM clipboard")
.map_err(|e| StashError::QueryDelete(e.to_string()))?; .map_err(|e| StashError::QueryDelete(e.to_string().into()))?;
let mut rows = stmt let mut rows = stmt
.query([]) .query([])
.map_err(|e| StashError::QueryDelete(e.to_string()))?; .map_err(|e| StashError::QueryDelete(e.to_string().into()))?;
let mut deleted = 0; let mut deleted = 0;
while let Some(row) = rows while let Some(row) = rows
.next() .next()
.map_err(|e| StashError::QueryDelete(e.to_string()))? .map_err(|e| StashError::QueryDelete(e.to_string().into()))?
{ {
let id: u64 = row let id: u64 = row
.get(0) .get(0)
.map_err(|e| StashError::QueryDelete(e.to_string()))?; .map_err(|e| StashError::QueryDelete(e.to_string().into()))?;
let contents: Vec<u8> = row let contents: Vec<u8> = row
.get(1) .get(1)
.map_err(|e| StashError::QueryDelete(e.to_string()))?; .map_err(|e| StashError::QueryDelete(e.to_string().into()))?;
if contents.windows(query.len()).any(|w| w == query.as_bytes()) { if contents.windows(query.len()).any(|w| w == query.as_bytes()) {
self self
.conn .conn
.execute("DELETE FROM clipboard WHERE id = ?1", params![id]) .execute("DELETE FROM clipboard WHERE id = ?1", params![id])
.map_err(|e| StashError::QueryDelete(e.to_string()))?; .map_err(|e| StashError::QueryDelete(e.to_string().into()))?;
deleted += 1; deleted += 1;
} }
} }
@ -416,7 +468,7 @@ impl ClipboardDb for SqliteClipboardDb {
self self
.conn .conn
.execute("DELETE FROM clipboard WHERE id = ?1", params![id]) .execute("DELETE FROM clipboard WHERE id = ?1", params![id])
.map_err(|e| StashError::DeleteEntry(id, e.to_string()))?; .map_err(|e| StashError::DeleteEntry(id, e.to_string().into()))?;
deleted += 1; deleted += 1;
} }
} }
@ -435,30 +487,36 @@ impl ClipboardDb for SqliteClipboardDb {
} }
} }
// Helper functions
/// Try to load a sensitive regex from systemd credential or env. /// Try to load a sensitive regex from systemd credential or env.
/// ///
/// # Returns /// # Returns
///
/// `Some(Regex)` if present and valid, `None` otherwise. /// `Some(Regex)` if present and valid, `None` otherwise.
fn load_sensitive_regex() -> Option<Regex> { fn load_sensitive_regex() -> Option<Regex> {
if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") { static REGEX_CACHE: OnceLock<Option<Regex>> = OnceLock::new();
let file = format!("{regex_path}/clipboard_filter"); static CHECKED: std::sync::atomic::AtomicBool =
if let Ok(contents) = fs::read_to_string(&file) { std::sync::atomic::AtomicBool::new(false);
if let Ok(re) = Regex::new(contents.trim()) {
return Some(re); if !CHECKED.load(std::sync::atomic::Ordering::Relaxed) {
CHECKED.store(true, std::sync::atomic::Ordering::Relaxed);
let regex = if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") {
let file = format!("{regex_path}/clipboard_filter");
if let Ok(contents) = fs::read_to_string(&file) {
Regex::new(contents.trim()).ok()
} else {
None
} }
} } else if let Ok(pattern) = env::var("STASH_SENSITIVE_REGEX") {
Regex::new(&pattern).ok()
} else {
None
};
let _ = REGEX_CACHE.set(regex);
} }
// Fallback to an environment variable REGEX_CACHE.get().and_then(std::clone::Clone::clone)
if let Ok(pattern) = env::var("STASH_SENSITIVE_REGEX") {
if let Ok(re) = Regex::new(&pattern) {
return Some(re);
}
}
None
} }
pub fn extract_id(input: &str) -> Result<u64, &'static str> { pub fn extract_id(input: &str) -> Result<u64, &'static str> {
@ -466,35 +524,45 @@ pub fn extract_id(input: &str) -> Result<u64, &'static str> {
id_str.parse().map_err(|_| "invalid id") id_str.parse().map_err(|_| "invalid id")
} }
pub fn detect_mime_optimized(data: &[u8]) -> Option<String> {
// Check if it's valid UTF-8 first, which most clipboard content are.
// This will be used to return early without unnecessary mimetype detection
// overhead.
if std::str::from_utf8(data).is_ok() {
return Some("text/plain".to_string());
}
// Only run image detection on binary data
detect_mime(data)
}
pub fn detect_mime(data: &[u8]) -> Option<String> { pub fn detect_mime(data: &[u8]) -> Option<String> {
if let Ok(img_type) = imagesize::image_type(data) { if let Ok(img_type) = imagesize::image_type(data) {
Some( let mime_str = match img_type {
match img_type { ImageType::Png => "image/png",
ImageType::Png => "image/png", ImageType::Jpeg => "image/jpeg",
ImageType::Jpeg => "image/jpeg", ImageType::Gif => "image/gif",
ImageType::Gif => "image/gif", ImageType::Bmp => "image/bmp",
ImageType::Bmp => "image/bmp", ImageType::Tiff => "image/tiff",
ImageType::Tiff => "image/tiff", ImageType::Webp => "image/webp",
ImageType::Webp => "image/webp", ImageType::Aseprite => "image/x-aseprite",
ImageType::Aseprite => "image/x-aseprite", ImageType::Dds => "image/vnd.ms-dds",
ImageType::Dds => "image/vnd.ms-dds", ImageType::Exr => "image/aces",
ImageType::Exr => "image/aces", ImageType::Farbfeld => "image/farbfeld",
ImageType::Farbfeld => "image/farbfeld", ImageType::Hdr => "image/vnd.radiance",
ImageType::Hdr => "image/vnd.radiance", ImageType::Ico => "image/x-icon",
ImageType::Ico => "image/x-icon", ImageType::Ilbm => "image/ilbm",
ImageType::Ilbm => "image/ilbm", ImageType::Jxl => "image/jxl",
ImageType::Jxl => "image/jxl", ImageType::Ktx2 => "image/ktx2",
ImageType::Ktx2 => "image/ktx2", ImageType::Pnm => "image/x-portable-anymap",
ImageType::Pnm => "image/x-portable-anymap", ImageType::Psd => "image/vnd.adobe.photoshop",
ImageType::Psd => "image/vnd.adobe.photoshop", ImageType::Qoi => "image/qoi",
ImageType::Qoi => "image/qoi", ImageType::Tga => "image/x-tga",
ImageType::Tga => "image/x-tga", ImageType::Vtf => "image/x-vtf",
ImageType::Vtf => "image/x-vtf", ImageType::Heif(_) => "image/heif",
ImageType::Heif(_) => "image/heif", _ => "application/octet-stream",
_ => "application/octet-stream", };
} Some(mime_str.to_string())
.to_string(),
)
} else { } else {
None None
} }
@ -503,38 +571,54 @@ pub fn detect_mime(data: &[u8]) -> Option<String> {
pub fn preview_entry(data: &[u8], mime: Option<&str>, width: u32) -> String { pub fn preview_entry(data: &[u8], mime: Option<&str>, width: u32) -> String {
if let Some(mime) = mime { if let Some(mime) = mime {
if mime.starts_with("image/") { if mime.starts_with("image/") {
if let Ok(ImageSize { return format!("[[ binary data {} {} ]]", size_str(data.len()), mime);
width: img_width,
height: img_height,
}) = imagesize::blob_size(data)
{
return format!(
"[[ binary data {} {} {}x{} ]]",
size_str(data.len()),
mime,
img_width,
img_height
);
}
} else if mime == "application/json" || mime.starts_with("text/") { } else if mime == "application/json" || mime.starts_with("text/") {
let s = match str::from_utf8(data) { let Ok(s) = str::from_utf8(data) else {
Ok(s) => s, return format!("[[ invalid UTF-8 {} ]]", size_str(data.len()));
Err(e) => {
error!("Failed to decode UTF-8 clipboard data: {e}");
""
},
}; };
let s = s.trim().replace(|c: char| c.is_whitespace(), " ");
return truncate(&s, width as usize, ""); 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 data, use lossy conversion
let s = String::from_utf8_lossy(data); let s = String::from_utf8_lossy(data);
truncate(s.trim(), width as usize, "") truncate(s.trim(), width as usize, "")
} }
pub fn truncate(s: &str, max: usize, ellip: &str) -> String { pub fn truncate(s: &str, max: usize, ellip: &str) -> String {
if s.chars().count() > max { let char_count = s.chars().count();
s.chars().take(max).collect::<String>() + ellip if char_count > max {
let mut result = String::with_capacity(max * 4 + ellip.len()); // UTF-8 worst case
let mut char_iter = s.chars();
for _ in 0..max {
if let Some(c) = char_iter.next() {
result.push(c);
}
}
result.push_str(ellip);
result
} else { } else {
s.to_string() s.to_string()
} }
@ -630,7 +714,7 @@ fn get_recently_active_excluded_app(
let mut candidates = Vec::new(); let mut candidates = Vec::new();
if let Ok(entries) = fs::read_dir(proc_dir) { if let Ok(entries) = std::fs::read_dir(proc_dir) {
for entry in entries.flatten() { for entry in entries.flatten() {
if let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() { if let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() {
if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) { if let Ok(comm) = fs::read_to_string(format!("/proc/{pid}/comm")) {
@ -729,9 +813,7 @@ fn get_process_activity_score(pid: u32) -> u64 {
/// Check if an app name matches any in the exclusion list. /// Check if an app name matches any in the exclusion list.
/// Supports basic string matching and simple regex patterns. /// Supports basic string matching and simple regex patterns.
fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool { fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool {
debug!( debug!("Checking if '{app_name}' matches exclusion list: {excluded_apps:?}");
"Checking if '{app_name}' matches exclusion list: {excluded_apps:?}"
);
for excluded in excluded_apps { for excluded in excluded_apps {
// Basic string matching (case-insensitive) // Basic string matching (case-insensitive)
@ -753,9 +835,7 @@ fn app_matches_exclusion(app_name: &str, excluded_apps: &[String]) -> bool {
let pattern = excluded.replace('*', ".*"); let pattern = excluded.replace('*', ".*");
if let Ok(regex) = regex::Regex::new(&pattern) { if let Ok(regex) = regex::Regex::new(&pattern) {
if regex.is_match(app_name) { if regex.is_match(app_name) {
debug!( debug!("Matched wildcard pattern: {app_name} matches {excluded}");
"Matched wildcard pattern: {app_name} matches {excluded}"
);
return true; return true;
} }
} }

View file

@ -36,7 +36,7 @@ struct Cli {
/// Number of recent entries to check for duplicates when storing new /// Number of recent entries to check for duplicates when storing new
/// clipboard data. /// clipboard data.
#[arg(long, default_value_t = 100)] #[arg(long, default_value_t = 20)]
max_dedupe_search: u64, max_dedupe_search: u64,
/// Maximum width (in characters) for clipboard entry previews in list /// Maximum width (in characters) for clipboard entry previews in list

View file

@ -25,7 +25,7 @@ static TOPLEVEL_APPS: LazyLock<Mutex<HashMap<ObjectId, String>>> =
pub fn init_wayland_state() { pub fn init_wayland_state() {
std::thread::spawn(|| { std::thread::spawn(|| {
if let Err(e) = run_wayland_event_loop() { if let Err(e) = run_wayland_event_loop() {
debug!("Wayland event loop error: {}", e); debug!("Wayland event loop error: {e}");
} }
}); });
} }
@ -35,7 +35,7 @@ pub fn get_focused_window_app() -> Option<String> {
// Try Wayland protocol first // Try Wayland protocol first
if let Ok(focused) = FOCUSED_APP.lock() { if let Ok(focused) = FOCUSED_APP.lock() {
if let Some(ref app) = *focused { if let Some(ref app) = *focused {
debug!("Found focused app via Wayland protocol: {}", app); debug!("Found focused app via Wayland protocol: {app}");
return Some(app.clone()); return Some(app.clone());
} }
} }
@ -49,7 +49,7 @@ fn run_wayland_event_loop() -> Result<(), Box<dyn std::error::Error>> {
let conn = match WaylandConnection::connect_to_env() { let conn = match WaylandConnection::connect_to_env() {
Ok(conn) => conn, Ok(conn) => conn,
Err(e) => { Err(e) => {
debug!("Failed to connect to Wayland: {}", e); debug!("Failed to connect to Wayland: {e}");
return Ok(()); return Ok(());
}, },
}; };
@ -111,7 +111,7 @@ impl Dispatch<ZwlrForeignToplevelManagerV1, ()> for AppState {
{ {
// New toplevel created // New toplevel created
// We'll track it for focus events // We'll track it for focus events
let _handle: ZwlrForeignToplevelHandleV1 = toplevel; let _: ZwlrForeignToplevelHandleV1 = toplevel;
} }
} }
@ -136,7 +136,7 @@ impl Dispatch<ZwlrForeignToplevelHandleV1, ()> for AppState {
match event { match event {
zwlr_foreign_toplevel_handle_v1::Event::AppId { app_id } => { zwlr_foreign_toplevel_handle_v1::Event::AppId { app_id } => {
debug!("Toplevel app_id: {}", app_id); debug!("Toplevel app_id: {app_id}");
// Store the app_id for this handle // Store the app_id for this handle
if let Ok(mut apps) = TOPLEVEL_APPS.lock() { if let Ok(mut apps) = TOPLEVEL_APPS.lock() {
apps.insert(handle_id, app_id); apps.insert(handle_id, app_id);
@ -157,7 +157,7 @@ impl Dispatch<ZwlrForeignToplevelHandleV1, ()> for AppState {
(TOPLEVEL_APPS.lock(), FOCUSED_APP.lock()) (TOPLEVEL_APPS.lock(), FOCUSED_APP.lock())
{ {
if let Some(app_id) = apps.get(&handle_id) { if let Some(app_id) = apps.get(&handle_id) {
debug!("Setting focused app to: {}", app_id); debug!("Setting focused app to: {app_id}");
*focused = Some(app_id.clone()); *focused = Some(app_id.clone());
} }
} }