From 5153b4d19cddcd27105e17d342797e1915394f27 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 1 Apr 2026 09:45:12 +0300 Subject: [PATCH 01/13] db: allow encrypting database entries via age on the storage layer Signed-off-by: NotAShelf Change-Id: I942e2aeba2f079323a55bf4455937ddd6a6a6964 --- Cargo.lock | 645 +++++++++++++++++++++++++++++++++++++++++++++++++- Cargo.toml | 1 + src/db/mod.rs | 178 +++++++++++++- 3 files changed, 812 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3bc9a63..090e616 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,59 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "age" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf640be7658959746f1f0f2faab798f6098a9436a8e18e148d18bc9875e13c4b" +dependencies = [ + "age-core", + "base64 0.21.7", + "bech32", + "chacha20poly1305", + "cookie-factory", + "hmac", + "i18n-embed", + "i18n-embed-fl", + "lazy_static", + "nom 7.1.3", + "pin-project", + "rand", + "rust-embed", + "scrypt", + "sha2", + "subtle", + "x25519-dalek", + "zeroize", +] + +[[package]] +name = "age-core" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2bf6a89c984ca9d850913ece2da39e1d200563b0a94b002b253beee4c5acf99" +dependencies = [ + "base64 0.21.7", + "chacha20poly1305", + "cookie-factory", + "hkdf", + "io_tee", + "nom 7.1.3", + "rand", + "secrecy", + "sha2", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -286,12 +339,33 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bech32" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445" + [[package]] name = "bit-set" version = "0.5.3" @@ -393,6 +467,41 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.6.0" @@ -508,6 +617,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" +dependencies = [ + "futures", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -581,6 +699,32 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling" version = "0.23.0" @@ -660,6 +804,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -867,6 +1012,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -878,6 +1029,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "find-crate" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2" +dependencies = [ + "toml", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -902,6 +1062,50 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "fluent" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb74634707bebd0ce645a981148e8fb8c7bccd4c33c652aeffd28bf2f96d555a" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash 1.1.0", + "self_cell 0.10.3", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1129,12 +1333,96 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "humantime" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "i18n-config" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef" +dependencies = [ + "basic-toml", + "log", + "serde", + "serde_derive", + "thiserror 1.0.69", + "unic-langid", +] + +[[package]] +name = "i18n-embed" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "669ffc2c93f97e6ddf06ddbe999fcd6782e3342978bb85f7d3c087c7978404c4" +dependencies = [ + "arc-swap", + "fluent", + "fluent-langneg", + "fluent-syntax", + "i18n-embed-impl", + "intl-memoizer", + "log", + "parking_lot", + "rust-embed", + "thiserror 1.0.69", + "unic-langid", + "walkdir", +] + +[[package]] +name = "i18n-embed-fl" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04b2969d0b3fc6143776c535184c19722032b43e6a642d710fa3f88faec53c2d" +dependencies = [ + "find-crate", + "fluent", + "fluent-syntax", + "i18n-config", + "i18n-embed", + "proc-macro-error2", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", + "unic-langid", +] + +[[package]] +name = "i18n-embed-impl" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2" +dependencies = [ + "find-crate", + "i18n-config", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -1283,6 +1571,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "inquire" version = "0.9.4" @@ -1309,6 +1606,31 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "io_tee" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1709,6 +2031,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "option-ext" version = "0.2.0" @@ -1779,6 +2107,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1891,6 +2229,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1928,6 +2286,17 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -1958,6 +2327,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1977,6 +2355,28 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2031,6 +2431,18 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", "rand_core", ] @@ -2039,6 +2451,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "ratatui" @@ -2199,12 +2614,58 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.117", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustc-demangle" version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2239,12 +2700,65 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "self_cell" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d" +dependencies = [ + "self_cell 1.2.2", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.27" @@ -2419,8 +2933,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" name = "stash-clipboard" version = "0.3.6" dependencies = [ + "age", "arc-swap", - "base64", + "base64 0.22.1", "blocking", "clap", "clap-verbosity-flag", @@ -2485,6 +3000,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -2571,7 +3092,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bitflags 2.11.0", "fancy-regex", "filedescriptor", @@ -2683,9 +3204,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] +[[package]] +name = "toml" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" +dependencies = [ + "serde", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -2780,6 +3311,15 @@ dependencies = [ "petgraph", ] +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.2", +] + [[package]] name = "typenum" version = "1.19.0" @@ -2803,6 +3343,25 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "serde", + "tinystr", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2838,6 +3397,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "url" version = "2.5.8" @@ -2902,6 +3471,16 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -3164,6 +3743,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -3435,6 +4023,18 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + [[package]] name = "yoke" version = "0.8.2" @@ -3519,6 +4119,26 @@ dependencies = [ "zvariant", ] +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zerofrom" version = "0.1.7" @@ -3540,6 +4160,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zerotrie" version = "0.2.4" @@ -3557,6 +4197,7 @@ version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index bae39c5..7e6c408 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ name = "stash" # actual binary name for Nix, Cargo, etc. path = "src/main.rs" [dependencies] +age = "0.11.2" arc-swap = { version = "1.9.0", optional = true } base64 = "0.22.1" blocking = "1.6.2" diff --git a/src/db/mod.rs b/src/db/mod.rs index 65eb097..60d6a0b 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -215,12 +215,18 @@ pub enum StashError { QueryDelete(Box), #[error("Failed to delete entry with id {0}: {1}")] DeleteEntry(i64, Box), + + #[error("Encryption error: {0}")] + Encryption(Box), + #[error("Decryption error: {0}")] + Decryption(Box), } pub trait ClipboardDb { /// Store a new clipboard entry. /// /// # Arguments + /// /// * `input` - Reader for the clipboard content /// * `max_dedupe_search` - Maximum number of recent entries to check for /// duplicates @@ -595,11 +601,26 @@ impl SqliteClipboardDb { .get(2) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; + let decrypted_contents = if contents.starts_with(b"age-encryption.org/v1") + { + match decrypt_data(&contents) { + Ok(decrypted) => decrypted, + Err(e) => { + debug!( + "Skipping entry {id} in JSON output: decryption failed: {e}" + ); + continue; + }, + } + } else { + contents + }; + let contents_str = match mime.as_deref() { Some(m) if m.starts_with("text/") || m == "application/json" => { - String::from_utf8_lossy(&contents).into_owned() + String::from_utf8_lossy(&decrypted_contents).into_owned() }, - _ => base64::prelude::BASE64_STANDARD.encode(&contents), + _ => base64::prelude::BASE64_STANDARD.encode(&decrypted_contents), }; entries.push(serde_json::json!({ "id": id, @@ -689,13 +710,22 @@ impl ClipboardDb for SqliteClipboardDb { None => None, }; + let encrypted_buf = if load_encryption_passphrase().is_some() { + Some(encrypt_data(&buf)?) + } else { + debug!("No encryption passphrase configured, storing entry unencrypted"); + None + }; + + let contents_to_store = encrypted_buf.unwrap_or(buf); + self .conn .execute( "INSERT INTO clipboard (contents, mime, content_hash, last_accessed, \ mime_types) VALUES (?1, ?2, ?3, ?4, ?5)", params![ - buf, + contents_to_store, mime, content_hash, std::time::SystemTime::now() @@ -838,7 +868,20 @@ impl ClipboardDb for SqliteClipboardDb { .get(2) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let preview = preview_entry(&contents, mime.as_deref(), preview_width); + let preview_contents = if contents.starts_with(b"age-encryption.org/v1") { + match decrypt_data(&contents) { + Ok(decrypted) => decrypted, + Err(e) => { + debug!("skipping entry {id}: decryption failed: {e}"); + continue; + }, + } + } else { + contents + }; + + let preview = + preview_entry(&preview_contents, mime.as_deref(), preview_width); if writeln!(out, "{id}\t{preview}").is_ok() { listed += 1; } @@ -872,8 +915,15 @@ impl ClipboardDb for SqliteClipboardDb { |row| Ok((row.get(0)?, row.get(1)?)), ) .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; + + let decrypted_contents = if contents.starts_with(b"age-encryption.org/v1") { + decrypt_data(&contents)? + } else { + contents + }; + out - .write_all(&contents) + .write_all(&decrypted_contents) .map_err(|e| StashError::DecodeWrite(e.to_string().into()))?; log::info!("decoded entry with id {id}"); Ok(()) @@ -898,7 +948,27 @@ impl ClipboardDb for SqliteClipboardDb { let contents: Vec = row .get(1) .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; - if contents.windows(query.len()).any(|w| w == query.as_bytes()) { + + let searchable_contents = if contents + .starts_with(b"age-encryption.org/v1") + { + match decrypt_data(&contents) { + Ok(decrypted) => decrypted, + Err(e) => { + warn!( + "Skipping entry {id} during delete_query: decryption failed: {e}" + ); + continue; + }, + } + } else { + contents + }; + + if searchable_contents + .windows(query.len()) + .any(|w| w == query.as_bytes()) + { self .conn .execute("DELETE FROM clipboard WHERE id = ?1", params![id]) @@ -963,7 +1033,13 @@ impl ClipboardDb for SqliteClipboardDb { } } - Ok((id, contents, mime)) + let decrypted_contents = if contents.starts_with(b"age-encryption.org/v1") { + decrypt_data(&contents)? + } else { + contents + }; + + Ok((id, decrypted_contents, mime)) } } @@ -1038,7 +1114,21 @@ impl SqliteClipboardDb { let mime: Option = row .get(2) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let preview = preview_entry(&contents, mime.as_deref(), preview_width); + let decrypted_contents = if contents.starts_with(b"age-encryption.org/v1") + { + match decrypt_data(&contents) { + Ok(decrypted) => decrypted, + Err(e) => { + debug!("Skipping entry {id} in TUI window: decryption failed: {e}"); + continue; + }, + } + } else { + contents + }; + + let preview = + preview_entry(&decrypted_contents, mime.as_deref(), preview_width); let mime_str = mime.unwrap_or_default(); window.push((id, preview, mime_str)); } @@ -1155,10 +1245,23 @@ impl SqliteClipboardDb { /// changes made after daemon startup. Regex compilation is cached by /// pattern to avoid recompilation. fn load_sensitive_regex() -> Option { + use std::process::Command; + // 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"); + let pattern = if let Ok(cred_dir) = env::var("CREDENTIALS_DIRECTORY") { + let file = format!("{cred_dir}/clipboard_filter"); fs::read_to_string(&file).ok().map(|s| s.trim().to_string()) + } else if let Ok(cmd) = env::var("STASH_SENSITIVE_REGEX_COMMAND") { + Command::new("sh") + .args(["-c", &cmd]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + } else if let Ok(file_path) = env::var("STASH_SENSITIVE_REGEX_FILE") { + fs::read_to_string(&file_path) + .ok() + .map(|s| s.trim().to_string()) } else { env::var("STASH_SENSITIVE_REGEX").ok() }?; @@ -1185,6 +1288,61 @@ fn load_sensitive_regex() -> Option { }) } +fn load_encryption_passphrase() -> Option { + use std::process::Command; + + static PASSPHRASE_CACHE: OnceLock = + OnceLock::new(); + + if let Some(cached) = PASSPHRASE_CACHE.get() { + return Some(cached.clone()); + } + + let passphrase = if let Ok(cred_dir) = env::var("CREDENTIALS_DIRECTORY") { + let file = format!("{cred_dir}/stash_encryption_passphrase"); + fs::read_to_string(&file).ok().map(|s| s.trim().to_owned()) + } else if let Ok(cmd) = env::var("STASH_ENCRYPTION_PASSPHRASE_COMMAND") { + Command::new("sh") + .args(["-c", &cmd]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()) + } else if let Ok(file_path) = env::var("STASH_ENCRYPTION_PASSPHRASE_FILE") { + fs::read_to_string(&file_path) + .ok() + .map(|s| s.trim().to_owned()) + } else { + env::var("STASH_ENCRYPTION_PASSPHRASE").ok() + }?; + + let secret = age::secrecy::SecretString::from(passphrase); + let _ = PASSPHRASE_CACHE.set(secret.clone()); + Some(secret) +} + +fn encrypt_data(data: &[u8]) -> Result, StashError> { + let passphrase = load_encryption_passphrase().ok_or_else(|| { + StashError::Encryption("No encryption passphrase configured".into()) + })?; + + let recipient = age::scrypt::Recipient::new(passphrase); + let encrypted = age::encrypt(&recipient, data) + .map_err(|e| StashError::Encryption(e.to_string().into()))?; + Ok(encrypted) +} + +fn decrypt_data(encrypted: &[u8]) -> Result, StashError> { + let passphrase = load_encryption_passphrase().ok_or_else(|| { + StashError::Decryption("No encryption passphrase configured".into()) + })?; + + let identity = age::scrypt::Identity::new(passphrase); + let decrypted = age::decrypt(&identity, encrypted) + .map_err(|e| StashError::Decryption(e.to_string().into()))?; + Ok(decrypted) +} + pub fn extract_id(input: &str) -> Result { let id_str = input.split('\t').next().unwrap_or(""); id_str.parse().map_err(|_| "invalid id") From d78cbd674139f42106202b7153712f769f03bed1 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Wed, 1 Apr 2026 11:34:30 +0300 Subject: [PATCH 02/13] docs: document new regex file and command options & encrypted db Signed-off-by: NotAShelf Change-Id: I552f0c891a5d3b3c8b4944189f9ee35b6a6a6964 --- README.md | 119 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 101 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index d29b4f4..12b0559 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,10 @@
- Lightweight & feature-rich Wayland clipboard "manager" with fast persistent history and - robust multi-media support. Stores and previews clipboard entries (text, images) - on the clipboard with a neat TUI and advanced scripting capabilities. + Lightweight & feature-rich Wayland clipboard "manager" with fast persistent + history and robust multi-media support. Stores and previews clipboard + entries (text, images) on the clipboard with a neat TUI and advanced scripting + capabilities.
@@ -52,6 +53,7 @@ with many features such as but not necessarily limited to: - 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) +- Database encryption using age (see below) on top of the existing features of Cliphist, which are as follows: @@ -357,21 +359,38 @@ sensitive pattern, using a regular expression. This is useful for preventing accidental storage of secrets, passwords, or other sensitive data. You don't want sensitive data ending up in your persistent clipboard, right? -The filter can be configured in one of three ways, as part of two separate -features. +The filter can be configured in several ways, as part of two separate features. #### Clipboard Filtering by Entry Regex -This can be configured in one of two ways. You can use the **environment -variable** `STASTH_SENSITIVE_REGEX` to a valid regex pattern, and if the +This can be configured in one of several ways. You can use the **environment +variable** `STASH_SENSITIVE_REGEX` to a valid regex pattern, and if the clipboard text matches the regex it will not be stored. This can be used for trivial secrets such as but not limited to GitHub tokens or secrets that follow a rule, e.g. a prefix. You would typically set this in your `~/.bashrc` or similar but in some cases this might be a security flaw. -The safer alternative to this is using **Systemd LoadCrediental**. If Stash is -running as a Systemd service, you can provide a regex pattern using a crediental -file. For example, add to your `stash.service`: +The _less-insecure_ [^1] alternative to this is using +`STASH_SENSITIVE_REGEX_FILE` to read the regex from a file path. This is useful +for NixOS secrets managers like agenix or sops-nix. + +```bash +# You can set this in your configuration with `environment.sessionVariables` +# or similar, pointing to the *decryption path*. +$ export STASH_SENSITIVE_REGEX_FILE=/run/secrets/stash/clipboard_filter +``` + +Or use `STASH_SENSITIVE_REGEX_COMMAND` to execute a command that returns the +regex pattern. This works well with password managers: + +```bash +# Stash will execute the command and consume the result +$ export STASH_SENSITIVE_REGEX_COMMAND="pass show stash/clipboard-filter" +``` + +The safest option is using **Systemd LoadCredential**. If Stash is running as a +Systemd service, you can provide a regex pattern using a credential file. For +example, add to your `stash.service`: ```dosini LoadCredential=clipboard_filter:/etc/stash/clipboard_filter @@ -382,9 +401,9 @@ quotes). This is done automatically in the [vendored Systemd service](./contrib/stash.service). Remember to set the appropriate file permissions if using this option. -The service will check the credential file first, then the environment variable. -If a clipboard entry matches the regex, it will be skipped and a warning will be -logged. +The service will check the credential file first, then the command, then the +file path, then the environment variable. If a clipboard entry matches the +regex, it will be skipped and a warning will be logged. > [!TIP] > **Example regex to block common password patterns**: @@ -393,17 +412,21 @@ logged. > > For security reasons, you are recommended to use the regex only for generic > tokens that follow a specific rule, for example a generic prefix or suffix. +> For blocking entries from applications that emit sensitive data, such as +> password managers, filter by application class instead. #### Clipboard Filtering by Application Class Stash allows blocking an entry from the persistent history if it has been copied from certain applications. This depends on the `use-toplevel` feature flag and uses the the `wlr-foreign-toplevel-management-v1` protocol for precise focus -detection. While this feature flag is enabled (the default) you may use -`--excluded-apps` in, e.g., `stash watch` or set the `STASH_EXCLUDED_APPS` -environment variable to block entries from persisting in the database if they -are coming from your password manager for example. The entry is still copied to -the clipboard, but it will never be put inside the database. +detection. + +While this feature flag is enabled (the default) you may use `--excluded-apps` +in, e.g., `stash watch` or set the `STASH_EXCLUDED_APPS` environment variable to +block entries from persisting in the database if they are coming from your +password manager for example. The entry is still copied to the clipboard, but it +will never be put inside the database. This is a more robust alternative to using the regex method above, since you likely do not want to catch your passwords with a regex. Simply pass your @@ -516,6 +539,66 @@ 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. +### Database Encryption + +Stash supports encrypting clipboard entries at rest using the +[age](https://age-encryption.org/) encryption format. This provides protection +for sensitive data stored in the database. + +> [!WARNING] +> If you enable encryption after already having entries in the database, the +> existing entries will remain unencrypted. Only new entries added after +> configuring encryption will be encrypted. To encrypt existing entries, you +> would need to wipe the database and re-copy your clipboard contents. + +Encryption is **opt-in** and only takes effect when a passphrase is configured. +When one _is_ configured, all new clipboard entries are encrypted before being +stored in the database. Entries without encryption configured are stored in +plaintext instead. + +Encrypted entries are detected by the `age-encryption.org/v1` header and +decrypted automatically on retrieval. Search operations (e.g., +`stash delete --type query`) decrypt entries on-the-fly to match queries; +entries that fail decryption are skipped with a warning + +#### Configuration + +Encryption is configured using a passphrase, which can be provided in one of +several ways. The simplest is using the **environment variable** +`STASH_ENCRYPTION_PASSPHRASE`: + +```bash +export STASH_ENCRYPTION_PASSPHRASE="your-secure-passphrase" +``` + +Alternatively, you can use `STASH_ENCRYPTION_PASSPHRASE_FILE` to read the +passphrase from a file path. This is useful for NixOS secrets managers: + +```bash +export STASH_ENCRYPTION_PASSPHRASE_FILE=/run/secrets/stash/encryption_passphrase +``` + +Or use `STASH_ENCRYPTION_PASSPHRASE_COMMAND` to execute a command that returns +the passphrase. This works well with password managers: + +```bash +export STASH_ENCRYPTION_PASSPHRASE_COMMAND="pass show stash/encryption-key" +``` + +The safest option is using **Systemd LoadCredential**. If Stash is running as a +Systemd service, you can provide the passphrase using a credential file: + +```dosini +LoadCredential=stash_encryption_passphrase:/etc/stash/encryption_passphrase +``` + +This is done automatically in the +[vendored Systemd service](./contrib/stash.service). + +> [!TIP] +> When using encryption, make sure to back up your passphrase. Without it, you +> will not be able to recover encrypted entries. + ### Entry Expiration Stash supports time-to-live (TTL) for clipboard entries. When an entry's From d01390139665faeadf60488f8f9f3c008b59943a Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 4 Apr 2026 23:08:54 +0300 Subject: [PATCH 03/13] db: *warn* the users when encrypted entries cannot be decrypted Signed-off-by: NotAShelf Change-Id: I1cfe9994b640cdf571007b5c52b0a2bc6a6a6964 --- src/db/mod.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index 60d6a0b..4bc2cd1 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -606,9 +606,7 @@ impl SqliteClipboardDb { match decrypt_data(&contents) { Ok(decrypted) => decrypted, Err(e) => { - debug!( - "Skipping entry {id} in JSON output: decryption failed: {e}" - ); + warn!("Skipping entry {id} in JSON output: decryption failed: {e}"); continue; }, } @@ -872,7 +870,7 @@ impl ClipboardDb for SqliteClipboardDb { match decrypt_data(&contents) { Ok(decrypted) => decrypted, Err(e) => { - debug!("skipping entry {id}: decryption failed: {e}"); + warn!("skipping entry {id}: decryption failed: {e}"); continue; }, } @@ -1119,7 +1117,7 @@ impl SqliteClipboardDb { match decrypt_data(&contents) { Ok(decrypted) => decrypted, Err(e) => { - debug!("Skipping entry {id} in TUI window: decryption failed: {e}"); + warn!("Skipping entry {id} in TUI window: decryption failed: {e}"); continue; }, } From 7866af166e1ea2063fea98c2d8efd62ca8cfa60b Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sat, 4 Apr 2026 23:15:08 +0300 Subject: [PATCH 04/13] db: use a single-byte marker prefix for encryption detection Signed-off-by: NotAShelf Change-Id: I8330fcde76dc983569f7c6bb859b62e06a6a6964 --- src/db/mod.rs | 33 +++++++++++++++++---------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index 4bc2cd1..c0d24d8 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -606,7 +606,7 @@ impl SqliteClipboardDb { match decrypt_data(&contents) { Ok(decrypted) => decrypted, Err(e) => { - warn!("Skipping entry {id} in JSON output: decryption failed: {e}"); + warn!("skipping entry {id}: decryption failed: {e}"); continue; }, } @@ -865,8 +865,7 @@ impl ClipboardDb for SqliteClipboardDb { let mime: Option = row .get(2) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - - let preview_contents = if contents.starts_with(b"age-encryption.org/v1") { + let preview_contents = if contents.starts_with(&[0x01u8]) { match decrypt_data(&contents) { Ok(decrypted) => decrypted, Err(e) => { @@ -914,7 +913,7 @@ impl ClipboardDb for SqliteClipboardDb { ) .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; - let decrypted_contents = if contents.starts_with(b"age-encryption.org/v1") { + let decrypted_contents = if contents.starts_with(&[0x01u8]) { decrypt_data(&contents)? } else { contents @@ -947,15 +946,11 @@ impl ClipboardDb for SqliteClipboardDb { .get(1) .map_err(|e| StashError::QueryDelete(e.to_string().into()))?; - let searchable_contents = if contents - .starts_with(b"age-encryption.org/v1") - { + let searchable_contents = if contents.starts_with(&[0x01u8]) { match decrypt_data(&contents) { Ok(decrypted) => decrypted, Err(e) => { - warn!( - "Skipping entry {id} during delete_query: decryption failed: {e}" - ); + warn!("skipping entry {id}: decryption failed: {e}"); continue; }, } @@ -1031,7 +1026,7 @@ impl ClipboardDb for SqliteClipboardDb { } } - let decrypted_contents = if contents.starts_with(b"age-encryption.org/v1") { + let decrypted_contents = if contents.starts_with(&[0x01u8]) { decrypt_data(&contents)? } else { contents @@ -1112,12 +1107,11 @@ impl SqliteClipboardDb { let mime: Option = row .get(2) .map_err(|e| StashError::ListDecode(e.to_string().into()))?; - let decrypted_contents = if contents.starts_with(b"age-encryption.org/v1") - { + let decrypted_contents = if contents.starts_with(&[0x01u8]) { match decrypt_data(&contents) { Ok(decrypted) => decrypted, Err(e) => { - warn!("Skipping entry {id} in TUI window: decryption failed: {e}"); + warn!("skipping entry {id}: decryption failed: {e}"); continue; }, } @@ -1327,7 +1321,11 @@ fn encrypt_data(data: &[u8]) -> Result, StashError> { let recipient = age::scrypt::Recipient::new(passphrase); let encrypted = age::encrypt(&recipient, data) .map_err(|e| StashError::Encryption(e.to_string().into()))?; - Ok(encrypted) + // Prepend marker byte to identify our encrypted data + let mut result = Vec::with_capacity(1 + encrypted.len()); + result.push(0x01u8); + result.extend_from_slice(&encrypted); + Ok(result) } fn decrypt_data(encrypted: &[u8]) -> Result, StashError> { @@ -1335,8 +1333,11 @@ fn decrypt_data(encrypted: &[u8]) -> Result, StashError> { StashError::Decryption("No encryption passphrase configured".into()) })?; + // Strip our marker byte if present + let data_to_decrypt = encrypted.strip_prefix(&[0x01u8]).unwrap_or(encrypted); + let identity = age::scrypt::Identity::new(passphrase); - let decrypted = age::decrypt(&identity, encrypted) + let decrypted = age::decrypt(&identity, data_to_decrypt) .map_err(|e| StashError::Decryption(e.to_string().into()))?; Ok(decrypted) } From cd692ba00247cfebc1686a202ffd1505dfb95faf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 20:00:34 +0000 Subject: [PATCH 05/13] build(deps): bump softprops/action-gh-release from 2 to 3 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: '3' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 62bfdd3..62cfe82 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -40,7 +40,7 @@ jobs: steps: - name: Create Release id: create_release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: draft: false prerelease: false @@ -98,7 +98,7 @@ jobs: cp target/${{ matrix.target }}/release/stash ${{ matrix.name }} - name: Upload Release Asset - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: files: ${{ matrix.name }} @@ -120,7 +120,7 @@ jobs: sha256sum stash-* > SHA256SUMS - name: Upload Checksums - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: token: ${{ secrets.GITHUB_TOKEN }} files: SHA256SUMS From 7498d688c9247c91976fb86b354656aac59029af Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:57:54 +0000 Subject: [PATCH 06/13] build(deps): bump libc from 0.2.184 to 0.2.185 Bumps [libc](https://github.com/rust-lang/libc) from 0.2.184 to 0.2.185. - [Release notes](https://github.com/rust-lang/libc/releases) - [Changelog](https://github.com/rust-lang/libc/blob/0.2.185/CHANGELOG.md) - [Commits](https://github.com/rust-lang/libc/compare/0.2.184...0.2.185) --- updated-dependencies: - dependency-name: libc dependency-version: 0.2.185 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3c753bf..113cb91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1401,9 +1401,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libredox" diff --git a/Cargo.toml b/Cargo.toml index e3467ae..2aae609 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ env_logger = "0.11.10" humantime = "2.3.0" imagesize = "0.14.0" inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] } -libc = "0.2.184" +libc = "0.2.185" log = "0.4.29" mime-sniffer = "0.1.3" notify-rust = { version = "4.14.0", optional = true } From 0ebf62fa5db64d49e1d0d19c9bdd6cf983d8580f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:57:41 +0000 Subject: [PATCH 07/13] build(deps): bump crane from `7cf72d9` to `28462d6` Bumps [crane](https://github.com/ipetkov/crane) from `7cf72d9` to `28462d6`. - [Release notes](https://github.com/ipetkov/crane/releases) - [Commits](https://github.com/ipetkov/crane/compare/7cf72d978629469c4bd4206b95c402514c1f6000...28462d6d55c33206ffa5a56c7907ca3125ed788f) --- updated-dependencies: - dependency-name: crane dependency-version: 28462d6d55c33206ffa5a56c7907ca3125ed788f dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index e50ffba..d437322 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1775839657, - "narHash": "sha256-SPm9ck7jh3Un9nwPuMGbRU04UroFmOHjLP56T10MOeM=", + "lastModified": 1776635034, + "narHash": "sha256-OEOJrT3ZfwbChzODfIH4GzlNTtOFuZFWPtW7jIeR8xU=", "owner": "ipetkov", "repo": "crane", - "rev": "7cf72d978629469c4bd4206b95c402514c1f6000", + "rev": "dc7496d8ea6e526b1254b55d09b966e94673750f", "type": "github" }, "original": { From 4d4d359bcfa936493ce2e526fe3e6af98d31557d Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Mon, 27 Apr 2026 20:54:05 +0300 Subject: [PATCH 08/13] docs: fix `cargo install` link in README Fixes #90 Signed-off-by: NotAShelf Change-Id: I41c2ae0fbf1992478c6409864f9bdef66a6a6964 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d29b4f4..775f618 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ releases are made when a version gets tagged, and are available under - Build and install from source with Cargo: ```bash - cargo install stash --locked + cargo install stash-clipboard --locked ``` Additionally, you may get Stash from source via `cargo install` using From 4055adb896ff0c6b5628a61787dbac30af77addd Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 3 May 2026 16:21:38 +0300 Subject: [PATCH 09/13] db: refactor migrations; fix LRU eviction logic Signed-off-by: NotAShelf Change-Id: I594551967b392a52bdf95db41ccf40816a6a6964 --- src/db/mod.rs | 500 +++++++++++++++++++++++++----------------- src/db/nonblocking.rs | 31 +-- 2 files changed, 300 insertions(+), 231 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index 65eb097..8ff5f8d 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -338,215 +338,87 @@ impl SqliteClipboardDb { if schema_version == 0 { tx.execute_batch( "CREATE TABLE IF NOT EXISTS clipboard ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - contents BLOB NOT NULL, - mime TEXT - );", + id INTEGER PRIMARY KEY AUTOINCREMENT, + contents BLOB NOT NULL, + mime TEXT + );", ) - .map_err(|e| { - StashError::Store( - format!("Failed to create clipboard table: {e}").into(), - ) - })?; - - tx.execute("PRAGMA user_version = 1", []).map_err(|e| { - StashError::Store(format!("Failed to set schema version: {e}").into()) - })?; + .map_err(migration_err)?; + tx.pragma_update(None, "user_version", 1i64) + .map_err(migration_err)?; } - // Add content_hash column if it doesn't exist. Migration MUST be done to - // avoid breaking existing installations. if schema_version < 2 { - let has_content_hash: bool = tx - .query_row( - "SELECT sql FROM sqlite_master WHERE type='table' AND \ - name='clipboard'", - [], - |row| { - let sql: String = row.get(0)?; - Ok(sql.to_lowercase().contains("content_hash")) - }, - ) - .unwrap_or(false); - - if !has_content_hash { + if !column_exists(&tx, "content_hash") { tx.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", []) - .map_err(|e| { - StashError::Store( - format!("Failed to add content_hash column: {e}").into(), - ) - })?; + .map_err(migration_err)?; } - - // Create index for content_hash if it doesn't exist tx.execute( "CREATE INDEX IF NOT EXISTS idx_content_hash ON \ clipboard(content_hash)", [], ) - .map_err(|e| { - StashError::Store( - format!("Failed to create content_hash index: {e}").into(), - ) - })?; - - tx.execute("PRAGMA user_version = 2", []).map_err(|e| { - StashError::Store(format!("Failed to set schema version: {e}").into()) - })?; + .map_err(migration_err)?; + tx.pragma_update(None, "user_version", 2i64) + .map_err(migration_err)?; } - // Add last_accessed column if it doesn't exist if schema_version < 3 { - let has_last_accessed: bool = tx - .query_row( - "SELECT sql FROM sqlite_master WHERE type='table' AND \ - name='clipboard'", - [], - |row| { - let sql: String = row.get(0)?; - Ok(sql.to_lowercase().contains("last_accessed")) - }, - ) - .unwrap_or(false); - - if !has_last_accessed { + if !column_exists(&tx, "last_accessed") { tx.execute("ALTER TABLE clipboard ADD COLUMN last_accessed INTEGER", [ ]) - .map_err(|e| { - StashError::Store( - format!("Failed to add last_accessed column: {e}").into(), - ) - })?; + .map_err(migration_err)?; } - - // Create index for last_accessed if it doesn't exist tx.execute( "CREATE INDEX IF NOT EXISTS idx_last_accessed ON \ clipboard(last_accessed)", [], ) - .map_err(|e| { - StashError::Store( - format!("Failed to create last_accessed index: {e}").into(), - ) - })?; - - tx.execute("PRAGMA user_version = 3", []).map_err(|e| { - StashError::Store(format!("Failed to set schema version: {e}").into()) - })?; + .map_err(migration_err)?; + tx.pragma_update(None, "user_version", 3i64) + .map_err(migration_err)?; } - // 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 { + if !column_exists(&tx, "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(), - ) - })?; + .map_err(migration_err)?; } - - // 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()) - })?; + .map_err(migration_err)?; + tx.pragma_update(None, "user_version", 4i64) + .map_err(migration_err)?; } - // 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 { + if !column_exists(&tx, "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(), - ) - })?; + .map_err(migration_err)?; } - - // 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()) - })?; + .map_err(migration_err)?; + tx.pragma_update(None, "user_version", 5i64) + .map_err(migration_err)?; } - // Add mime_types column if it doesn't exist (v6) - // Stores all MIME types offered by the source application as JSON array. - // Needed for clipboard persistence to re-offer the same types. if schema_version < 6 { - let has_mime_types: bool = tx - .query_row( - "SELECT sql FROM sqlite_master WHERE type='table' AND \ - name='clipboard'", - [], - |row| { - let sql: String = row.get(0)?; - Ok(sql.to_lowercase().contains("mime_types")) - }, - ) - .unwrap_or(false); - - if !has_mime_types { + if !column_exists(&tx, "mime_types") { tx.execute("ALTER TABLE clipboard ADD COLUMN mime_types TEXT", []) - .map_err(|e| { - StashError::Store( - format!("Failed to add mime_types column: {e}").into(), - ) - })?; + .map_err(migration_err)?; } - - tx.execute("PRAGMA user_version = 6", []).map_err(|e| { - StashError::Store(format!("Failed to set schema version: {e}").into()) - })?; + tx.pragma_update(None, "user_version", 6i64) + .map_err(migration_err)?; } tx.commit().map_err(|e| { @@ -555,14 +427,29 @@ impl SqliteClipboardDb { ) })?; - // Initialize Wayland state in background thread. This will be used to track - // focused window state. #[cfg(feature = "use-toplevel")] crate::wayland::init_wayland_state(); Ok(Self { conn, db_path }) } } +/// Check whether `column` exists in the `clipboard` table. +fn column_exists(conn: &Connection, column: &str) -> bool { + conn + .prepare("PRAGMA table_info(clipboard)") + .and_then(|mut stmt| { + stmt + .query_map([], |row| row.get::<_, String>(1)) + .map(|rows| rows.filter_map(Result::ok).any(|c| c == column)) + }) + .unwrap_or(false) +} + +/// Convert a rusqlite error into [`StashError::Store`]. +fn migration_err(e: rusqlite::Error) -> StashError { + StashError::Store(e.to_string().into()) +} + impl SqliteClipboardDb { pub fn list_json( &self, @@ -765,7 +652,7 @@ impl ClipboardDb for SqliteClipboardDb { .conn .execute( "DELETE FROM clipboard WHERE id IN (SELECT id FROM clipboard ORDER \ - BY id ASC LIMIT ?1)", + BY COALESCE(last_accessed, 0) ASC, id ASC LIMIT ?1)", params![i64::try_from(to_delete).unwrap_or(i64::MAX)], ) .map_err(|e| StashError::Trim(e.to_string().into()))?; @@ -928,40 +815,23 @@ impl ClipboardDb for SqliteClipboardDb { &self, id: i64, ) -> Result<(i64, Vec, Option), StashError> { - let (contents, mime, content_hash): (Vec, Option, Option) = - self - .conn - .query_row( - "SELECT contents, mime, content_hash FROM clipboard WHERE id = ?1", - params![id], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; + let (contents, mime): (Vec, Option) = self + .conn + .query_row( + "SELECT contents, mime FROM clipboard WHERE id = ?1", + params![id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; - if let Some(hash) = content_hash { - let most_recent_id: Option = self - .conn - .query_row( - "SELECT id FROM clipboard WHERE content_hash = ?1 AND last_accessed \ - = (SELECT MAX(last_accessed) FROM clipboard WHERE content_hash = \ - ?1)", - params![hash], - |row| row.get(0), - ) - .optional() - .map_err(|e| StashError::DecodeGet(e.to_string().into()))?; - - if most_recent_id != Some(id) { - self - .conn - .execute( - "UPDATE clipboard SET last_accessed = CAST(strftime('%s', 'now') \ - AS INTEGER) WHERE id = ?1", - params![id], - ) - .map_err(|e| StashError::Store(e.to_string().into()))?; - } - } + self + .conn + .execute( + "UPDATE clipboard SET last_accessed = CAST(strftime('%s', 'now') AS \ + INTEGER) WHERE id = ?1", + params![id], + ) + .map_err(|e| StashError::Store(e.to_string().into()))?; Ok((id, contents, mime)) } @@ -1253,17 +1123,10 @@ pub fn size_str(size: usize) -> String { /// Check if clipboard should be excluded based on excluded apps configuration. /// Uses timing correlation and focused window detection to identify source app. fn should_exclude_by_app(excluded_apps: Option<&[String]>) -> bool { - let excluded = match excluded_apps { - Some(apps) if !apps.is_empty() => apps, - _ => return false, - }; - - // Try multiple detection strategies - if detect_excluded_app_activity(excluded) { - return true; + match excluded_apps { + Some(apps) if !apps.is_empty() => detect_excluded_app_activity(apps), + _ => false, } - - false } /// Detect if clipboard likely came from an excluded app using multiple @@ -2238,4 +2101,231 @@ mod tests { "Regex loading should be deterministic" ); } + + #[test] + fn test_migration_from_v3() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let db_path = temp_dir.path().join("test_v3.db"); + let conn = Connection::open(&db_path).expect("open"); + conn + .execute_batch( + "CREATE TABLE clipboard ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contents BLOB NOT NULL, + mime TEXT, + content_hash INTEGER, + last_accessed INTEGER + ); + INSERT INTO clipboard (contents, mime, content_hash) VALUES \ + (x'010203', 'text/plain', 12345);", + ) + .expect("create v3 schema"); + conn + .pragma_update(None, "user_version", 3i64) + .expect("set version"); + + let db = SqliteClipboardDb::new(conn, db_path).expect("migrate"); + assert_eq!(get_schema_version(&db.conn).expect("version"), 6); + assert!(table_column_exists(&db.conn, "clipboard", "expires_at")); + assert!(table_column_exists(&db.conn, "clipboard", "is_expired")); + assert!(table_column_exists(&db.conn, "clipboard", "mime_types")); + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0)) + .expect("count"); + assert_eq!(count, 1, "existing data must survive migration"); + } + + #[test] + fn test_migration_from_v4() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let db_path = temp_dir.path().join("test_v4.db"); + let conn = Connection::open(&db_path).expect("open"); + conn + .execute_batch( + "CREATE TABLE clipboard ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contents BLOB NOT NULL, + mime TEXT, + content_hash INTEGER, + last_accessed INTEGER, + expires_at REAL + ); + INSERT INTO clipboard (contents, mime) VALUES (x'aabbcc', \ + 'image/png');", + ) + .expect("create v4 schema"); + conn + .pragma_update(None, "user_version", 4i64) + .expect("set version"); + + let db = SqliteClipboardDb::new(conn, db_path).expect("migrate"); + assert_eq!(get_schema_version(&db.conn).expect("version"), 6); + assert!(table_column_exists(&db.conn, "clipboard", "is_expired")); + assert!(table_column_exists(&db.conn, "clipboard", "mime_types")); + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0)) + .expect("count"); + assert_eq!(count, 1, "existing data must survive migration"); + } + + #[test] + fn test_migration_from_v5() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let db_path = temp_dir.path().join("test_v5.db"); + let conn = Connection::open(&db_path).expect("open"); + conn + .execute_batch( + "CREATE TABLE clipboard ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contents BLOB NOT NULL, + mime TEXT, + content_hash INTEGER, + last_accessed INTEGER, + expires_at REAL, + is_expired INTEGER DEFAULT 0 + ); + INSERT INTO clipboard (contents, mime) VALUES (x'deadbeef', \ + 'application/octet-stream');", + ) + .expect("create v5 schema"); + conn + .pragma_update(None, "user_version", 5i64) + .expect("set version"); + + let db = SqliteClipboardDb::new(conn, db_path).expect("migrate"); + assert_eq!(get_schema_version(&db.conn).expect("version"), 6); + assert!(table_column_exists(&db.conn, "clipboard", "mime_types")); + } + + /// Pre-migration entries (NULL content_hash) must have last_accessed + /// updated when accessed via copy_entry. + #[test] + fn test_copy_entry_updates_last_accessed_null_hash() { + let db = test_db(); + db.conn + .execute( + "INSERT INTO clipboard (contents, mime, content_hash, last_accessed) \ + VALUES (?1, 'text/plain', NULL, 0)", + rusqlite::params![b"legacy data".as_ref()], + ) + .expect("insert null-hash entry"); + let id: i64 = db + .conn + .query_row("SELECT last_insert_rowid()", [], |r| r.get(0)) + .expect("id"); + + db.copy_entry(id).expect("copy"); + + let last_accessed: i64 = db + .conn + .query_row( + "SELECT last_accessed FROM clipboard WHERE id = ?1", + [id], + |r| r.get(0), + ) + .expect("last_accessed"); + assert!( + last_accessed > 0, + "last_accessed must be updated for null-hash entries" + ); + } + + /// trim_db must evict the least-recently-accessed entries, not the + /// lowest-id entries. + #[test] + fn test_trim_db_evicts_lru_not_oldest() { + let db = test_db(); + let mut ids = Vec::new(); + for i in 0..5u8 { + let id = db + .store_entry( + std::io::Cursor::new(vec![i; 4]), + 0, + 100, + None, + None, + DEFAULT_MAX_ENTRY_SIZE, + None, + None, + ) + .expect("store"); + ids.push(id); + } + + // Zero out all timestamps so copy_entry produces a strictly higher value. + db.conn + .execute("UPDATE clipboard SET last_accessed = 0", []) + .expect("reset timestamps"); + + // Touch the first (oldest by id) entry to make it most-recently-used. + db.copy_entry(ids[0]).expect("copy"); + + // Trim to 4; ids[0] was just accessed and must survive. + db.trim_db(4).expect("trim"); + + let still_there: i64 = db + .conn + .query_row( + "SELECT COUNT(*) FROM clipboard WHERE id = ?1", + [ids[0]], + |r| r.get(0), + ) + .expect("count"); + assert_eq!( + still_there, 1, + "recently accessed entry must not be evicted" + ); + + let total: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM clipboard", [], |r| r.get(0)) + .expect("total"); + assert_eq!(total, 4); + } + + /// All new columns must be NULL for entries created before their respective + /// schema versions. + #[test] + fn test_migration_null_columns_for_legacy_entries() { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let db_path = temp_dir.path().join("test_legacy.db"); + { + let conn = Connection::open(&db_path).expect("open"); + conn + .execute_batch( + "CREATE TABLE clipboard ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contents BLOB NOT NULL, + mime TEXT + ); + INSERT INTO clipboard (contents, mime) VALUES (x'68656c6c6f', \ + 'text/plain');", + ) + .expect("create v0 schema"); + } + + let conn = Connection::open(&db_path).expect("open"); + let db = SqliteClipboardDb::new(conn, db_path).expect("migrate"); + + let (hash, accessed, expires): (Option, Option, Option) = db + .conn + .query_row( + "SELECT content_hash, last_accessed, expires_at FROM clipboard WHERE \ + id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .expect("query"); + assert!(hash.is_none(), "content_hash must be NULL for pre-v2 entry"); + assert!( + accessed.is_none(), + "last_accessed must be NULL for pre-v3 entry" + ); + assert!( + expires.is_none(), + "expires_at must be NULL for pre-v4 entry" + ); + } } diff --git a/src/db/nonblocking.rs b/src/db/nonblocking.rs index d62e0dd..d6a00cd 100644 --- a/src/db/nonblocking.rs +++ b/src/db/nonblocking.rs @@ -8,6 +8,7 @@ use crate::db::{ClipboardDb, SqliteClipboardDb, StashError}; /// 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. +#[derive(Clone)] pub struct AsyncClipboardDb { db_path: PathBuf, } @@ -72,25 +73,11 @@ impl AsyncClipboardDb { 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() + stmt + .query_map([], |row| Ok((row.get::<_, f64>(0)?, row.get::<_, i64>(1)?))) .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) + .collect::, _>>() + .map_err(|e| StashError::ListDecode(e.to_string().into())) }) .await } @@ -136,14 +123,6 @@ impl AsyncClipboardDb { } } -impl Clone for AsyncClipboardDb { - fn clone(&self) -> Self { - Self { - db_path: self.db_path.clone(), - } - } -} - #[cfg(test)] mod tests { use std::{collections::HashSet, hash::Hasher}; From cf207d0a3d4db5f4ac937dbd4d78a0a26ffd2d66 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 3 May 2026 17:19:24 +0300 Subject: [PATCH 10/13] clipboard: downgrade error logging to debug for expected failures Signed-off-by: NotAShelf Change-Id: Ic1cca0d0212b9b3611da8ca3f9c6fb326a6a6964 --- src/clipboard/persist.rs | 3 +-- src/multicall/wl_copy.rs | 3 +-- src/multicall/wl_paste.rs | 15 +++++++++------ 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/clipboard/persist.rs b/src/clipboard/persist.rs index a677f50..f5312a7 100644 --- a/src/clipboard/persist.rs +++ b/src/clipboard/persist.rs @@ -196,8 +196,7 @@ fn serve_clipboard_child(prepared: PreparedCopy) { }, Err(e) => { - log::error!("clipboard persistence: serve failed: {e}"); - exit(1); + log::debug!("clipboard persistence: serve ended: {e}"); }, } } diff --git a/src/multicall/wl_copy.rs b/src/multicall/wl_copy.rs index 3794420..7948c68 100644 --- a/src/multicall/wl_copy.rs +++ b/src/multicall/wl_copy.rs @@ -222,8 +222,7 @@ fn fork_and_serve(prepared_copy: wl_clipboard_rs::copy::PreparedCopy) { 0 => { // Child process - serve clipboard content if let Err(e) = prepared_copy.serve() { - log::error!("background clipboard service failed: {e}"); - std::process::exit(1); + log::debug!("background clipboard service ended: {e}"); } std::process::exit(0); }, diff --git a/src/multicall/wl_paste.rs b/src/multicall/wl_paste.rs index 5daa1fd..5a893d6 100644 --- a/src/multicall/wl_paste.rs +++ b/src/multicall/wl_paste.rs @@ -456,6 +456,9 @@ fn handle_regular_paste( bail!("no content available and --no-newline specified"); } if let Err(e) = out.write_all(&buf) { + if e.kind() == io::ErrorKind::BrokenPipe { + return Ok(()); + } bail!("failed to write to stdout: {e}"); } @@ -471,12 +474,12 @@ fn handle_regular_paste( || types == "application/x-sh" }; - if !args.no_newline - && is_text_content - && !buf.ends_with(b"\n") - && let Err(e) = out.write_all(b"\n") - { - bail!("failed to write newline to stdout: {e}"); + if !args.no_newline && is_text_content && !buf.ends_with(b"\n") { + if let Err(e) = out.write_all(b"\n") { + if e.kind() != io::ErrorKind::BrokenPipe { + bail!("failed to write newline to stdout: {e}"); + } + } } }, Err(PasteError::NoSeats) => { From 9217b327983691dd1a8bba185270d0d0e2e03410 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 3 May 2026 17:19:32 +0300 Subject: [PATCH 11/13] commands: fix MIME fallback in TUI; improve watch logging Signed-off-by: NotAShelf Change-Id: I67d0486ca9719b334957ff3868da3f0c6a6a6964 --- src/commands/list.rs | 2 +- src/commands/watch.rs | 14 +++++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/commands/list.rs b/src/commands/list.rs index b3041e5..369949c 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -698,7 +698,7 @@ impl SqliteClipboardDb { 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, + None => MimeType::Autodetect, }; let copy_result = opts .copy(Source::Bytes(contents.clone().into()), mime_type); diff --git a/src/commands/watch.rs b/src/commands/watch.rs index 71cdc17..111a330 100644 --- a/src/commands/watch.rs +++ b/src/commands/watch.rs @@ -435,6 +435,14 @@ impl WatchCommand for SqliteClipboardDb { log::info!("clipboard entry excluded by app filter"); last_hash = Some(current_hash); }, + Err(crate::db::StashError::AllWhitespace) => { + log::debug!("clipboard entry is all whitespace, skipping"); + last_hash = Some(current_hash); + }, + Err(crate::db::StashError::TooSmall(_)) => { + log::debug!("clipboard entry below minimum size, skipping"); + last_hash = Some(current_hash); + }, Err(e) => { log::error!("failed to store clipboard entry: {e}"); last_hash = Some(current_hash); @@ -518,8 +526,8 @@ mod tests { #[test] fn test_pick_image_preference_falls_back() { + // No image types in offer set; first type is used as fallback. let offered = vec!["text/html".to_string(), "text/plain".to_string()]; - // No image types offered — falls back to first assert_eq!(pick_mime(&offered, "image").unwrap(), "text/html"); } @@ -550,14 +558,14 @@ mod tests { #[test] fn test_pick_html_fallback_when_only_html() { - // When text/html is the only type, pick it + // text/html is used when it is the only offered type. let offered = vec!["text/html".to_string()]; assert_eq!(pick_mime(&offered, "any").unwrap(), "text/html"); } #[test] fn test_pick_text_over_html_when_no_image() { - // Rich text copy: html + plain, no image — prefer plain text + // html + plain with no image type; plain text wins over html. let offered = vec!["text/html".to_string(), "text/plain".to_string()]; assert_eq!(pick_mime(&offered, "any").unwrap(), "text/plain"); } From e3cbee58430d939b08a0e03f3e359c5a299b4261 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 19:15:15 +0000 Subject: [PATCH 12/13] build(deps): bump crane from `dc7496d` to `ad8b31a` Bumps [crane](https://github.com/ipetkov/crane) from `dc7496d` to `ad8b31a`. - [Release notes](https://github.com/ipetkov/crane/releases) - [Commits](https://github.com/ipetkov/crane/compare/dc7496d8ea6e526b1254b55d09b966e94673750f...ad8b31ad0ba8448bd958d7a5d50d811dc5d271c0) --- updated-dependencies: - dependency-name: crane dependency-version: ad8b31ad0ba8448bd958d7a5d50d811dc5d271c0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index d437322..67bf7d2 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1776635034, - "narHash": "sha256-OEOJrT3ZfwbChzODfIH4GzlNTtOFuZFWPtW7jIeR8xU=", + "lastModified": 1777830388, + "narHash": "sha256-2uoQAqUk2H0ijQtGiWAyNeQYGYc6yfAcRRLlJAz4Gp8=", "owner": "ipetkov", "repo": "crane", - "rev": "dc7496d8ea6e526b1254b55d09b966e94673750f", + "rev": "d459c1350e96ce1a7e3859c513ef5e9869d67d6f", "type": "github" }, "original": { From 30e70ac018ec08e20727df3d05ce7cef918d63ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 15:09:36 +0000 Subject: [PATCH 13/13] build(deps): bump nixpkgs from `4c1018d` to `1c3fe55` Bumps [nixpkgs](https://github.com/NixOS/nixpkgs) from `4c1018d` to `1c3fe55`. - [Commits](https://github.com/NixOS/nixpkgs/compare/4c1018dae018162ec878d42fec712642d214fdfa...1c3fe55ad329cbcb28471bb30f05c9827f724c76) --- updated-dependencies: - dependency-name: nixpkgs dependency-version: 1c3fe55ad329cbcb28471bb30f05c9827f724c76 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 67bf7d2..8a9f105 100644 --- a/flake.lock +++ b/flake.lock @@ -17,11 +17,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1775710090, - "narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=", + "lastModified": 1777954456, + "narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "4c1018dae018162ec878d42fec712642d214fdfa", + "rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1", "type": "github" }, "original": {