mirror of
https://github.com/NotAShelf/stash.git
synced 2026-04-13 06:23:47 +00:00
Merge pull request #71 from NotAShelf/notashelf/push-nnnqqrzkpywp
stash/db: general cleanup; async db ops for `watch` & deterministic hashing
This commit is contained in:
commit
be6cde092a
9 changed files with 1467 additions and 542 deletions
541
Cargo.lock
generated
541
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -15,6 +15,7 @@ path = "src/main.rs"
|
|||
|
||||
[dependencies]
|
||||
base64 = "0.22.1"
|
||||
blocking = "1.6.2"
|
||||
clap = { version = "4.5.60", features = [ "derive", "env" ] }
|
||||
clap-verbosity-flag = "3.0.4"
|
||||
color-eyre = "0.6.5"
|
||||
|
|
@ -43,6 +44,7 @@ wayland-protocols-wlr = { version = "0.3.10", default-features = false, optional
|
|||
wl-clipboard-rs = "0.9.3"
|
||||
|
||||
[dev-dependencies]
|
||||
futures = "0.3.32"
|
||||
tempfile = "3.26.0"
|
||||
|
||||
[features]
|
||||
|
|
|
|||
|
|
@ -57,6 +57,9 @@ struct TuiState {
|
|||
|
||||
/// Whether to show entries in reverse order (oldest first).
|
||||
reverse: bool,
|
||||
|
||||
/// ID of entry currently being copied.
|
||||
copying_entry: Option<i64>,
|
||||
}
|
||||
|
||||
impl TuiState {
|
||||
|
|
@ -91,6 +94,7 @@ impl TuiState {
|
|||
search_query: String::new(),
|
||||
search_mode: false,
|
||||
reverse,
|
||||
copying_entry: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -408,7 +412,7 @@ impl SqliteClipboardDb {
|
|||
},
|
||||
(KeyCode::Enter, _) => actions.copy = true,
|
||||
(KeyCode::Char('D'), KeyModifiers::SHIFT) => {
|
||||
actions.delete = true
|
||||
actions.delete = true;
|
||||
},
|
||||
(KeyCode::Char('/'), _) => actions.toggle_search = true,
|
||||
_ => {},
|
||||
|
|
@ -678,42 +682,51 @@ impl SqliteClipboardDb {
|
|||
if actions.copy
|
||||
&& let Some(&(id, ..)) = tui.selected_entry()
|
||||
{
|
||||
match self.copy_entry(id) {
|
||||
Ok((new_id, contents, mime)) => {
|
||||
if new_id != id {
|
||||
tui.dirty = true;
|
||||
}
|
||||
let opts = Options::new();
|
||||
let mime_type = match mime {
|
||||
Some(ref m) if m == "text/plain" => MimeType::Text,
|
||||
Some(ref m) => MimeType::Specific(m.clone().to_owned()),
|
||||
None => MimeType::Text,
|
||||
};
|
||||
let copy_result = opts
|
||||
.copy(Source::Bytes(contents.clone().into()), mime_type);
|
||||
match copy_result {
|
||||
Ok(()) => {
|
||||
let _ = Notification::new()
|
||||
.summary("Stash")
|
||||
.body("Copied entry to clipboard")
|
||||
.show();
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to copy entry to clipboard: {e}");
|
||||
let _ = Notification::new()
|
||||
.summary("Stash")
|
||||
.body(&format!("Failed to copy to clipboard: {e}"))
|
||||
.show();
|
||||
},
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch entry {id}: {e}");
|
||||
let _ = Notification::new()
|
||||
.summary("Stash")
|
||||
.body(&format!("Failed to fetch entry: {e}"))
|
||||
.show();
|
||||
},
|
||||
if tui.copying_entry == Some(id) {
|
||||
log::debug!(
|
||||
"Skipping duplicate copy for entry {id} (already in \
|
||||
progress)"
|
||||
);
|
||||
} else {
|
||||
tui.copying_entry = Some(id);
|
||||
match self.copy_entry(id) {
|
||||
Ok((new_id, contents, mime)) => {
|
||||
if new_id != id {
|
||||
tui.dirty = true;
|
||||
}
|
||||
let opts = Options::new();
|
||||
let mime_type = match mime {
|
||||
Some(ref m) if m == "text/plain" => MimeType::Text,
|
||||
Some(ref m) => MimeType::Specific(m.clone().clone()),
|
||||
None => MimeType::Text,
|
||||
};
|
||||
let copy_result = opts
|
||||
.copy(Source::Bytes(contents.clone().into()), mime_type);
|
||||
match copy_result {
|
||||
Ok(()) => {
|
||||
let _ = Notification::new()
|
||||
.summary("Stash")
|
||||
.body("Copied entry to clipboard")
|
||||
.show();
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to copy entry to clipboard: {e}");
|
||||
let _ = Notification::new()
|
||||
.summary("Stash")
|
||||
.body(&format!("Failed to copy to clipboard: {e}"))
|
||||
.show();
|
||||
},
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch entry {id}: {e}");
|
||||
let _ = Notification::new()
|
||||
.summary("Stash")
|
||||
.body(&format!("Failed to fetch entry: {e}"))
|
||||
.show();
|
||||
},
|
||||
}
|
||||
tui.copying_entry = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ impl StoreCommand for SqliteClipboardDb {
|
|||
Some(excluded_apps),
|
||||
min_size,
|
||||
max_size,
|
||||
None, // no pre-computed hash for CLI store
|
||||
)?;
|
||||
log::info!("Entry stored");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,32 @@
|
|||
use std::{
|
||||
collections::{BinaryHeap, hash_map::DefaultHasher},
|
||||
hash::{Hash, Hasher},
|
||||
io::Read,
|
||||
time::Duration,
|
||||
};
|
||||
use std::{collections::BinaryHeap, io::Read, time::Duration};
|
||||
|
||||
/// FNV-1a hasher for deterministic hashing across process runs.
|
||||
/// Unlike `DefaultHasher` (`SipHash`), this produces stable hashes.
|
||||
struct Fnv1aHasher {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl Fnv1aHasher {
|
||||
const FNV_OFFSET: u64 = 0xCBF29CE484222325;
|
||||
const FNV_PRIME: u64 = 0x100000001B3;
|
||||
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
state: Self::FNV_OFFSET,
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&mut self, bytes: &[u8]) {
|
||||
for byte in bytes {
|
||||
self.state ^= u64::from(*byte);
|
||||
self.state = self.state.wrapping_mul(Self::FNV_PRIME);
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&self) -> u64 {
|
||||
self.state
|
||||
}
|
||||
}
|
||||
|
||||
use smol::Timer;
|
||||
use wl_clipboard_rs::{
|
||||
|
|
@ -17,7 +40,7 @@ use wl_clipboard_rs::{
|
|||
},
|
||||
};
|
||||
|
||||
use crate::db::{ClipboardDb, SqliteClipboardDb};
|
||||
use crate::db::{SqliteClipboardDb, nonblocking::AsyncClipboardDb};
|
||||
|
||||
/// Wrapper to provide [`Ord`] implementation for `f64` by negating values.
|
||||
/// This allows [`BinaryHeap`], which is a max-heap, to function as a min-heap.
|
||||
|
|
@ -59,7 +82,7 @@ impl std::cmp::Ord for Neg {
|
|||
}
|
||||
|
||||
/// Min-heap for tracking entry expirations with sub-second precision.
|
||||
/// Uses Neg wrapper to turn BinaryHeap (max-heap) into min-heap behavior.
|
||||
/// Uses Neg wrapper to turn `BinaryHeap` (max-heap) into min-heap behavior.
|
||||
#[derive(Debug, Default)]
|
||||
struct ExpirationQueue {
|
||||
heap: BinaryHeap<(Neg, i64)>,
|
||||
|
|
@ -97,6 +120,16 @@ impl ExpirationQueue {
|
|||
}
|
||||
expired
|
||||
}
|
||||
|
||||
/// Check if the queue is empty
|
||||
fn is_empty(&self) -> bool {
|
||||
self.heap.is_empty()
|
||||
}
|
||||
|
||||
/// Get the number of entries in the queue
|
||||
fn len(&self) -> usize {
|
||||
self.heap.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get clipboard contents using the source application's preferred MIME type.
|
||||
|
|
@ -177,7 +210,7 @@ fn negotiate_mime_type(
|
|||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub trait WatchCommand {
|
||||
fn watch(
|
||||
async fn watch(
|
||||
&self,
|
||||
max_dedupe_search: u64,
|
||||
max_items: u64,
|
||||
|
|
@ -190,7 +223,7 @@ pub trait WatchCommand {
|
|||
}
|
||||
|
||||
impl WatchCommand for SqliteClipboardDb {
|
||||
fn watch(
|
||||
async fn watch(
|
||||
&self,
|
||||
max_dedupe_search: u64,
|
||||
max_items: u64,
|
||||
|
|
@ -200,211 +233,210 @@ impl WatchCommand for SqliteClipboardDb {
|
|||
min_size: Option<usize>,
|
||||
max_size: usize,
|
||||
) {
|
||||
smol::block_on(async {
|
||||
log::info!(
|
||||
"Starting clipboard watch daemon with MIME type preference: \
|
||||
{mime_type_preference}"
|
||||
);
|
||||
let async_db = AsyncClipboardDb::new(self.db_path.clone());
|
||||
log::info!(
|
||||
"Starting clipboard watch daemon with MIME type preference: \
|
||||
{mime_type_preference}"
|
||||
);
|
||||
|
||||
// Build expiration queue from existing entries
|
||||
let mut exp_queue = ExpirationQueue::new();
|
||||
if let Ok(Some((expires_at, id))) = self.get_next_expiration() {
|
||||
exp_queue.push(expires_at, id);
|
||||
// Load remaining expirations (exclude already-marked expired entries)
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT \
|
||||
NULL AND (is_expired IS NULL OR is_expired = 0) ORDER BY \
|
||||
expires_at ASC",
|
||||
)
|
||||
.ok();
|
||||
if let Some(ref mut stmt) = stmt {
|
||||
let mut rows = stmt.query([]).ok();
|
||||
if let Some(ref mut rows) = rows {
|
||||
while let Ok(Some(row)) = rows.next() {
|
||||
if let (Ok(exp), Ok(row_id)) =
|
||||
(row.get::<_, f64>(0), row.get::<_, i64>(1))
|
||||
{
|
||||
// Skip first entry which is already added
|
||||
if exp_queue
|
||||
.heap
|
||||
.iter()
|
||||
.any(|(_, existing_id)| *existing_id == row_id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
exp_queue.push(exp, row_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Build expiration queue from existing entries
|
||||
let mut exp_queue = ExpirationQueue::new();
|
||||
|
||||
// Load all expirations from database asynchronously
|
||||
match async_db.load_all_expirations().await {
|
||||
Ok(expirations) => {
|
||||
for (expires_at, id) in expirations {
|
||||
exp_queue.push(expires_at, id);
|
||||
}
|
||||
}
|
||||
|
||||
// We use hashes for comparison instead of storing full contents
|
||||
let mut last_hash: Option<u64> = None;
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
|
||||
// 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 using smart MIME negotiation
|
||||
if let Ok((mut reader, _)) = negotiate_mime_type(mime_type_preference) {
|
||||
buf.clear();
|
||||
if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
|
||||
last_hash = Some(hash_contents(&buf));
|
||||
if !exp_queue.is_empty() {
|
||||
log::info!("Loaded {} expirations from database", exp_queue.len());
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!("Failed to load expirations: {e}");
|
||||
},
|
||||
}
|
||||
|
||||
// We use hashes for comparison instead of storing full contents
|
||||
let mut last_hash: Option<u64> = None;
|
||||
let mut buf = Vec::with_capacity(4096);
|
||||
|
||||
// Helper to hash clipboard contents using FNV-1a (deterministic across
|
||||
// runs)
|
||||
let hash_contents = |data: &[u8]| -> u64 {
|
||||
let mut hasher = Fnv1aHasher::new();
|
||||
hasher.write(data);
|
||||
hasher.finish()
|
||||
};
|
||||
|
||||
// Initialize with current clipboard using smart MIME negotiation
|
||||
if let Ok((mut reader, _)) = negotiate_mime_type(mime_type_preference) {
|
||||
buf.clear();
|
||||
if reader.read_to_end(&mut buf).is_ok() && !buf.is_empty() {
|
||||
last_hash = Some(hash_contents(&buf));
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
// Process any pending expirations
|
||||
if let Some(next_exp) = exp_queue.peek_next() {
|
||||
let now = SqliteClipboardDb::now();
|
||||
if next_exp <= now {
|
||||
// Expired entries to process
|
||||
let expired_ids = exp_queue.pop_expired(now);
|
||||
for id in expired_ids {
|
||||
// Verify entry still exists and get its content_hash
|
||||
let expired_hash: Option<i64> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT content_hash FROM clipboard WHERE id = ?1",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.ok();
|
||||
let poll_interval = Duration::from_millis(500);
|
||||
|
||||
if let Some(stored_hash) = expired_hash {
|
||||
// Mark as expired
|
||||
self
|
||||
.conn
|
||||
.execute(
|
||||
"UPDATE clipboard SET is_expired = 1 WHERE id = ?1",
|
||||
[id],
|
||||
)
|
||||
.ok();
|
||||
loop {
|
||||
// Process any pending expirations that are due now
|
||||
if let Some(next_exp) = exp_queue.peek_next() {
|
||||
let now = SqliteClipboardDb::now();
|
||||
if next_exp <= now {
|
||||
// Expired entries to process
|
||||
let expired_ids = exp_queue.pop_expired(now);
|
||||
for id in expired_ids {
|
||||
// Verify entry still exists and get its content_hash
|
||||
let expired_hash: Option<i64> =
|
||||
match async_db.get_content_hash(id).await {
|
||||
Ok(hash) => hash,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to get content hash for entry {id}: {e}");
|
||||
None
|
||||
},
|
||||
};
|
||||
|
||||
if let Some(stored_hash) = expired_hash {
|
||||
// Mark as expired
|
||||
if let Err(e) = async_db.mark_expired(id).await {
|
||||
log::warn!("Failed to mark entry {id} as expired: {e}");
|
||||
} else {
|
||||
log::info!("Entry {id} marked as expired");
|
||||
}
|
||||
|
||||
// Check if this expired entry is currently in the clipboard
|
||||
if let Ok((mut reader, _)) =
|
||||
negotiate_mime_type(mime_type_preference)
|
||||
// Check if this expired entry is currently in the clipboard
|
||||
if let Ok((mut reader, _)) =
|
||||
negotiate_mime_type(mime_type_preference)
|
||||
{
|
||||
let mut current_buf = Vec::new();
|
||||
if reader.read_to_end(&mut current_buf).is_ok()
|
||||
&& !current_buf.is_empty()
|
||||
{
|
||||
let mut current_buf = Vec::new();
|
||||
if reader.read_to_end(&mut current_buf).is_ok()
|
||||
&& !current_buf.is_empty()
|
||||
{
|
||||
let current_hash = hash_contents(¤t_buf);
|
||||
// Compare as i64 (database stores as i64)
|
||||
if current_hash as i64 == stored_hash {
|
||||
// Clear the clipboard since expired content is still
|
||||
// there
|
||||
let mut opts = Options::new();
|
||||
opts.clipboard(
|
||||
wl_clipboard_rs::copy::ClipboardType::Regular,
|
||||
let current_hash = hash_contents(¤t_buf);
|
||||
// Convert stored i64 to u64 for comparison (preserves bit
|
||||
// pattern)
|
||||
if current_hash == stored_hash as u64 {
|
||||
// Clear the clipboard since expired content is still
|
||||
// there
|
||||
let mut opts = Options::new();
|
||||
opts
|
||||
.clipboard(wl_clipboard_rs::copy::ClipboardType::Regular);
|
||||
if opts
|
||||
.copy(
|
||||
Source::Bytes(Vec::new().into()),
|
||||
CopyMimeType::Autodetect,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
log::info!(
|
||||
"Cleared clipboard containing expired entry {id}"
|
||||
);
|
||||
last_hash = None; // reset tracked hash
|
||||
} else {
|
||||
log::warn!(
|
||||
"Failed to clear clipboard for expired entry {id}"
|
||||
);
|
||||
if opts
|
||||
.copy(
|
||||
Source::Bytes(Vec::new().into()),
|
||||
CopyMimeType::Autodetect,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
log::info!(
|
||||
"Cleared clipboard containing expired entry {id}"
|
||||
);
|
||||
last_hash = None; // reset tracked hash
|
||||
} else {
|
||||
log::warn!(
|
||||
"Failed to clear clipboard for expired entry {id}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Sleep *precisely* until next expiration
|
||||
let sleep_duration = next_exp - now;
|
||||
Timer::after(Duration::from_secs_f64(sleep_duration)).await;
|
||||
continue; // skip normal poll, process expirations first
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normal clipboard polling
|
||||
match negotiate_mime_type(mime_type_preference) {
|
||||
Ok((mut reader, _mime_type)) => {
|
||||
buf.clear();
|
||||
if let Err(e) = reader.read_to_end(&mut buf) {
|
||||
log::error!("Failed to read clipboard contents: {e}");
|
||||
Timer::after(Duration::from_millis(500)).await;
|
||||
continue;
|
||||
}
|
||||
// Normal clipboard polling (always run, even when expirations are
|
||||
// pending)
|
||||
match negotiate_mime_type(mime_type_preference) {
|
||||
Ok((mut reader, _mime_type)) => {
|
||||
buf.clear();
|
||||
if let Err(e) = reader.read_to_end(&mut buf) {
|
||||
log::error!("Failed to read clipboard contents: {e}");
|
||||
Timer::after(Duration::from_millis(500)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only store if changed and not empty
|
||||
if !buf.is_empty() {
|
||||
let current_hash = hash_contents(&buf);
|
||||
if last_hash != Some(current_hash) {
|
||||
match self.store_entry(
|
||||
&buf[..],
|
||||
// Only store if changed and not empty
|
||||
if !buf.is_empty() {
|
||||
let current_hash = hash_contents(&buf);
|
||||
if last_hash != Some(current_hash) {
|
||||
// Clone buf for the async operation since it needs 'static
|
||||
let buf_clone = buf.clone();
|
||||
#[allow(clippy::cast_possible_wrap)]
|
||||
let content_hash = Some(current_hash as i64);
|
||||
match async_db
|
||||
.store_entry(
|
||||
buf_clone,
|
||||
max_dedupe_search,
|
||||
max_items,
|
||||
Some(excluded_apps),
|
||||
Some(excluded_apps.to_vec()),
|
||||
min_size,
|
||||
max_size,
|
||||
) {
|
||||
Ok(id) => {
|
||||
log::info!("Stored new clipboard entry (id: {id})");
|
||||
last_hash = Some(current_hash);
|
||||
content_hash,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(id) => {
|
||||
log::info!("Stored new clipboard entry (id: {id})");
|
||||
last_hash = Some(current_hash);
|
||||
|
||||
// Set expiration if configured
|
||||
if let Some(duration) = expire_after {
|
||||
let expires_at =
|
||||
SqliteClipboardDb::now() + duration.as_secs_f64();
|
||||
self.set_expiration(id, expires_at).ok();
|
||||
// Set expiration if configured
|
||||
if let Some(duration) = expire_after {
|
||||
let expires_at =
|
||||
SqliteClipboardDb::now() + duration.as_secs_f64();
|
||||
if let Err(e) =
|
||||
async_db.set_expiration(id, expires_at).await
|
||||
{
|
||||
log::warn!(
|
||||
"Failed to set expiration for entry {id}: {e}"
|
||||
);
|
||||
} else {
|
||||
exp_queue.push(expires_at, id);
|
||||
}
|
||||
},
|
||||
Err(crate::db::StashError::ExcludedByApp(_)) => {
|
||||
log::info!("Clipboard entry excluded by app filter");
|
||||
last_hash = Some(current_hash);
|
||||
},
|
||||
Err(crate::db::StashError::Store(ref msg))
|
||||
if msg.contains("Excluded by app filter") =>
|
||||
{
|
||||
log::info!("Clipboard entry excluded by app filter");
|
||||
last_hash = Some(current_hash);
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to store clipboard entry: {e}");
|
||||
last_hash = Some(current_hash);
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(crate::db::StashError::ExcludedByApp(_)) => {
|
||||
log::info!("Clipboard entry excluded by app filter");
|
||||
last_hash = Some(current_hash);
|
||||
},
|
||||
Err(crate::db::StashError::Store(ref msg))
|
||||
if msg.contains("Excluded by app filter") =>
|
||||
{
|
||||
log::info!("Clipboard entry excluded by app filter");
|
||||
last_hash = Some(current_hash);
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("Failed to store clipboard entry: {e}");
|
||||
last_hash = Some(current_hash);
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let error_msg = e.to_string();
|
||||
if !error_msg.contains("empty") {
|
||||
log::error!("Failed to get clipboard contents: {e}");
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Normal poll interval (only if no expirations pending)
|
||||
if exp_queue.peek_next().is_none() {
|
||||
Timer::after(Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
let error_msg = e.to_string();
|
||||
if !error_msg.contains("empty") {
|
||||
log::error!("Failed to get clipboard contents: {e}");
|
||||
}
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate sleep time: min of poll interval and time until next
|
||||
// expiration
|
||||
let sleep_duration = if let Some(next_exp) = exp_queue.peek_next() {
|
||||
let now = SqliteClipboardDb::now();
|
||||
let time_to_exp = (next_exp - now).max(0.0);
|
||||
poll_interval.min(Duration::from_secs_f64(time_to_exp))
|
||||
} else {
|
||||
poll_interval
|
||||
};
|
||||
Timer::after(sleep_duration).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Unit-testable helper: given ordered offers and a preference, return the
|
||||
/// Given ordered offers and a preference, return the
|
||||
/// chosen MIME type. This mirrors the selection logic in
|
||||
/// [`negotiate_mime_type`] without requiring a Wayland connection.
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
629
src/db/mod.rs
629
src/db/mod.rs
|
|
@ -1,14 +1,100 @@
|
|||
use std::{
|
||||
collections::hash_map::DefaultHasher,
|
||||
env,
|
||||
fmt,
|
||||
fs,
|
||||
hash::{Hash, Hasher},
|
||||
io::{BufRead, BufReader, Read, Write},
|
||||
path::PathBuf,
|
||||
str,
|
||||
sync::OnceLock,
|
||||
sync::{Mutex, OnceLock},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub mod nonblocking;
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
static CACHE: OnceLock<Mutex<ProcessCache>> = 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// FNV-1a hasher for deterministic hashing across process runs.
|
||||
/// Unlike `DefaultHasher` (`SipHash` with random seed), this produces stable
|
||||
/// hashes.
|
||||
pub struct Fnv1aHasher {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl Fnv1aHasher {
|
||||
const FNV_OFFSET: u64 = 0xCBF29CE484222325;
|
||||
const FNV_PRIME: u64 = 0x100000001B3;
|
||||
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
state: Self::FNV_OFFSET,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(&mut self, bytes: &[u8]) {
|
||||
for byte in bytes {
|
||||
self.state ^= u64::from(*byte);
|
||||
self.state = self.state.wrapping_mul(Self::FNV_PRIME);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(&self) -> u64 {
|
||||
self.state
|
||||
}
|
||||
}
|
||||
|
||||
use base64::prelude::*;
|
||||
use log::{debug, error, info, warn};
|
||||
use mime_sniffer::MimeTypeSniffer;
|
||||
|
|
@ -19,6 +105,97 @@ 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<String>,
|
||||
limit: Option<usize>,
|
||||
offset: Option<usize>,
|
||||
}
|
||||
|
||||
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.")]
|
||||
|
|
@ -66,6 +243,18 @@ pub enum StashError {
|
|||
}
|
||||
|
||||
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)
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn store_entry(
|
||||
&self,
|
||||
input: impl Read,
|
||||
|
|
@ -74,6 +263,7 @@ pub trait ClipboardDb {
|
|||
excluded_apps: Option<&[String]>,
|
||||
min_size: Option<usize>,
|
||||
max_size: usize,
|
||||
content_hash: Option<i64>,
|
||||
) -> Result<i64, StashError>;
|
||||
|
||||
fn deduplicate_by_hash(
|
||||
|
|
@ -119,11 +309,15 @@ impl fmt::Display for Entry {
|
|||
}
|
||||
|
||||
pub struct SqliteClipboardDb {
|
||||
pub conn: Connection,
|
||||
pub conn: Connection,
|
||||
pub db_path: PathBuf,
|
||||
}
|
||||
|
||||
impl SqliteClipboardDb {
|
||||
pub fn new(mut conn: Connection) -> Result<Self, StashError> {
|
||||
pub fn new(
|
||||
mut conn: Connection,
|
||||
db_path: PathBuf,
|
||||
) -> Result<Self, StashError> {
|
||||
conn
|
||||
.pragma_update(None, "synchronous", "OFF")
|
||||
.map_err(|e| {
|
||||
|
|
@ -183,8 +377,8 @@ impl SqliteClipboardDb {
|
|||
})?;
|
||||
}
|
||||
|
||||
// Add content_hash column if it doesn't exist
|
||||
// Migration MUST be done to avoid breaking existing installations.
|
||||
// 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(
|
||||
|
|
@ -358,7 +552,7 @@ impl SqliteClipboardDb {
|
|||
// focused window state.
|
||||
#[cfg(feature = "use-toplevel")]
|
||||
crate::wayland::init_wayland_state();
|
||||
Ok(Self { conn })
|
||||
Ok(Self { conn, db_path })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -368,19 +562,8 @@ impl SqliteClipboardDb {
|
|||
include_expired: bool,
|
||||
reverse: bool,
|
||||
) -> Result<String, StashError> {
|
||||
let order = if reverse { "ASC" } else { "DESC" };
|
||||
let query = if include_expired {
|
||||
format!(
|
||||
"SELECT id, contents, mime FROM clipboard ORDER BY \
|
||||
COALESCE(last_accessed, 0) {order}, id {order}"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \
|
||||
OR is_expired = 0) ORDER BY COALESCE(last_accessed, 0) {order}, id \
|
||||
{order}"
|
||||
)
|
||||
};
|
||||
let builder = ListQueryBuilder::new(include_expired, reverse);
|
||||
let query = builder.select_star_query();
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(&query)
|
||||
|
|
@ -432,6 +615,7 @@ impl ClipboardDb for SqliteClipboardDb {
|
|||
excluded_apps: Option<&[String]>,
|
||||
min_size: Option<usize>,
|
||||
max_size: usize,
|
||||
content_hash: Option<i64>,
|
||||
) -> Result<i64, StashError> {
|
||||
let mut buf = Vec::new();
|
||||
if input.read_to_end(&mut buf).is_err() || buf.is_empty() {
|
||||
|
|
@ -454,11 +638,14 @@ impl ClipboardDb for SqliteClipboardDb {
|
|||
return Err(StashError::AllWhitespace);
|
||||
}
|
||||
|
||||
// Calculate content hash for deduplication
|
||||
let mut hasher = DefaultHasher::new();
|
||||
buf.hash(&mut hasher);
|
||||
#[allow(clippy::cast_possible_wrap)]
|
||||
let content_hash = hasher.finish() as i64;
|
||||
// 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);
|
||||
|
||||
|
|
@ -607,19 +794,8 @@ impl ClipboardDb for SqliteClipboardDb {
|
|||
include_expired: bool,
|
||||
reverse: bool,
|
||||
) -> Result<usize, StashError> {
|
||||
let order = if reverse { "ASC" } else { "DESC" };
|
||||
let query = if include_expired {
|
||||
format!(
|
||||
"SELECT id, contents, mime FROM clipboard ORDER BY \
|
||||
COALESCE(last_accessed, 0) {order}, id {order}"
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \
|
||||
OR is_expired = 0) ORDER BY COALESCE(last_accessed, 0) {order}, id \
|
||||
{order}"
|
||||
)
|
||||
};
|
||||
let builder = ListQueryBuilder::new(include_expired, reverse);
|
||||
let query = builder.select_star_query();
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(&query)
|
||||
|
|
@ -780,43 +956,14 @@ impl SqliteClipboardDb {
|
|||
include_expired: bool,
|
||||
search: Option<&str>,
|
||||
) -> Result<usize, StashError> {
|
||||
let search_pattern = search.map(|s| {
|
||||
// Avoid backslash escaping issues
|
||||
let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_");
|
||||
format!("%{escaped}%")
|
||||
});
|
||||
let builder =
|
||||
ListQueryBuilder::new(include_expired, false).with_search(search);
|
||||
let query = builder.count_query();
|
||||
|
||||
let count: i64 = match (include_expired, search_pattern.as_deref()) {
|
||||
(true, None) => {
|
||||
self
|
||||
.conn
|
||||
.query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0))
|
||||
},
|
||||
(true, Some(pattern)) => {
|
||||
self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM clipboard WHERE (LOWER(CAST(contents AS \
|
||||
TEXT)) LIKE LOWER(?1) ESCAPE '!')",
|
||||
[pattern],
|
||||
|r| r.get(0),
|
||||
)
|
||||
},
|
||||
(false, None) => {
|
||||
self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \
|
||||
is_expired = 0)",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
},
|
||||
(false, Some(pattern)) => {
|
||||
self.conn.query_row(
|
||||
"SELECT COUNT(*) FROM clipboard WHERE (is_expired IS NULL OR \
|
||||
is_expired = 0) AND (LOWER(CAST(contents AS TEXT)) LIKE LOWER(?1) \
|
||||
ESCAPE '!')",
|
||||
[pattern],
|
||||
|r| r.get(0),
|
||||
)
|
||||
},
|
||||
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)
|
||||
|
|
@ -838,55 +985,23 @@ impl SqliteClipboardDb {
|
|||
search: Option<&str>,
|
||||
reverse: bool,
|
||||
) -> Result<Vec<(i64, String, String)>, StashError> {
|
||||
let search_pattern = search.map(|s| {
|
||||
let escaped = s.replace('!', "!!").replace('%', "!%").replace('_', "!_");
|
||||
format!("%{escaped}%")
|
||||
});
|
||||
|
||||
let order = if reverse { "ASC" } else { "DESC" };
|
||||
let query = match (include_expired, search_pattern.as_deref()) {
|
||||
(true, None) => {
|
||||
format!(
|
||||
"SELECT id, contents, mime FROM clipboard ORDER BY \
|
||||
COALESCE(last_accessed, 0) {order}, id {order} LIMIT ?1 OFFSET ?2"
|
||||
)
|
||||
},
|
||||
(true, Some(_)) => {
|
||||
format!(
|
||||
"SELECT id, contents, mime FROM clipboard WHERE \
|
||||
(LOWER(CAST(contents AS TEXT)) LIKE LOWER(?3) ESCAPE '!') ORDER BY \
|
||||
COALESCE(last_accessed, 0) {order}, id {order} LIMIT ?1 OFFSET ?2"
|
||||
)
|
||||
},
|
||||
(false, None) => {
|
||||
format!(
|
||||
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \
|
||||
OR is_expired = 0) ORDER BY COALESCE(last_accessed, 0) {order}, id \
|
||||
{order} LIMIT ?1 OFFSET ?2"
|
||||
)
|
||||
},
|
||||
(false, Some(_)) => {
|
||||
format!(
|
||||
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL \
|
||||
OR is_expired = 0) AND (LOWER(CAST(contents AS TEXT)) LIKE \
|
||||
LOWER(?3) ESCAPE '!') ORDER BY COALESCE(last_accessed, 0) {order}, \
|
||||
id {order} LIMIT ?1 OFFSET ?2"
|
||||
)
|
||||
},
|
||||
};
|
||||
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) = search_pattern.as_deref() {
|
||||
let mut rows = if let Some(pattern) = builder.search_param() {
|
||||
stmt
|
||||
.query(rusqlite::params![limit as i64, offset as i64, pattern])
|
||||
.query(rusqlite::params![pattern])
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
|
||||
} else {
|
||||
stmt
|
||||
.query(rusqlite::params![limit as i64, offset as i64])
|
||||
.query([])
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
|
||||
};
|
||||
|
||||
|
|
@ -932,20 +1047,6 @@ impl SqliteClipboardDb {
|
|||
.map_err(|e| StashError::Trim(e.to_string().into()))
|
||||
}
|
||||
|
||||
/// Get the earliest expiration (timestamp, id) for heap initialization
|
||||
pub fn get_next_expiration(&self) -> Result<Option<(f64, i64)>, StashError> {
|
||||
match self.conn.query_row(
|
||||
"SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT NULL \
|
||||
ORDER BY expires_at ASC LIMIT 1",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
) {
|
||||
Ok(result) => Ok(Some(result)),
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||
Err(e) => Err(StashError::Store(e.to_string().into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set expiration timestamp for an entry
|
||||
pub fn set_expiration(
|
||||
&self,
|
||||
|
|
@ -1028,31 +1129,41 @@ impl SqliteClipboardDb {
|
|||
/// # 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<Regex> {
|
||||
static REGEX_CACHE: OnceLock<Option<Regex>> = OnceLock::new();
|
||||
static CHECKED: std::sync::atomic::AtomicBool =
|
||||
std::sync::atomic::AtomicBool::new(false);
|
||||
// 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()
|
||||
}?;
|
||||
|
||||
if !CHECKED.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
CHECKED.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
// Cache compiled regexes by pattern to avoid recompilation
|
||||
static REGEX_CACHE: OnceLock<
|
||||
Mutex<std::collections::HashMap<String, Regex>>,
|
||||
> = OnceLock::new();
|
||||
let cache =
|
||||
REGEX_CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
|
||||
|
||||
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);
|
||||
// Check cache first
|
||||
if let Ok(cache) = cache.lock()
|
||||
&& let Some(regex) = cache.get(&pattern)
|
||||
{
|
||||
return Some(regex.clone());
|
||||
}
|
||||
|
||||
REGEX_CACHE.get().and_then(std::clone::Clone::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<i64, &'static str> {
|
||||
|
|
@ -1153,7 +1264,8 @@ fn detect_excluded_app_activity(excluded_apps: &[String]) -> bool {
|
|||
}
|
||||
|
||||
// Strategy 2: Check recently active processes (timing correlation)
|
||||
if let Some(active_app) = get_recently_active_excluded_app(excluded_apps) {
|
||||
// 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;
|
||||
}
|
||||
|
|
@ -1184,7 +1296,8 @@ fn get_focused_window_app() -> Option<String> {
|
|||
}
|
||||
|
||||
/// Check for recently active excluded apps using CPU and I/O activity.
|
||||
fn get_recently_active_excluded_app(
|
||||
/// This is the uncached version - use `ProcessCache::get()` for cached access.
|
||||
fn get_recently_active_excluded_app_uncached(
|
||||
excluded_apps: &[String],
|
||||
) -> Option<String> {
|
||||
let proc_dir = std::path::Path::new("/proc");
|
||||
|
|
@ -1330,7 +1443,8 @@ mod tests {
|
|||
fn test_db() -> SqliteClipboardDb {
|
||||
let conn =
|
||||
Connection::open_in_memory().expect("Failed to open in-memory db");
|
||||
SqliteClipboardDb::new(conn).expect("Failed to create test database")
|
||||
SqliteClipboardDb::new(conn, PathBuf::from(":memory:"))
|
||||
.expect("Failed to create test database")
|
||||
}
|
||||
|
||||
fn get_schema_version(conn: &Connection) -> rusqlite::Result<i64> {
|
||||
|
|
@ -1361,7 +1475,8 @@ mod tests {
|
|||
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).expect("Failed to create 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"),
|
||||
|
|
@ -1411,7 +1526,8 @@ mod tests {
|
|||
|
||||
assert_eq!(get_schema_version(&conn).expect("Failed to get version"), 0);
|
||||
|
||||
let db = SqliteClipboardDb::new(conn).expect("Failed to create database");
|
||||
let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:"))
|
||||
.expect("Failed to create database");
|
||||
|
||||
assert_eq!(
|
||||
get_schema_version(&db.conn)
|
||||
|
|
@ -1453,7 +1569,8 @@ mod tests {
|
|||
)
|
||||
.expect("Failed to insert data");
|
||||
|
||||
let db = SqliteClipboardDb::new(conn).expect("Failed to create database");
|
||||
let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:"))
|
||||
.expect("Failed to create database");
|
||||
|
||||
assert_eq!(
|
||||
get_schema_version(&db.conn)
|
||||
|
|
@ -1496,7 +1613,8 @@ mod tests {
|
|||
)
|
||||
.expect("Failed to insert data");
|
||||
|
||||
let db = SqliteClipboardDb::new(conn).expect("Failed to create database");
|
||||
let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:"))
|
||||
.expect("Failed to create database");
|
||||
|
||||
assert_eq!(
|
||||
get_schema_version(&db.conn)
|
||||
|
|
@ -1527,12 +1645,13 @@ mod tests {
|
|||
)
|
||||
.expect("Failed to create table");
|
||||
|
||||
let db = SqliteClipboardDb::new(conn).expect("Failed to create database");
|
||||
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).expect("Failed to create database again");
|
||||
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");
|
||||
|
||||
|
|
@ -1545,13 +1664,14 @@ mod tests {
|
|||
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).expect("Failed to create 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)
|
||||
.store_entry(cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None)
|
||||
.expect("Failed to store entry");
|
||||
|
||||
let content_hash: Option<i64> = db
|
||||
|
|
@ -1581,12 +1701,13 @@ mod tests {
|
|||
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
|
||||
let db_path = temp_dir.path().join("test_copy.db");
|
||||
let conn = Connection::open(&db_path).expect("Failed to open database");
|
||||
let db = SqliteClipboardDb::new(conn).expect("Failed to create database");
|
||||
let db = SqliteClipboardDb::new(conn, PathBuf::from(":memory:"))
|
||||
.expect("Failed to create database");
|
||||
|
||||
let test_data = b"Test content for copy";
|
||||
let cursor = std::io::Cursor::new(test_data.to_vec());
|
||||
let id_a = db
|
||||
.store_entry(cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE)
|
||||
.store_entry(cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE, None)
|
||||
.expect("Failed to store entry A");
|
||||
|
||||
let original_last_accessed: i64 = db
|
||||
|
|
@ -1600,8 +1721,8 @@ mod tests {
|
|||
|
||||
std::thread::sleep(std::time::Duration::from_millis(1100));
|
||||
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
test_data.hash(&mut hasher);
|
||||
let mut hasher = Fnv1aHasher::new();
|
||||
hasher.write(test_data);
|
||||
let content_hash = hasher.finish() as i64;
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
|
|
@ -1662,7 +1783,8 @@ mod tests {
|
|||
)
|
||||
.expect("Failed to insert data");
|
||||
|
||||
let db = SqliteClipboardDb::new(conn).expect("Failed to create 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 version"),
|
||||
|
|
@ -1688,6 +1810,7 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
DEFAULT_MAX_ENTRY_SIZE,
|
||||
None,
|
||||
)
|
||||
.expect("Failed to store URI list");
|
||||
|
||||
|
|
@ -1721,6 +1844,7 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
DEFAULT_MAX_ENTRY_SIZE,
|
||||
None,
|
||||
)
|
||||
.expect("Failed to store image");
|
||||
|
||||
|
|
@ -1749,6 +1873,7 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
DEFAULT_MAX_ENTRY_SIZE,
|
||||
None,
|
||||
)
|
||||
.expect("Failed to store first");
|
||||
let _id2 = db
|
||||
|
|
@ -1759,6 +1884,7 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
DEFAULT_MAX_ENTRY_SIZE,
|
||||
None,
|
||||
)
|
||||
.expect("Failed to store second");
|
||||
|
||||
|
|
@ -1794,6 +1920,7 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
DEFAULT_MAX_ENTRY_SIZE,
|
||||
None,
|
||||
)
|
||||
.expect("Failed to store");
|
||||
}
|
||||
|
|
@ -1815,6 +1942,7 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
DEFAULT_MAX_ENTRY_SIZE,
|
||||
None,
|
||||
);
|
||||
assert!(matches!(result, Err(StashError::EmptyOrTooLarge)));
|
||||
}
|
||||
|
|
@ -1829,6 +1957,7 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
DEFAULT_MAX_ENTRY_SIZE,
|
||||
None,
|
||||
);
|
||||
assert!(matches!(result, Err(StashError::AllWhitespace)));
|
||||
}
|
||||
|
|
@ -1845,6 +1974,7 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
DEFAULT_MAX_ENTRY_SIZE,
|
||||
None,
|
||||
);
|
||||
assert!(matches!(result, Err(StashError::TooLarge(5000000))));
|
||||
}
|
||||
|
|
@ -1860,6 +1990,7 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
DEFAULT_MAX_ENTRY_SIZE,
|
||||
None,
|
||||
)
|
||||
.expect("Failed to store");
|
||||
|
||||
|
|
@ -1886,6 +2017,7 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
DEFAULT_MAX_ENTRY_SIZE,
|
||||
None,
|
||||
)
|
||||
.expect("Failed to store");
|
||||
db.store_entry(
|
||||
|
|
@ -1895,6 +2027,7 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
DEFAULT_MAX_ENTRY_SIZE,
|
||||
None,
|
||||
)
|
||||
.expect("Failed to store");
|
||||
|
||||
|
|
@ -1922,6 +2055,7 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
DEFAULT_MAX_ENTRY_SIZE,
|
||||
None,
|
||||
)
|
||||
.expect("Failed to store");
|
||||
}
|
||||
|
|
@ -2001,6 +2135,7 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
DEFAULT_MAX_ENTRY_SIZE,
|
||||
None,
|
||||
)
|
||||
.expect("Failed to store");
|
||||
|
||||
|
|
@ -2010,4 +2145,168 @@ mod tests {
|
|||
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,
|
||||
)
|
||||
.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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
326
src/db/nonblocking.rs
Normal file
326
src/db/nonblocking.rs
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use rusqlite::OptionalExtension;
|
||||
|
||||
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
|
||||
|
||||
/// Async wrapper for database operations that runs blocking operations
|
||||
/// on a thread pool to avoid blocking the async runtime. Since
|
||||
/// [`rusqlite::Connection`] is not Send, we store the database path and open a
|
||||
/// new connection for each operation.
|
||||
pub struct AsyncClipboardDb {
|
||||
db_path: PathBuf,
|
||||
}
|
||||
|
||||
impl AsyncClipboardDb {
|
||||
pub fn new(db_path: PathBuf) -> Self {
|
||||
Self { db_path }
|
||||
}
|
||||
|
||||
pub async fn store_entry(
|
||||
&self,
|
||||
data: Vec<u8>,
|
||||
max_dedupe_search: u64,
|
||||
max_items: u64,
|
||||
excluded_apps: Option<Vec<String>>,
|
||||
min_size: Option<usize>,
|
||||
max_size: usize,
|
||||
content_hash: Option<i64>,
|
||||
) -> Result<i64, StashError> {
|
||||
let path = self.db_path.clone();
|
||||
blocking::unblock(move || {
|
||||
let db = Self::open_db_internal(&path)?;
|
||||
db.store_entry(
|
||||
std::io::Cursor::new(data),
|
||||
max_dedupe_search,
|
||||
max_items,
|
||||
excluded_apps.as_deref(),
|
||||
min_size,
|
||||
max_size,
|
||||
content_hash,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_expiration(
|
||||
&self,
|
||||
id: i64,
|
||||
expires_at: f64,
|
||||
) -> Result<(), StashError> {
|
||||
let path = self.db_path.clone();
|
||||
blocking::unblock(move || {
|
||||
let db = Self::open_db_internal(&path)?;
|
||||
db.set_expiration(id, expires_at)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn load_all_expirations(
|
||||
&self,
|
||||
) -> Result<Vec<(f64, i64)>, StashError> {
|
||||
let path = self.db_path.clone();
|
||||
blocking::unblock(move || {
|
||||
let db = Self::open_db_internal(&path)?;
|
||||
let mut stmt = db
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT expires_at, id FROM clipboard WHERE expires_at IS NOT NULL \
|
||||
AND (is_expired IS NULL OR is_expired = 0) ORDER BY expires_at ASC",
|
||||
)
|
||||
.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 expirations = Vec::new();
|
||||
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
|
||||
{
|
||||
let exp = row
|
||||
.get::<_, f64>(0)
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
let id = row
|
||||
.get::<_, i64>(1)
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
expirations.push((exp, id));
|
||||
}
|
||||
Ok(expirations)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_content_hash(
|
||||
&self,
|
||||
id: i64,
|
||||
) -> Result<Option<i64>, StashError> {
|
||||
let path = self.db_path.clone();
|
||||
blocking::unblock(move || {
|
||||
let db = Self::open_db_internal(&path)?;
|
||||
let result: Option<i64> = db
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT content_hash FROM clipboard WHERE id = ?1",
|
||||
[id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn mark_expired(&self, id: i64) -> Result<(), StashError> {
|
||||
let path = self.db_path.clone();
|
||||
blocking::unblock(move || {
|
||||
let db = Self::open_db_internal(&path)?;
|
||||
db.conn
|
||||
.execute("UPDATE clipboard SET is_expired = 1 WHERE id = ?1", [id])
|
||||
.map_err(|e| StashError::Store(e.to_string().into()))?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn open_db_internal(path: &PathBuf) -> Result<SqliteClipboardDb, StashError> {
|
||||
let conn = rusqlite::Connection::open(path).map_err(|e| {
|
||||
StashError::Store(format!("Failed to open database: {e}").into())
|
||||
})?;
|
||||
SqliteClipboardDb::new(conn, path.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for AsyncClipboardDb {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
db_path: self.db_path.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::HashSet;
|
||||
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn setup_test_db() -> (AsyncClipboardDb, tempfile::TempDir) {
|
||||
let temp_dir = tempdir().expect("Failed to create temp dir");
|
||||
let db_path = temp_dir.path().join("test.db");
|
||||
|
||||
// Create initial database
|
||||
{
|
||||
let conn =
|
||||
rusqlite::Connection::open(&db_path).expect("Failed to open database");
|
||||
crate::db::SqliteClipboardDb::new(conn, db_path.clone())
|
||||
.expect("Failed to create database");
|
||||
}
|
||||
|
||||
let async_db = AsyncClipboardDb::new(db_path);
|
||||
(async_db, temp_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_async_store_entry() {
|
||||
smol::block_on(async {
|
||||
let (async_db, _temp_dir) = setup_test_db();
|
||||
let data = b"async test data";
|
||||
|
||||
let id = async_db
|
||||
.store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None)
|
||||
.await
|
||||
.expect("Failed to store entry");
|
||||
|
||||
assert!(id > 0, "Should return positive id");
|
||||
|
||||
// Verify it was stored by checking content hash
|
||||
let hash = async_db
|
||||
.get_content_hash(id)
|
||||
.await
|
||||
.expect("Failed to get hash")
|
||||
.expect("Hash should exist");
|
||||
|
||||
// Calculate expected hash
|
||||
let mut hasher = crate::db::Fnv1aHasher::new();
|
||||
hasher.write(data);
|
||||
let expected_hash = hasher.finish() as i64;
|
||||
|
||||
assert_eq!(hash, expected_hash, "Stored hash should match");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_async_set_expiration_and_load() {
|
||||
smol::block_on(async {
|
||||
let (async_db, _temp_dir) = setup_test_db();
|
||||
let data = b"expiring entry";
|
||||
|
||||
let id = async_db
|
||||
.store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None)
|
||||
.await
|
||||
.expect("Failed to store entry");
|
||||
|
||||
let expires_at = 1234567890.5;
|
||||
async_db
|
||||
.set_expiration(id, expires_at)
|
||||
.await
|
||||
.expect("Failed to set expiration");
|
||||
|
||||
// Load all expirations
|
||||
let expirations = async_db
|
||||
.load_all_expirations()
|
||||
.await
|
||||
.expect("Failed to load expirations");
|
||||
|
||||
assert_eq!(expirations.len(), 1, "Should have one expiration");
|
||||
assert!(
|
||||
(expirations[0].0 - expires_at).abs() < 0.001,
|
||||
"Expiration time should match"
|
||||
);
|
||||
assert_eq!(expirations[0].1, id, "Expiration id should match");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_async_mark_expired() {
|
||||
smol::block_on(async {
|
||||
let (async_db, _temp_dir) = setup_test_db();
|
||||
let data = b"entry to expire";
|
||||
|
||||
let id = async_db
|
||||
.store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None)
|
||||
.await
|
||||
.expect("Failed to store entry");
|
||||
|
||||
async_db
|
||||
.mark_expired(id)
|
||||
.await
|
||||
.expect("Failed to mark as expired");
|
||||
|
||||
// Load expirations, this should be empty since entry is now marked
|
||||
// expired
|
||||
let expirations = async_db
|
||||
.load_all_expirations()
|
||||
.await
|
||||
.expect("Failed to load expirations");
|
||||
|
||||
assert!(
|
||||
expirations.is_empty(),
|
||||
"Expired entries should not be loaded"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_async_get_content_hash_not_found() {
|
||||
smol::block_on(async {
|
||||
let (async_db, _temp_dir) = setup_test_db();
|
||||
|
||||
let hash = async_db
|
||||
.get_content_hash(999999)
|
||||
.await
|
||||
.expect("Should not fail on non-existent entry");
|
||||
|
||||
assert!(hash.is_none(), "Hash should be None for non-existent entry");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_async_clone() {
|
||||
let (async_db, _temp_dir) = setup_test_db();
|
||||
let cloned = async_db.clone();
|
||||
|
||||
smol::block_on(async {
|
||||
// Both should work independently
|
||||
let data = b"clone test";
|
||||
|
||||
let id1 = async_db
|
||||
.store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None)
|
||||
.await
|
||||
.expect("Failed with original");
|
||||
|
||||
let id2 = cloned
|
||||
.store_entry(data.to_vec(), 100, 1000, None, None, 5_000_000, None)
|
||||
.await
|
||||
.expect("Failed with clone");
|
||||
|
||||
assert_ne!(id1, id2, "Should store as separate entries");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_async_concurrent_operations() {
|
||||
smol::block_on(async {
|
||||
let (async_db, _temp_dir) = setup_test_db();
|
||||
|
||||
// Spawn multiple concurrent store operations
|
||||
let futures: Vec<_> = (0..5)
|
||||
.map(|i| {
|
||||
let db = async_db.clone();
|
||||
let data = format!("concurrent test {}", i).into_bytes();
|
||||
smol::spawn(async move {
|
||||
db.store_entry(data, 100, 1000, None, None, 5_000_000, None)
|
||||
.await
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results: Result<Vec<_>, _> = futures::future::join_all(futures)
|
||||
.await
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
let ids = results.expect("All stores should succeed");
|
||||
assert_eq!(ids.len(), 5, "Should have 5 entries");
|
||||
|
||||
// All IDs should be unique
|
||||
let unique_ids: HashSet<_> = ids.iter().collect();
|
||||
assert_eq!(unique_ids.len(), 5, "All IDs should be unique");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -228,7 +228,7 @@ fn main() -> color_eyre::eyre::Result<()> {
|
|||
}
|
||||
|
||||
let conn = rusqlite::Connection::open(&db_path)?;
|
||||
let db = db::SqliteClipboardDb::new(conn)?;
|
||||
let db = db::SqliteClipboardDb::new(conn, db_path)?;
|
||||
|
||||
match cli.command {
|
||||
Some(Command::Store) => {
|
||||
|
|
@ -397,7 +397,7 @@ fn main() -> color_eyre::eyre::Result<()> {
|
|||
if expired {
|
||||
match db.cleanup_expired() {
|
||||
Ok(count) => {
|
||||
log::info!("Wiped {} expired entries", count);
|
||||
log::info!("Wiped {count} expired entries");
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("failed to wipe expired entries: {e}");
|
||||
|
|
@ -421,7 +421,7 @@ fn main() -> color_eyre::eyre::Result<()> {
|
|||
DbAction::Stats => {
|
||||
match db.stats() {
|
||||
Ok(stats) => {
|
||||
println!("{}", stats);
|
||||
println!("{stats}");
|
||||
},
|
||||
Err(e) => {
|
||||
log::error!("failed to get database stats: {e}");
|
||||
|
|
@ -476,7 +476,8 @@ fn main() -> color_eyre::eyre::Result<()> {
|
|||
&mime_type,
|
||||
cli.min_size,
|
||||
cli.max_size,
|
||||
);
|
||||
)
|
||||
.await;
|
||||
},
|
||||
|
||||
None => {
|
||||
|
|
|
|||
|
|
@ -360,7 +360,7 @@ fn execute_watch_command(
|
|||
|
||||
/// Select the best MIME type from available types when none is specified.
|
||||
/// Prefers specific content types (image/*, application/*) over generic
|
||||
/// text representations (TEXT, STRING, UTF8_STRING).
|
||||
/// text representations (TEXT, STRING, `UTF8_STRING`).
|
||||
fn select_best_mime_type(
|
||||
types: &std::collections::HashSet<String>,
|
||||
) -> Option<String> {
|
||||
|
|
@ -421,7 +421,7 @@ fn handle_regular_paste(
|
|||
let selected_type = available_types.as_ref().and_then(select_best_mime_type);
|
||||
|
||||
let mime_type = if let Some(ref best) = selected_type {
|
||||
log::debug!("Auto-selecting MIME type: {}", best);
|
||||
log::debug!("Auto-selecting MIME type: {best}");
|
||||
PasteMimeType::Specific(best)
|
||||
} else {
|
||||
get_paste_mime_type(args.mime_type.as_deref())
|
||||
|
|
@ -461,14 +461,14 @@ fn handle_regular_paste(
|
|||
|
||||
// Only add newline for text content, not binary data
|
||||
// Check if the MIME type indicates text content
|
||||
let is_text_content = if !types.is_empty() {
|
||||
let is_text_content = if types.is_empty() {
|
||||
// If no MIME type, check if content is valid UTF-8
|
||||
std::str::from_utf8(&buf).is_ok()
|
||||
} else {
|
||||
types.starts_with("text/")
|
||||
|| types == "application/json"
|
||||
|| types == "application/xml"
|
||||
|| types == "application/x-sh"
|
||||
} else {
|
||||
// If no MIME type, check if content is valid UTF-8
|
||||
std::str::from_utf8(&buf).is_ok()
|
||||
};
|
||||
|
||||
if !args.no_newline
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue