various: validate lower and upper boundaries before storing; add CLI flags

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I6484f9579a8799d952b15adcb47c8eec6a6a6964
This commit is contained in:
raf 2026-02-26 17:02:45 +03:00
commit 3a14860ae1
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
4 changed files with 68 additions and 33 deletions

View file

@ -2,6 +2,7 @@ use std::io::Read;
use crate::db::{ClipboardDb, SqliteClipboardDb}; use crate::db::{ClipboardDb, SqliteClipboardDb};
#[allow(clippy::too_many_arguments)]
pub trait StoreCommand { pub trait StoreCommand {
fn store( fn store(
&self, &self,
@ -10,6 +11,8 @@ pub trait StoreCommand {
max_items: u64, max_items: u64,
state: Option<String>, state: Option<String>,
excluded_apps: &[String], excluded_apps: &[String],
min_size: Option<usize>,
max_size: usize,
) -> Result<(), crate::db::StashError>; ) -> Result<(), crate::db::StashError>;
} }
@ -21,6 +24,8 @@ impl StoreCommand for SqliteClipboardDb {
max_items: u64, max_items: u64,
state: Option<String>, state: Option<String>,
excluded_apps: &[String], excluded_apps: &[String],
min_size: Option<usize>,
max_size: usize,
) -> Result<(), crate::db::StashError> { ) -> Result<(), crate::db::StashError> {
if let Some("sensitive" | "clear") = state.as_deref() { if let Some("sensitive" | "clear") = state.as_deref() {
self.delete_last()?; self.delete_last()?;
@ -31,6 +36,8 @@ impl StoreCommand for SqliteClipboardDb {
max_dedupe_search, max_dedupe_search,
max_items, max_items,
Some(excluded_apps), Some(excluded_apps),
min_size,
max_size,
)?; )?;
log::info!("Entry stored"); log::info!("Entry stored");
} }

View file

@ -175,6 +175,7 @@ fn negotiate_mime_type(
} }
} }
#[allow(clippy::too_many_arguments)]
pub trait WatchCommand { pub trait WatchCommand {
fn watch( fn watch(
&self, &self,
@ -183,6 +184,8 @@ pub trait WatchCommand {
excluded_apps: &[String], excluded_apps: &[String],
expire_after: Option<Duration>, expire_after: Option<Duration>,
mime_type_preference: &str, mime_type_preference: &str,
min_size: Option<usize>,
max_size: usize,
); );
} }
@ -194,6 +197,8 @@ impl WatchCommand for SqliteClipboardDb {
excluded_apps: &[String], excluded_apps: &[String],
expire_after: Option<Duration>, expire_after: Option<Duration>,
mime_type_preference: &str, mime_type_preference: &str,
min_size: Option<usize>,
max_size: usize,
) { ) {
smol::block_on(async { smol::block_on(async {
log::info!( log::info!(
@ -349,6 +354,8 @@ impl WatchCommand for SqliteClipboardDb {
max_dedupe_search, max_dedupe_search,
max_items, max_items,
Some(excluded_apps), Some(excluded_apps),
min_size,
max_size,
) { ) {
Ok(id) => { Ok(id) => {
log::info!("Stored new clipboard entry (id: {id})"); log::info!("Stored new clipboard entry (id: {id})");

View file

@ -16,6 +16,8 @@ use rusqlite::{Connection, OptionalExtension, params};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
pub const DEFAULT_MAX_ENTRY_SIZE: usize = 5_000_000;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum StashError { pub enum StashError {
#[error("Input is empty or too large, skipping store.")] #[error("Input is empty or too large, skipping store.")]
@ -70,7 +72,7 @@ pub trait ClipboardDb {
max_items: u64, max_items: u64,
excluded_apps: Option<&[String]>, excluded_apps: Option<&[String]>,
min_size: Option<usize>, min_size: Option<usize>,
max_size: Option<usize>, max_size: usize,
) -> Result<i64, StashError>; ) -> Result<i64, StashError>;
fn deduplicate_by_hash( fn deduplicate_by_hash(
@ -417,7 +419,7 @@ impl ClipboardDb for SqliteClipboardDb {
max_items: u64, max_items: u64,
excluded_apps: Option<&[String]>, excluded_apps: Option<&[String]>,
min_size: Option<usize>, min_size: Option<usize>,
max_size: Option<usize>, max_size: usize,
) -> Result<i64, StashError> { ) -> Result<i64, StashError> {
let mut buf = Vec::new(); let mut buf = Vec::new();
if input.read_to_end(&mut buf).is_err() || buf.is_empty() { if input.read_to_end(&mut buf).is_err() || buf.is_empty() {
@ -432,12 +434,8 @@ impl ClipboardDb for SqliteClipboardDb {
return Err(StashError::TooSmall(min)); return Err(StashError::TooSmall(min));
} }
if let Some(max) = max_size { if size > max_size {
if size > max { return Err(StashError::TooLarge(max_size));
return Err(StashError::TooLarge(max));
}
} else if size > 5 * 1_000_000 {
return Err(StashError::TooLarge(5 * 1_000_000));
} }
if buf.iter().all(u8::is_ascii_whitespace) { if buf.iter().all(u8::is_ascii_whitespace) {
@ -1536,7 +1534,7 @@ mod tests {
let cursor = std::io::Cursor::new(test_data.to_vec()); let cursor = std::io::Cursor::new(test_data.to_vec());
let id = db let id = db
.store_entry(cursor, 100, 1000, None, None, None) .store_entry(cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE)
.expect("Failed to store entry"); .expect("Failed to store entry");
let content_hash: Option<i64> = db let content_hash: Option<i64> = db
@ -1571,7 +1569,7 @@ mod tests {
let test_data = b"Test content for copy"; let test_data = b"Test content for copy";
let cursor = std::io::Cursor::new(test_data.to_vec()); let cursor = std::io::Cursor::new(test_data.to_vec());
let id_a = db let id_a = db
.store_entry(cursor, 100, 1000, None, None, None) .store_entry(cursor, 100, 1000, None, None, DEFAULT_MAX_ENTRY_SIZE)
.expect("Failed to store entry A"); .expect("Failed to store entry A");
let original_last_accessed: i64 = db let original_last_accessed: i64 = db
@ -1672,7 +1670,7 @@ mod tests {
1000, 1000,
None, None,
None, None,
None, DEFAULT_MAX_ENTRY_SIZE,
) )
.expect("Failed to store URI list"); .expect("Failed to store URI list");
@ -1705,7 +1703,7 @@ mod tests {
1000, 1000,
None, None,
None, None,
None, DEFAULT_MAX_ENTRY_SIZE,
) )
.expect("Failed to store image"); .expect("Failed to store image");
@ -1733,7 +1731,7 @@ mod tests {
1000, 1000,
None, None,
None, None,
None, DEFAULT_MAX_ENTRY_SIZE,
) )
.expect("Failed to store first"); .expect("Failed to store first");
let _id2 = db let _id2 = db
@ -1743,7 +1741,7 @@ mod tests {
1000, 1000,
None, None,
None, None,
None, DEFAULT_MAX_ENTRY_SIZE,
) )
.expect("Failed to store second"); .expect("Failed to store second");
@ -1778,7 +1776,7 @@ mod tests {
3, // max 3 items 3, // max 3 items
None, None,
None, None,
None, DEFAULT_MAX_ENTRY_SIZE,
) )
.expect("Failed to store"); .expect("Failed to store");
} }
@ -1799,7 +1797,7 @@ mod tests {
1000, 1000,
None, None,
None, None,
None, DEFAULT_MAX_ENTRY_SIZE,
); );
assert!(matches!(result, Err(StashError::EmptyOrTooLarge))); assert!(matches!(result, Err(StashError::EmptyOrTooLarge)));
} }
@ -1813,7 +1811,7 @@ mod tests {
1000, 1000,
None, None,
None, None,
None, DEFAULT_MAX_ENTRY_SIZE,
); );
assert!(matches!(result, Err(StashError::AllWhitespace))); assert!(matches!(result, Err(StashError::AllWhitespace)));
} }
@ -1823,8 +1821,14 @@ mod tests {
let db = test_db(); let db = test_db();
// 5MB + 1 byte // 5MB + 1 byte
let data = vec![b'a'; 5 * 1_000_000 + 1]; let data = vec![b'a'; 5 * 1_000_000 + 1];
let result = let result = db.store_entry(
db.store_entry(std::io::Cursor::new(data), 100, 1000, None, None, None); std::io::Cursor::new(data),
100,
1000,
None,
None,
DEFAULT_MAX_ENTRY_SIZE,
);
assert!(matches!(result, Err(StashError::TooLarge(5000000)))); assert!(matches!(result, Err(StashError::TooLarge(5000000))));
} }
@ -1838,7 +1842,7 @@ mod tests {
1000, 1000,
None, None,
None, None,
None, DEFAULT_MAX_ENTRY_SIZE,
) )
.expect("Failed to store"); .expect("Failed to store");
@ -1864,7 +1868,7 @@ mod tests {
1000, 1000,
None, None,
None, None,
None, DEFAULT_MAX_ENTRY_SIZE,
) )
.expect("Failed to store"); .expect("Failed to store");
db.store_entry( db.store_entry(
@ -1873,7 +1877,7 @@ mod tests {
1000, 1000,
None, None,
None, None,
None, DEFAULT_MAX_ENTRY_SIZE,
) )
.expect("Failed to store"); .expect("Failed to store");
@ -1900,7 +1904,7 @@ mod tests {
1000, 1000,
None, None,
None, None,
None, DEFAULT_MAX_ENTRY_SIZE,
) )
.expect("Failed to store"); .expect("Failed to store");
} }
@ -1970,7 +1974,7 @@ mod tests {
1000, 1000,
None, None,
None, None,
None, DEFAULT_MAX_ENTRY_SIZE,
) )
.expect("Failed to store"); .expect("Failed to store");

View file

@ -15,15 +15,18 @@ pub(crate) mod mime;
mod multicall; mod multicall;
#[cfg(feature = "use-toplevel")] mod wayland; #[cfg(feature = "use-toplevel")] mod wayland;
use crate::commands::{ use crate::{
decode::DecodeCommand, commands::{
delete::DeleteCommand, decode::DecodeCommand,
import::ImportCommand, delete::DeleteCommand,
list::ListCommand, import::ImportCommand,
query::QueryCommand, list::ListCommand,
store::StoreCommand, query::QueryCommand,
watch::WatchCommand, store::StoreCommand,
wipe::WipeCommand, watch::WatchCommand,
wipe::WipeCommand,
},
db::DEFAULT_MAX_ENTRY_SIZE,
}; };
#[derive(Parser)] #[derive(Parser)]
@ -42,6 +45,16 @@ struct Cli {
#[arg(long, default_value_t = 20)] #[arg(long, default_value_t = 20)]
max_dedupe_search: u64, max_dedupe_search: u64,
/// Minimum size (in bytes) for clipboard entries. Entries smaller than this
/// will not be stored.
#[arg(long, env = "STASH_MIN_SIZE")]
min_size: Option<usize>,
/// Maximum size (in bytes) for clipboard entries. Entries larger than this
/// will not be stored. Defaults to 5MB.
#[arg(long, default_value_t = DEFAULT_MAX_ENTRY_SIZE, env = "STASH_MAX_SIZE")]
max_size: usize,
/// Maximum width (in characters) for clipboard entry previews in list /// Maximum width (in characters) for clipboard entry previews in list
/// output. /// output.
#[arg(long, default_value_t = 100)] #[arg(long, default_value_t = 100)]
@ -226,6 +239,8 @@ fn main() -> color_eyre::eyre::Result<()> {
&cli.excluded_apps, &cli.excluded_apps,
#[cfg(not(feature = "use-toplevel"))] #[cfg(not(feature = "use-toplevel"))]
&[], &[],
cli.min_size,
cli.max_size,
), ),
"failed to store entry", "failed to store entry",
); );
@ -451,6 +466,8 @@ fn main() -> color_eyre::eyre::Result<()> {
&[], &[],
expire_after, expire_after,
&mime_type, &mime_type,
cli.min_size,
cli.max_size,
); );
}, },