Merge pull request #60 from NotAShelf/notashelf/push-wvyzsrrzyrum

add auto-expiry mechanism to `stash watch`
This commit is contained in:
raf 2026-01-23 21:10:41 +03:00 committed by GitHub
commit ded38723d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 699 additions and 62 deletions

35
Cargo.lock generated
View file

@ -1011,6 +1011,12 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "humantime"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]]
name = "ident_case"
version = "1.0.1"
@ -1121,9 +1127,9 @@ dependencies = [
[[package]]
name = "js-sys"
version = "0.3.85"
version = "0.3.83"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
dependencies = [
"once_cell",
"wasm-bindgen",
@ -2128,6 +2134,7 @@ dependencies = [
"ctrlc",
"dirs",
"env_logger",
"humantime",
"imagesize",
"inquire",
"libc",
@ -2561,18 +2568,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
version = "1.0.1+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.108"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
dependencies = [
"cfg-if",
"once_cell",
@ -2583,9 +2590,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.108"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@ -2593,9 +2600,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.108"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
dependencies = [
"bumpalo",
"proc-macro2",
@ -2606,9 +2613,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.108"
version = "0.2.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
dependencies = [
"unicode-ident",
]
@ -2924,9 +2931,9 @@ dependencies = [
[[package]]
name = "wit-bindgen"
version = "0.51.0"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]]
name = "wl-clipboard-rs"

View file

@ -10,7 +10,7 @@ repository = "https://github.com/notashelf/stash"
rust-version = "1.90"
[[bin]]
name = "stash" # actual binary name for Nix, Cargo, etc.
name = "stash" # actual binary name for Nix, Cargo, etc.
path = "src/main.rs"
[dependencies]
@ -22,6 +22,7 @@ crossterm = "0.29.0"
ctrlc = "3.5.1"
dirs = "6.0.0"
env_logger = "0.11.8"
humantime = "2.3.0"
imagesize = "0.14.0"
inquire = { version = "0.9.2", default-features = false, features = [
"crossterm",

132
README.md
View file

@ -45,8 +45,9 @@ with many features such as but not necessarily limited to:
- Import clipboard history from TSV (e.g., from `cliphist list`)
- Image preview (shows dimensions and format)
- Text previews with customizable width
- Deduplication and entry limit control
- De-duplication, whitespace prevention and entry limit control
- Automatic clipboard monitoring with `stash watch`
- Configurable auto-expiry of old entries in watch mode as a safety buffer
- Drop-in replacement for `wl-clipboard` tools (`wl-copy` and `wl-paste`)
- Sensitive clipboard filtering via regex (see below)
- Sensitive clipboard filtering by application (see below)
@ -141,7 +142,7 @@ Commands:
list List clipboard history
decode Decode and output clipboard entry by id
delete Delete clipboard entry by id (if numeric), or entries matching a query (if not). Numeric arguments are treated as ids. Use --type to specify explicitly
wipe Wipe all clipboard history
db Database management operations
import Import clipboard data from stdin (default: TSV format)
watch Start a process to watch clipboard for changes and store automatically
help Print this message or the help of the given subcommand(s)
@ -154,7 +155,7 @@ Options:
--preview-width <PREVIEW_WIDTH>
Maximum width (in characters) for clipboard entry previews in list output [default: 100]
--db-path <DB_PATH>
Path to the `SQLite` clipboard database file
Path to the `SQLite` clipboard database file [env: STASH_DB_PATH=]
--excluded-apps <EXCLUDED_APPS>
Application names to exclude from clipboard history [env: STASH_EXCLUDED_APPS=]
--ask
@ -188,6 +189,11 @@ and copying/deleting entries. This behaviour is EXCLUSIVE TO TTYs and Stash will
display entries in Cliphist-compatible TSV format in Bash scripts. You may also
enforce the output format with `stash list --format <tsv | json>`.
You may also view your clipboard _with the addition of expired entries_, i.e.,
entries that have reached their TTL and are marked as expired, using the
`--expired` flag as `stash list --expired`. Expired entries are not cleaned up
when using this flag, allowing you to inspect them before running cleanup.
### Decode an entry by ID
```bash
@ -219,10 +225,33 @@ stash delete --type id < ids.txt
### Wipe all entries
> [!WARNING]
> This command is deprecated, and will be removed in v0.4.0. Use `stash db wipe`
> instead.
```bash
stash wipe
```
### Database management
Stash provides a `db` subcommand for database maintenance operations:
```bash
stash db wipe [--expired] [--ask]
stash db vacuum
stash db stats
```
- `stash db wipe`: Remove all entries from the database. Use `--expired` to only
wipe expired entries instead of all entries. Requires `--ask` confirmation by
default.
- `stash db vacuum`: Optimize the database using SQLite's VACUUM command,
reclaiming space and improving performance.
- `stash db stats`: Display database statistics including total/active/expired
entry counts, storage size, and page information. This is provided purely for
convenience and the rule of the cool.
### Watch clipboard for changes and store automatically
```bash
@ -235,13 +264,16 @@ automatically. This is designed as an alternative to shelling out to
premade Systemd service in `contrib/`. Packagers are encouraged to vendor the
service unless adding their own.
> [!TIP]
> Stash provides `wl-copy` and `wl-paste` binaries for backwards compatibility
> with the `wl-clipboard` tools. If _must_ depend on those binaries by name, you
> may simply use the `wl-copy` and `wl-paste` provided as `wl-clipboard-rs`
> wrappers on your system. In other words, you can use
> `wl-paste --watch stash store` as an alternative to `stash watch` if
> preferred.
#### Automatic Clipboard Clearing on Expiration
When `stash watch` is running and a clipboard entry expires, Stash will detect
if the current clipboard still contains that expired content and automatically
clear it. This prevents stale data from remaining in your clipboard after an
entry has expired from history.
> [!NOTE]
> This behavior only applies when the watch daemon is actively running. Manual
> expiration or deletion of entries will not clear the clipboard.
### Options
@ -406,6 +438,86 @@ figured out something new, e.g. a neat shell trick, feel free to add it here!
the packagers. While building from source, you may link
`target/release/stash` manually.
### Entry Expiration
Stash supports time-to-live (TTL) for clipboard entries. When an entry's
expiration time is reached, it is marked as expired rather than immediately
deleted. This allows for inspection of expired entries and automatic clipboard
cleanup.
#### How Expiration Works
When `stash watch` is running with `--expire-after`, it monitors the clipboard
and processes expired entries periodically. Upon expiration:
1. The entry's `is_expired` flag is set to `1` in the database
2. If the current clipboard content matches the expired entry, Stash clears the
clipboard to prevent pasting stale data
3. Expired entries are excluded from normal list operations unless `--expired`
is specified
> [!NOTE]
> By default, entries do not expire. Use `stash watch --expire-after DURATION`
> to enable expiration (e.g., `--expire-after 24h` for 24-hour TTL).
#### Viewing Expired Entries
Use `stash list --expired` to include expired entries in the output. This is
useful for:
- Inspecting what has expired from your clipboard history
- Verifying that sensitive data has been properly expired
- Debugging expiration behavior
```bash
# View all entries including expired ones
stash list --expired
# View expired entries in JSON format
stash list --expired --format json
```
#### Cleaning Up Expired Entries
The watch daemon automatically cleans up expired entries when it processes them.
For manual cleanup, use:
```bash
# Remove all expired entries from the database
stash db wipe --expired
```
> [!NOTE]
> If you have a large number of expired entries, consider running
> `stash db vacuum` afterward to reclaim disk space.
#### Automatic Clipboard Clearing
When `stash watch` is running and an entry expires, Stash checks if the current
clipboard still contains that expired content. If it matches, Stash clears the
clipboard automatically. This prevents accidentally pasting outdated content.
> [!TIP]
> This behavior only applies when the watch daemon is actively running. Manual
> expiration or deletion of entries will not clear the clipboard.
#### Database Maintenance
Stash uses SQLite for persistent storage. Over time, deleted entries and
fragmentation can affect performance. Use the `stash db` command to maintain
your database:
- **Check statistics**: `stash db stats` shows entry counts and storage usage.
Use this to monitor growth and decide when to clean up.
- **Remove expired entries**: `stash db wipe --expired` removes entries that
have reached their TTL. The daemon normally handles this, but this is useful
for manual cleanup.
- **Optimize storage**: `stash db vacuum` runs SQLite's VACUUM command to
reclaim space and defragment the database. This is safe to run periodically.
It is recommended to run `stash db vacuum` occasionally (e.g., monthly) to keep
the database compact, especially after deleting many entries.
## Attributions
My thanks go first to [@YaLTeR](https://github.com/YaLTeR/) for the

View file

@ -6,8 +6,12 @@ use unicode_width::UnicodeWidthStr;
use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
pub trait ListCommand {
fn list(&self, out: impl Write, preview_width: u32)
-> Result<(), StashError>;
fn list(
&self,
out: impl Write,
preview_width: u32,
include_expired: bool,
) -> Result<(), StashError>;
}
impl ListCommand for SqliteClipboardDb {
@ -15,14 +19,21 @@ impl ListCommand for SqliteClipboardDb {
&self,
out: impl Write,
preview_width: u32,
include_expired: bool,
) -> Result<(), StashError> {
self.list_entries(out, preview_width).map(|_| ())
self
.list_entries(out, preview_width, include_expired)
.map(|_| ())
}
}
impl SqliteClipboardDb {
#[allow(clippy::too_many_lines)]
pub fn list_tui(&self, preview_width: u32) -> Result<(), StashError> {
pub fn list_tui(
&self,
preview_width: u32,
include_expired: bool,
) -> Result<(), StashError> {
use std::io::stdout;
use crossterm::{
@ -53,12 +64,16 @@ impl SqliteClipboardDb {
use wl_clipboard_rs::copy::{MimeType, Options, Source};
// Query entries from DB
let query = if include_expired {
"SELECT id, contents, mime FROM clipboard ORDER BY last_accessed DESC, \
id DESC"
} else {
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \
is_expired = 0) ORDER BY last_accessed DESC, id DESC"
};
let mut stmt = self
.conn
.prepare(
"SELECT id, contents, mime FROM clipboard ORDER BY last_accessed \
DESC, id DESC",
)
.prepare(query)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut rows = stmt
.query([])

View file

@ -1,21 +1,105 @@
use std::{
collections::hash_map::DefaultHasher,
collections::{BinaryHeap, hash_map::DefaultHasher},
hash::{Hash, Hasher},
io::Read,
time::Duration,
};
use smol::Timer;
use wl_clipboard_rs::paste::{ClipboardType, Seat, get_contents};
use wl_clipboard_rs::{
copy::{MimeType as CopyMimeType, Options, Source},
paste::{ClipboardType, Seat, get_contents},
};
use crate::db::{ClipboardDb, SqliteClipboardDb};
/// Wrapper to provide [`Ord`] implementation for `f64` by negating values.
/// This allows [`BinaryHeap`], which is a max-heap, to function as a min-heap.
/// Also see:
/// - <https://doc.rust-lang.org/std/cmp/struct.Reverse.html>
/// - <https://doc.rust-lang.org/std/primitive.f64.html#method.total_cmp>
/// - <https://docs.rs/ordered-float/latest/ordered_float/>
#[derive(Debug, Clone, Copy)]
struct Neg(f64);
impl Neg {
fn inner(&self) -> f64 {
self.0
}
}
impl std::cmp::PartialEq for Neg {
fn eq(&self, other: &Self) -> bool {
self.0 == other.0
}
}
impl std::cmp::Eq for Neg {}
impl std::cmp::PartialOrd for Neg {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl std::cmp::Ord for Neg {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
// Reverse ordering for min-heap behavior
other
.0
.partial_cmp(&self.0)
.unwrap_or(std::cmp::Ordering::Equal)
}
}
/// Min-heap for tracking entry expirations with sub-second precision.
/// Uses Neg wrapper to turn BinaryHeap (max-heap) into min-heap behavior.
#[derive(Debug, Default)]
struct ExpirationQueue {
heap: BinaryHeap<(Neg, i64)>,
}
impl ExpirationQueue {
/// Create a new empty expiration queue
fn new() -> Self {
Self {
heap: BinaryHeap::new(),
}
}
/// Push a new expiration into the queue
fn push(&mut self, expires_at: f64, id: i64) {
self.heap.push((Neg(expires_at), id));
}
/// Peek at the next expiration timestamp without removing it
fn peek_next(&self) -> Option<f64> {
self.heap.peek().map(|(neg, _)| neg.inner())
}
/// Remove and return all entries that have expired by `now`
fn pop_expired(&mut self, now: f64) -> Vec<i64> {
let mut expired = Vec::new();
while let Some((neg_exp, id)) = self.heap.peek() {
let expires_at = neg_exp.inner();
if expires_at <= now {
expired.push(*id);
self.heap.pop();
} else {
break;
}
}
expired
}
}
pub trait WatchCommand {
fn watch(
&self,
max_dedupe_search: u64,
max_items: u64,
excluded_apps: &[String],
expire_after: Option<Duration>,
);
}
@ -25,10 +109,46 @@ impl WatchCommand for SqliteClipboardDb {
max_dedupe_search: u64,
max_items: u64,
excluded_apps: &[String],
expire_after: Option<Duration>,
) {
smol::block_on(async {
log::info!("Starting clipboard watch daemon");
// 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);
}
}
}
}
}
// We use hashes for comparison instead of storing full contents
let mut last_hash: Option<u64> = None;
let mut buf = Vec::with_capacity(4096);
@ -53,6 +173,83 @@ impl WatchCommand for SqliteClipboardDb {
}
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();
if let Some(stored_hash) = expired_hash {
// Mark as expired
self
.conn
.execute(
"UPDATE clipboard SET is_expired = 1 WHERE id = ?1",
[id],
)
.ok();
log::info!("Entry {id} marked as expired");
// Check if this expired entry is currently in the clipboard
if let Ok((mut reader, _)) = get_contents(
ClipboardType::Regular,
Seat::Unspecified,
wl_clipboard_rs::paste::MimeType::Any,
) {
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(&current_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,
);
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 get_contents(
ClipboardType::Regular,
Seat::Unspecified,
@ -70,16 +267,23 @@ impl WatchCommand for SqliteClipboardDb {
if !buf.is_empty() {
let current_hash = hash_contents(&buf);
if last_hash != Some(current_hash) {
let id = self.next_sequence();
match self.store_entry(
&buf[..],
max_dedupe_search,
max_items,
Some(excluded_apps),
) {
Ok(_) => {
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();
exp_queue.push(expires_at, id);
}
},
Err(crate::db::StashError::ExcludedByApp(_)) => {
log::info!("Clipboard entry excluded by app filter");
@ -106,7 +310,11 @@ impl WatchCommand for SqliteClipboardDb {
}
},
}
Timer::after(Duration::from_millis(500)).await;
// Normal poll interval (only if no expirations pending)
if exp_queue.peek_next().is_none() {
Timer::after(Duration::from_millis(500)).await;
}
}
});
}

View file

@ -80,6 +80,7 @@ pub trait ClipboardDb {
&self,
out: impl Write,
preview_width: u32,
include_expired: bool,
) -> Result<usize, StashError>;
fn decode_entry(
&self,
@ -93,7 +94,6 @@ pub trait ClipboardDb {
&self,
id: i64,
) -> Result<(i64, Vec<u8>, Option<String>), StashError>;
fn next_sequence(&self) -> i64;
}
#[derive(Serialize, Deserialize)]
@ -256,6 +256,89 @@ impl SqliteClipboardDb {
})?;
}
// Add expires_at column if it doesn't exist (v4)
if schema_version < 4 {
let has_expires_at: bool = tx
.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND \
name='clipboard'",
[],
|row| {
let sql: String = row.get(0)?;
Ok(sql.to_lowercase().contains("expires_at"))
},
)
.unwrap_or(false);
if !has_expires_at {
tx.execute("ALTER TABLE clipboard ADD COLUMN expires_at REAL", [])
.map_err(|e| {
StashError::Store(
format!("Failed to add expires_at column: {e}").into(),
)
})?;
}
// Create partial index for expires_at (only index non-NULL values)
tx.execute(
"CREATE INDEX IF NOT EXISTS idx_expires_at ON clipboard(expires_at) \
WHERE expires_at IS NOT NULL",
[],
)
.map_err(|e| {
StashError::Store(
format!("Failed to create expires_at index: {e}").into(),
)
})?;
tx.execute("PRAGMA user_version = 4", []).map_err(|e| {
StashError::Store(format!("Failed to set schema version: {e}").into())
})?;
}
// Add is_expired column if it doesn't exist (v5)
if schema_version < 5 {
let has_is_expired: bool = tx
.query_row(
"SELECT sql FROM sqlite_master WHERE type='table' AND \
name='clipboard'",
[],
|row| {
let sql: String = row.get(0)?;
Ok(sql.to_lowercase().contains("is_expired"))
},
)
.unwrap_or(false);
if !has_is_expired {
tx.execute(
"ALTER TABLE clipboard ADD COLUMN is_expired INTEGER DEFAULT 0",
[],
)
.map_err(|e| {
StashError::Store(
format!("Failed to add is_expired column: {e}").into(),
)
})?;
}
// Create index for is_expired (for filtering)
tx.execute(
"CREATE INDEX IF NOT EXISTS idx_is_expired ON clipboard(is_expired) \
WHERE is_expired = 1",
[],
)
.map_err(|e| {
StashError::Store(
format!("Failed to create is_expired index: {e}").into(),
)
})?;
tx.execute("PRAGMA user_version = 5", []).map_err(|e| {
StashError::Store(format!("Failed to set schema version: {e}").into())
})?;
}
tx.commit().map_err(|e| {
StashError::Store(
format!("Failed to commit migration transaction: {e}").into(),
@ -271,13 +354,17 @@ impl SqliteClipboardDb {
}
impl SqliteClipboardDb {
pub fn list_json(&self) -> Result<String, StashError> {
pub fn list_json(&self, include_expired: bool) -> Result<String, StashError> {
let query = if include_expired {
"SELECT id, contents, mime FROM clipboard ORDER BY \
COALESCE(last_accessed, 0) DESC, id DESC"
} else {
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \
is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC"
};
let mut stmt = self
.conn
.prepare(
"SELECT id, contents, mime FROM clipboard ORDER BY \
COALESCE(last_accessed, 0) DESC, id DESC",
)
.prepare(query)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut rows = stmt
.query([])
@ -486,13 +573,18 @@ impl ClipboardDb for SqliteClipboardDb {
&self,
mut out: impl Write,
preview_width: u32,
include_expired: bool,
) -> Result<usize, StashError> {
let query = if include_expired {
"SELECT id, contents, mime FROM clipboard ORDER BY \
COALESCE(last_accessed, 0) DESC, id DESC"
} else {
"SELECT id, contents, mime FROM clipboard WHERE (is_expired IS NULL OR \
is_expired = 0) ORDER BY COALESCE(last_accessed, 0) DESC, id DESC"
};
let mut stmt = self
.conn
.prepare(
"SELECT id, contents, mime FROM clipboard ORDER BY \
COALESCE(last_accessed, 0) DESC, id DESC",
)
.prepare(query)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut rows = stmt
.query([])
@ -640,17 +732,119 @@ impl ClipboardDb for SqliteClipboardDb {
Ok((id, contents, mime))
}
}
fn next_sequence(&self) -> i64 {
match self
impl SqliteClipboardDb {
/// Get current Unix timestamp with sub-second precision
pub fn now() -> f64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs_f64()
}
/// Clean up all expired entries. Returns count deleted.
pub fn cleanup_expired(&self) -> Result<usize, StashError> {
let now = Self::now();
self
.conn
.query_row("SELECT MAX(id) FROM clipboard", [], |row| {
row.get::<_, Option<i64>>(0)
}) {
Ok(Some(max_id)) => max_id + 1,
Ok(None) | Err(_) => 1,
.execute(
"DELETE FROM clipboard WHERE expires_at IS NOT NULL AND expires_at <= \
?1",
[now],
)
.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,
id: i64,
expires_at: f64,
) -> Result<(), StashError> {
self
.conn
.execute(
"UPDATE clipboard SET expires_at = ?2 WHERE id = ?1",
params![id, expires_at],
)
.map_err(|e| StashError::Store(e.to_string().into()))?;
Ok(())
}
/// Optimize database using VACUUM
pub fn vacuum(&self) -> Result<(), StashError> {
self
.conn
.execute("VACUUM", [])
.map_err(|e| StashError::Store(e.to_string().into()))?;
Ok(())
}
/// Get database statistics
pub fn stats(&self) -> Result<String, StashError> {
let total: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM clipboard", [], |row| row.get(0))
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let expired: i64 = self
.conn
.query_row(
"SELECT COUNT(*) FROM clipboard WHERE is_expired = 1",
[],
|row| row.get(0),
)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let active = total - expired;
let with_expiration: i64 = self
.conn
.query_row(
"SELECT COUNT(*) FROM clipboard WHERE expires_at IS NOT NULL AND \
(is_expired IS NULL OR is_expired = 0)",
[],
|row| row.get(0),
)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
// Get database file size
let page_count: i64 = self
.conn
.query_row("PRAGMA page_count", [], |row| row.get(0))
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let page_size: i64 = self
.conn
.query_row("PRAGMA page_size", [], |row| row.get(0))
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let size_bytes = page_count * page_size;
let size_mb = size_bytes as f64 / 1024.0 / 1024.0;
Ok(format!(
"Database Statistics:\n\nEntries:\nTotal: {total}\nActive: \
{active}\nExpired: {expired}\nWith TTL: \
{with_expiration}\n\nStorage:\nSize: {size_mb:.2} MB \
({size_bytes} bytes)\nPages: {page_count}\nPage size: \
{page_size} bytes"
))
}
}
/// Try to load a sensitive regex from systemd credential or env.

View file

@ -2,9 +2,11 @@ use std::{
env,
io::{self, IsTerminal},
path::PathBuf,
time::Duration,
};
use clap::{CommandFactory, Parser, Subcommand};
use humantime::parse_duration;
use inquire::Confirm;
mod commands;
@ -71,6 +73,10 @@ enum Command {
/// Output format: "tsv" (default) or "json"
#[arg(long, value_parser = ["tsv", "json"])]
format: Option<String>,
/// Show only expired entries (diagnostic, does not remove them)
#[arg(long)]
expired: bool,
},
/// Decode and output clipboard entry by id
@ -93,12 +99,21 @@ enum Command {
},
/// Wipe all clipboard history
///
/// DEPRECATED: Use `stash db wipe` instead
#[command(hide = true)]
Wipe {
/// Ask for confirmation before wiping
#[arg(long)]
ask: bool,
},
/// Database management operations
Db {
#[command(subcommand)]
action: DbAction,
},
/// Import clipboard data from stdin (default: TSV format)
Import {
/// Explicitly specify format: "tsv" (default)
@ -111,7 +126,31 @@ enum Command {
},
/// Start a process to watch clipboard for changes and store automatically.
Watch,
Watch {
/// Expire new entries after duration (e.g., "3s", "500ms", "1h30m").
#[arg(long, value_parser = parse_duration)]
expire_after: Option<Duration>,
},
}
#[derive(Subcommand)]
enum DbAction {
/// Wipe database entries
Wipe {
/// Only wipe expired entries instead of all entries
#[arg(long)]
expired: bool,
/// Ask for confirmation before wiping
#[arg(long)]
ask: bool,
},
/// Optimize database using VACUUM
Vacuum,
/// Show database statistics
Stats,
}
fn report_error<T>(
@ -186,16 +225,16 @@ fn main() -> color_eyre::eyre::Result<()> {
"failed to store entry",
);
},
Some(Command::List { format }) => {
Some(Command::List { format, expired }) => {
match format.as_deref() {
Some("tsv") => {
report_error(
db.list(io::stdout(), cli.preview_width),
db.list(io::stdout(), cli.preview_width, expired),
"failed to list entries",
);
},
Some("json") => {
match db.list_json() {
match db.list_json(expired) {
Ok(json) => {
println!("{json}");
},
@ -210,12 +249,12 @@ fn main() -> color_eyre::eyre::Result<()> {
None => {
if std::io::stdout().is_terminal() {
report_error(
db.list_tui(cli.preview_width),
db.list_tui(cli.preview_width, expired),
"failed to list entries in TUI",
);
} else {
report_error(
db.list(io::stdout(), cli.preview_width),
db.list(io::stdout(), cli.preview_width, expired),
"failed to list entries",
);
}
@ -287,6 +326,10 @@ fn main() -> color_eyre::eyre::Result<()> {
}
},
Some(Command::Wipe { ask }) => {
eprintln!(
"Warning: The 'stash wipe' command is deprecated. Use 'stash db \
wipe' instead."
);
let mut should_proceed = true;
if ask {
should_proceed = Confirm::new(
@ -304,6 +347,62 @@ fn main() -> color_eyre::eyre::Result<()> {
}
},
Some(Command::Db { action }) => {
match action {
DbAction::Wipe { expired, ask } => {
let mut should_proceed = true;
if ask {
let message = if expired {
"Are you sure you want to wipe all expired clipboard entries?"
} else {
"Are you sure you want to wipe ALL clipboard history?"
};
should_proceed = Confirm::new(message)
.with_default(false)
.prompt()
.unwrap_or(false);
if !should_proceed {
log::info!("db wipe command aborted by user.");
}
}
if should_proceed {
if expired {
match db.cleanup_expired() {
Ok(count) => {
log::info!("Wiped {} expired entries", count);
},
Err(e) => {
log::error!("failed to wipe expired entries: {e}");
},
}
} else {
report_error(db.wipe(), "failed to wipe database");
}
}
},
DbAction::Vacuum => {
match db.vacuum() {
Ok(()) => {
log::info!("Database optimized successfully");
},
Err(e) => {
log::error!("failed to vacuum database: {e}");
},
}
},
DbAction::Stats => {
match db.stats() {
Ok(stats) => {
println!("{}", stats);
},
Err(e) => {
log::error!("failed to get database stats: {e}");
},
}
},
}
},
Some(Command::Import { r#type, ask }) => {
let mut should_proceed = true;
if ask {
@ -334,7 +433,7 @@ fn main() -> color_eyre::eyre::Result<()> {
}
}
},
Some(Command::Watch) => {
Some(Command::Watch { expire_after }) => {
db.watch(
cli.max_dedupe_search,
cli.max_items,
@ -342,6 +441,7 @@ fn main() -> color_eyre::eyre::Result<()> {
&cli.excluded_apps,
#[cfg(not(feature = "use-toplevel"))]
&[],
expire_after,
);
},