Compare commits

..

20 commits

Author SHA1 Message Date
raf
c1ca18e332
Merge pull request #97 from NotAShelf/dependabot/nix/crane-6d015ea
build(deps): bump crane from `d459c13` to `6d015ea`
2026-05-14 18:22:48 +03:00
dependabot[bot]
102920f0a8
build(deps): bump crane from d459c13 to 6d015ea
Bumps [crane](https://github.com/ipetkov/crane) from `d459c13` to `6d015ea`.
- [Release notes](https://github.com/ipetkov/crane/releases)
- [Commits](d459c1350e...6d015ea296)

---
updated-dependencies:
- dependency-name: crane
  dependency-version: 6d015ea29630b7ad2402841386da2cb617a470a7
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-14 14:58:51 +00:00
raf
fb9f1d123f
Merge pull request #94 from NotAShelf/dependabot/cargo/libc-0.2.186
build(deps): bump libc from 0.2.185 to 0.2.186
2026-05-13 07:34:26 +03:00
dependabot[bot]
a650fa6b3a
build(deps): bump libc from 0.2.185 to 0.2.186
Bumps [libc](https://github.com/rust-lang/libc) from 0.2.185 to 0.2.186.
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.186/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.185...0.2.186)

---
updated-dependencies:
- dependency-name: libc
  dependency-version: 0.2.186
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-13 03:44:51 +00:00
raf
f4f30cbc9c
Merge pull request #96 from NotAShelf/dependabot/cargo/notify-rust-4.17.0
build(deps): bump notify-rust from 4.14.0 to 4.17.0
2026-05-13 06:43:07 +03:00
dependabot[bot]
21d8bb6fab
build(deps): bump notify-rust from 4.14.0 to 4.17.0
Bumps [notify-rust](https://github.com/hoodie/notify-rust) from 4.14.0 to 4.17.0.
- [Release notes](https://github.com/hoodie/notify-rust/releases)
- [Changelog](https://github.com/hoodie/notify-rust/blob/main/CHANGELOG.md)
- [Commits](https://github.com/hoodie/notify-rust/compare/v4.14.0...v4.17.0)

---
updated-dependencies:
- dependency-name: notify-rust
  dependency-version: 4.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-12 20:39:10 +00:00
raf
e2ad638fb2
Merge pull request #95 from NotAShelf/dependabot/nix/nixpkgs-1c3fe55
build(deps): bump nixpkgs from `4c1018d` to `1c3fe55`
2026-05-06 18:17:12 +03:00
dependabot[bot]
30e70ac018
build(deps): bump nixpkgs from 4c1018d to 1c3fe55
Bumps [nixpkgs](https://github.com/NixOS/nixpkgs) from `4c1018d` to `1c3fe55`.
- [Commits](4c1018dae0...1c3fe55ad3)

---
updated-dependencies:
- dependency-name: nixpkgs
  dependency-version: 1c3fe55ad329cbcb28471bb30f05c9827f724c76
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-06 15:09:36 +00:00
raf
df9ddddba5
Merge pull request #93 from NotAShelf/dependabot/nix/crane-ad8b31a
build(deps): bump crane from `dc7496d` to `ad8b31a`
2026-05-04 23:16:03 +03:00
dependabot[bot]
e3cbee5843
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](dc7496d8ea...ad8b31ad0b)

---
updated-dependencies:
- dependency-name: crane
  dependency-version: ad8b31ad0ba8448bd958d7a5d50d811dc5d271c0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-04 19:15:15 +00:00
9217b32798
commands: fix MIME fallback in TUI; improve watch logging
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I67d0486ca9719b334957ff3868da3f0c6a6a6964
2026-05-03 18:11:03 +03:00
cf207d0a3d
clipboard: downgrade error logging to debug for expected failures
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ic1cca0d0212b9b3611da8ca3f9c6fb326a6a6964
2026-05-03 18:11:02 +03:00
4055adb896
db: refactor migrations; fix LRU eviction logic
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I594551967b392a52bdf95db41ccf40816a6a6964
2026-05-03 18:10:58 +03:00
4d4d359bcf
docs: fix cargo install link in README
Fixes #90

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I41c2ae0fbf1992478c6409864f9bdef66a6a6964
2026-04-27 20:54:36 +03:00
raf
01939c2136
Merge pull request #89 from NotAShelf/dependabot/nix/crane-28462d6
build(deps): bump crane from `7cf72d9` to `28462d6`
2026-04-24 20:34:24 +03:00
dependabot[bot]
0ebf62fa5d
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](7cf72d9786...28462d6d55)

---
updated-dependencies:
- dependency-name: crane
  dependency-version: 28462d6d55c33206ffa5a56c7907ca3125ed788f
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-24 14:57:41 +00:00
raf
4d3c99368f
Merge pull request #87 from NotAShelf/dependabot/cargo/libc-0.2.185
build(deps): bump libc from 0.2.184 to 0.2.185
2026-04-21 18:00:30 +03:00
dependabot[bot]
7498d688c9
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] <support@github.com>
2026-04-21 14:57:54 +00:00
raf
3c61cc19f6
Merge pull request #86 from NotAShelf/dependabot/github_actions/softprops/action-gh-release-3
build(deps): bump softprops/action-gh-release from 2 to 3
2026-04-12 23:07:28 +03:00
dependabot[bot]
cd692ba002
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] <support@github.com>
2026-04-12 20:00:34 +00:00
12 changed files with 369 additions and 1173 deletions

View file

@ -40,7 +40,7 @@ jobs:
steps: steps:
- name: Create Release - name: Create Release
id: create_release id: create_release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
with: with:
draft: false draft: false
prerelease: false prerelease: false
@ -98,7 +98,7 @@ jobs:
cp target/${{ matrix.target }}/release/stash ${{ matrix.name }} cp target/${{ matrix.target }}/release/stash ${{ matrix.name }}
- name: Upload Release Asset - name: Upload Release Asset
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
with: with:
files: ${{ matrix.name }} files: ${{ matrix.name }}
@ -120,7 +120,7 @@ jobs:
sha256sum stash-* > SHA256SUMS sha256sum stash-* > SHA256SUMS
- name: Upload Checksums - name: Upload Checksums
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
files: SHA256SUMS files: SHA256SUMS

653
Cargo.lock generated
View file

@ -17,59 +17,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" 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]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "1.1.4" version = "1.1.4"
@ -339,33 +286,12 @@ dependencies = [
"windows-link 0.2.1", "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]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" 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]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.5.3" version = "0.5.3"
@ -467,41 +393,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 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]] [[package]]
name = "clap" name = "clap"
version = "4.6.0" version = "4.6.0"
@ -617,15 +508,6 @@ dependencies = [
"unicode-segmentation", "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]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@ -699,32 +581,6 @@ dependencies = [
"windows-sys", "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]] [[package]]
name = "darling" name = "darling"
version = "0.23.0" version = "0.23.0"
@ -804,7 +660,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"crypto-common", "crypto-common",
"subtle",
] ]
[[package]] [[package]]
@ -1012,12 +867,6 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]] [[package]]
name = "filedescriptor" name = "filedescriptor"
version = "0.8.3" version = "0.8.3"
@ -1029,15 +878,6 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "find-crate"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2"
dependencies = [
"toml",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@ -1062,50 +902,6 @@ version = "0.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" 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]] [[package]]
name = "fnv" name = "fnv"
version = "1.0.7" version = "1.0.7"
@ -1339,96 +1135,12 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 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]] [[package]]
name = "humantime" name = "humantime"
version = "2.3.0" version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" 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]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.2.0" version = "2.2.0"
@ -1577,15 +1289,6 @@ dependencies = [
"rustversion", "rustversion",
] ]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "inquire" name = "inquire"
version = "0.9.4" version = "0.9.4"
@ -1612,31 +1315,6 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "is_terminal_polyfill" name = "is_terminal_polyfill"
version = "1.70.2" version = "1.70.2"
@ -1723,9 +1401,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.184" version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]] [[package]]
name = "libredox" name = "libredox"
@ -1930,9 +1608,9 @@ dependencies = [
[[package]] [[package]]
name = "notify-rust" name = "notify-rust"
version = "4.14.0" version = "4.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b2c9bc1689653cfbc04400b8719f2562638ff9c545bbd48cc58c657a14526df" checksum = "50ff2e74231b72c832d82982193b417f230945be6bdb5575b251d941d31adb00"
dependencies = [ dependencies = [
"futures-lite", "futures-lite",
"log", "log",
@ -2037,12 +1715,6 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@ -2113,16 +1785,6 @@ dependencies = [
"windows-link 0.2.1", "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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@ -2235,26 +1897,6 @@ dependencies = [
"siphasher", "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]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
version = "0.2.17" version = "0.2.17"
@ -2292,17 +1934,6 @@ dependencies = [
"windows-sys", "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]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.13.1" version = "1.13.1"
@ -2333,15 +1964,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 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]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.37" version = "0.2.37"
@ -2361,28 +1983,6 @@ dependencies = [
"toml_edit", "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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@ -2437,18 +2037,6 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [ 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", "rand_core",
] ]
@ -2457,9 +2045,6 @@ name = "rand_core"
version = "0.6.4" version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom 0.2.17",
]
[[package]] [[package]]
name = "ratatui" name = "ratatui"
@ -2620,58 +2205,12 @@ dependencies = [
"sqlite-wasm-rs", "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]] [[package]]
name = "rustc-demangle" name = "rustc-demangle"
version = "0.1.27" version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" 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]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@ -2706,65 +2245,12 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 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]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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]] [[package]]
name = "semver" name = "semver"
version = "1.0.28" version = "1.0.28"
@ -2939,9 +2425,8 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
name = "stash-clipboard" name = "stash-clipboard"
version = "0.3.6" version = "0.3.6"
dependencies = [ dependencies = [
"age",
"arc-swap", "arc-swap",
"base64 0.22.1", "base64",
"blocking", "blocking",
"clap", "clap",
"clap-verbosity-flag", "clap-verbosity-flag",
@ -3006,12 +2491,6 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "1.0.109" version = "1.0.109"
@ -3098,7 +2577,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.22.1", "base64",
"bitflags 2.11.0", "bitflags 2.11.0",
"fancy-regex", "fancy-regex",
"filedescriptor", "filedescriptor",
@ -3210,19 +2689,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [ dependencies = [
"displaydoc", "displaydoc",
"serde_core",
"zerovec", "zerovec",
] ]
[[package]]
name = "toml"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "1.1.1+spec-1.1.0" version = "1.1.1+spec-1.1.0"
@ -3317,15 +2786,6 @@ dependencies = [
"petgraph", "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]] [[package]]
name = "typenum" name = "typenum"
version = "1.19.0" version = "1.19.0"
@ -3349,25 +2809,6 @@ dependencies = [
"windows-sys", "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]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@ -3403,16 +2844,6 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" 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]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@ -3477,16 +2908,6 @@ dependencies = [
"utf8parse", "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]] [[package]]
name = "wasi" name = "wasi"
version = "0.11.1+wasi-snapshot-preview1" version = "0.11.1+wasi-snapshot-preview1"
@ -3749,15 +3170,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 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]] [[package]]
name = "winapi-x86_64-pc-windows-gnu" name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0" version = "0.4.0"
@ -4029,18 +3441,6 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" 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]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.2" version = "0.8.2"
@ -4125,26 +3525,6 @@ dependencies = [
"zvariant", "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]] [[package]]
name = "zerofrom" name = "zerofrom"
version = "0.1.7" version = "0.1.7"
@ -4166,26 +3546,6 @@ dependencies = [
"synstructure", "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]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.4" version = "0.2.4"
@ -4203,7 +3563,6 @@ version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [ dependencies = [
"serde",
"yoke", "yoke",
"zerofrom", "zerofrom",
"zerovec-derive", "zerovec-derive",

View file

@ -14,7 +14,6 @@ name = "stash" # actual binary name for Nix, Cargo, etc.
path = "src/main.rs" path = "src/main.rs"
[dependencies] [dependencies]
age = "0.11.2"
arc-swap = { version = "1.9.1", optional = true } arc-swap = { version = "1.9.1", optional = true }
base64 = "0.22.1" base64 = "0.22.1"
blocking = "1.6.2" blocking = "1.6.2"
@ -28,10 +27,10 @@ env_logger = "0.11.10"
humantime = "2.3.0" humantime = "2.3.0"
imagesize = "0.14.0" imagesize = "0.14.0"
inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] } inquire = { version = "0.9.4", default-features = false, features = [ "crossterm" ] }
libc = "0.2.184" libc = "0.2.186"
log = "0.4.29" log = "0.4.29"
mime-sniffer = "0.1.3" mime-sniffer = "0.1.3"
notify-rust = { version = "4.14.0", optional = true } notify-rust = { version = "4.17.0", optional = true }
ratatui = "0.30.0" ratatui = "0.30.0"
regex = "1.12.3" regex = "1.12.3"
rusqlite = { version = "0.39.0", features = [ "bundled" ] } rusqlite = { version = "0.39.0", features = [ "bundled" ] }

121
README.md
View file

@ -20,10 +20,9 @@
</div> </div>
<div align="center"> <div align="center">
Lightweight & feature-rich Wayland clipboard "manager" with fast persistent Lightweight & feature-rich Wayland clipboard "manager" with fast persistent history and
history and robust multi-media support. Stores and previews clipboard robust multi-media support. Stores and previews clipboard entries (text, images)
entries (text, images) on the clipboard with a neat TUI and advanced scripting on the clipboard with a neat TUI and advanced scripting capabilities.
capabilities.
</div> </div>
<div align="center"> <div align="center">
@ -53,7 +52,6 @@ with many features such as but not necessarily limited to:
- Drop-in replacement for `wl-clipboard` tools (`wl-copy` and `wl-paste`) - Drop-in replacement for `wl-clipboard` tools (`wl-copy` and `wl-paste`)
- Sensitive clipboard filtering via regex (see below) - Sensitive clipboard filtering via regex (see below)
- Sensitive clipboard filtering by application (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: on top of the existing features of Cliphist, which are as follows:
@ -126,7 +124,7 @@ releases are made when a version gets tagged, and are available under
- Build and install from source with Cargo: - Build and install from source with Cargo:
```bash ```bash
cargo install stash --locked cargo install stash-clipboard --locked
``` ```
Additionally, you may get Stash from source via `cargo install` using Additionally, you may get Stash from source via `cargo install` using
@ -359,38 +357,21 @@ sensitive pattern, using a regular expression. This is useful for preventing
accidental storage of secrets, passwords, or other sensitive data. You don't accidental storage of secrets, passwords, or other sensitive data. You don't
want sensitive data ending up in your persistent clipboard, right? want sensitive data ending up in your persistent clipboard, right?
The filter can be configured in several ways, as part of two separate features. The filter can be configured in one of three ways, as part of two separate
features.
#### Clipboard Filtering by Entry Regex #### Clipboard Filtering by Entry Regex
This can be configured in one of several ways. You can use the **environment This can be configured in one of two ways. You can use the **environment
variable** `STASH_SENSITIVE_REGEX` to a valid regex pattern, and if the variable** `STASTH_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 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 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 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. similar but in some cases this might be a security flaw.
The _less-insecure_ [^1] alternative to this is using The safer alternative to this is using **Systemd LoadCrediental**. If Stash is
`STASH_SENSITIVE_REGEX_FILE` to read the regex from a file path. This is useful running as a Systemd service, you can provide a regex pattern using a crediental
for NixOS secrets managers like agenix or sops-nix. file. For example, add to your `stash.service`:
```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 ```dosini
LoadCredential=clipboard_filter:/etc/stash/clipboard_filter LoadCredential=clipboard_filter:/etc/stash/clipboard_filter
@ -401,9 +382,9 @@ quotes). This is done automatically in the
[vendored Systemd service](./contrib/stash.service). Remember to set the [vendored Systemd service](./contrib/stash.service). Remember to set the
appropriate file permissions if using this option. appropriate file permissions if using this option.
The service will check the credential file first, then the command, then the The service will check the credential file first, then the environment variable.
file path, then the environment variable. If a clipboard entry matches the If a clipboard entry matches the regex, it will be skipped and a warning will be
regex, it will be skipped and a warning will be logged. logged.
> [!TIP] > [!TIP]
> **Example regex to block common password patterns**: > **Example regex to block common password patterns**:
@ -412,21 +393,17 @@ regex, it will be skipped and a warning will be logged.
> >
> For security reasons, you are recommended to use the regex only for generic > 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. > 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 #### Clipboard Filtering by Application Class
Stash allows blocking an entry from the persistent history if it has been copied 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 from certain applications. This depends on the `use-toplevel` feature flag and
uses the the `wlr-foreign-toplevel-management-v1` protocol for precise focus uses the the `wlr-foreign-toplevel-management-v1` protocol for precise focus
detection. 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`
While this feature flag is enabled (the default) you may use `--excluded-apps` environment variable to block entries from persisting in the database if they
in, e.g., `stash watch` or set the `STASH_EXCLUDED_APPS` environment variable to are coming from your password manager for example. The entry is still copied to
block entries from persisting in the database if they are coming from your the clipboard, but it will never be put inside the database.
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 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 likely do not want to catch your passwords with a regex. Simply pass your
@ -539,66 +516,6 @@ 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 the packagers. While building from source, you may link
`target/release/stash` manually. `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 ### Entry Expiration
Stash supports time-to-live (TTL) for clipboard entries. When an entry's Stash supports time-to-live (TTL) for clipboard entries. When an entry's

12
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": { "nodes": {
"crane": { "crane": {
"locked": { "locked": {
"lastModified": 1775839657, "lastModified": 1778106249,
"narHash": "sha256-SPm9ck7jh3Un9nwPuMGbRU04UroFmOHjLP56T10MOeM=", "narHash": "sha256-cM/AuKy5tMhwOOQIbha8ZRRMHVfNf7cv2aljIw+qoCg=",
"owner": "ipetkov", "owner": "ipetkov",
"repo": "crane", "repo": "crane",
"rev": "7cf72d978629469c4bd4206b95c402514c1f6000", "rev": "6d015ea29630b7ad2402841386da2cb617a470a7",
"type": "github" "type": "github"
}, },
"original": { "original": {
@ -17,11 +17,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1775710090, "lastModified": 1777954456,
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=", "narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "4c1018dae018162ec878d42fec712642d214fdfa", "rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -196,8 +196,7 @@ fn serve_clipboard_child(prepared: PreparedCopy) {
}, },
Err(e) => { Err(e) => {
log::error!("clipboard persistence: serve failed: {e}"); log::debug!("clipboard persistence: serve ended: {e}");
exit(1);
}, },
} }
} }

View file

@ -698,7 +698,7 @@ impl SqliteClipboardDb {
let mime_type = match mime { let mime_type = match mime {
Some(ref m) if m == "text/plain" => MimeType::Text, Some(ref m) if m == "text/plain" => MimeType::Text,
Some(ref m) => MimeType::Specific(m.clone().clone()), Some(ref m) => MimeType::Specific(m.clone().clone()),
None => MimeType::Text, None => MimeType::Autodetect,
}; };
let copy_result = opts let copy_result = opts
.copy(Source::Bytes(contents.clone().into()), mime_type); .copy(Source::Bytes(contents.clone().into()), mime_type);

View file

@ -435,6 +435,14 @@ impl WatchCommand for SqliteClipboardDb {
log::info!("clipboard entry excluded by app filter"); log::info!("clipboard entry excluded by app filter");
last_hash = Some(current_hash); 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) => { Err(e) => {
log::error!("failed to store clipboard entry: {e}"); log::error!("failed to store clipboard entry: {e}");
last_hash = Some(current_hash); last_hash = Some(current_hash);
@ -518,8 +526,8 @@ mod tests {
#[test] #[test]
fn test_pick_image_preference_falls_back() { 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()]; 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"); assert_eq!(pick_mime(&offered, "image").unwrap(), "text/html");
} }
@ -550,14 +558,14 @@ mod tests {
#[test] #[test]
fn test_pick_html_fallback_when_only_html() { 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()]; let offered = vec!["text/html".to_string()];
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/html"); assert_eq!(pick_mime(&offered, "any").unwrap(), "text/html");
} }
#[test] #[test]
fn test_pick_text_over_html_when_no_image() { 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()]; let offered = vec!["text/html".to_string(), "text/plain".to_string()];
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/plain"); assert_eq!(pick_mime(&offered, "any").unwrap(), "text/plain");
} }

View file

@ -215,18 +215,12 @@ pub enum StashError {
QueryDelete(Box<str>), QueryDelete(Box<str>),
#[error("Failed to delete entry with id {0}: {1}")] #[error("Failed to delete entry with id {0}: {1}")]
DeleteEntry(i64, Box<str>), DeleteEntry(i64, Box<str>),
#[error("Encryption error: {0}")]
Encryption(Box<str>),
#[error("Decryption error: {0}")]
Decryption(Box<str>),
} }
pub trait ClipboardDb { pub trait ClipboardDb {
/// Store a new clipboard entry. /// Store a new clipboard entry.
/// ///
/// # Arguments /// # Arguments
///
/// * `input` - Reader for the clipboard content /// * `input` - Reader for the clipboard content
/// * `max_dedupe_search` - Maximum number of recent entries to check for /// * `max_dedupe_search` - Maximum number of recent entries to check for
/// duplicates /// duplicates
@ -349,210 +343,82 @@ impl SqliteClipboardDb {
mime TEXT mime TEXT
);", );",
) )
.map_err(|e| { .map_err(migration_err)?;
StashError::Store( tx.pragma_update(None, "user_version", 1i64)
format!("Failed to create clipboard table: {e}").into(), .map_err(migration_err)?;
)
})?;
tx.execute("PRAGMA user_version = 1", []).map_err(|e| {
StashError::Store(format!("Failed to set schema version: {e}").into())
})?;
} }
// Add content_hash column if it doesn't exist. Migration MUST be done to
// avoid breaking existing installations.
if schema_version < 2 { if schema_version < 2 {
let has_content_hash: bool = tx if !column_exists(&tx, "content_hash") {
.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 {
tx.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", []) tx.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", [])
.map_err(|e| { .map_err(migration_err)?;
StashError::Store(
format!("Failed to add content_hash column: {e}").into(),
)
})?;
} }
// Create index for content_hash if it doesn't exist
tx.execute( tx.execute(
"CREATE INDEX IF NOT EXISTS idx_content_hash ON \ "CREATE INDEX IF NOT EXISTS idx_content_hash ON \
clipboard(content_hash)", clipboard(content_hash)",
[], [],
) )
.map_err(|e| { .map_err(migration_err)?;
StashError::Store( tx.pragma_update(None, "user_version", 2i64)
format!("Failed to create content_hash index: {e}").into(), .map_err(migration_err)?;
)
})?;
tx.execute("PRAGMA user_version = 2", []).map_err(|e| {
StashError::Store(format!("Failed to set schema version: {e}").into())
})?;
} }
// Add last_accessed column if it doesn't exist
if schema_version < 3 { if schema_version < 3 {
let has_last_accessed: bool = tx if !column_exists(&tx, "last_accessed") {
.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 {
tx.execute("ALTER TABLE clipboard ADD COLUMN last_accessed INTEGER", [ tx.execute("ALTER TABLE clipboard ADD COLUMN last_accessed INTEGER", [
]) ])
.map_err(|e| { .map_err(migration_err)?;
StashError::Store(
format!("Failed to add last_accessed column: {e}").into(),
)
})?;
} }
// Create index for last_accessed if it doesn't exist
tx.execute( tx.execute(
"CREATE INDEX IF NOT EXISTS idx_last_accessed ON \ "CREATE INDEX IF NOT EXISTS idx_last_accessed ON \
clipboard(last_accessed)", clipboard(last_accessed)",
[], [],
) )
.map_err(|e| { .map_err(migration_err)?;
StashError::Store( tx.pragma_update(None, "user_version", 3i64)
format!("Failed to create last_accessed index: {e}").into(), .map_err(migration_err)?;
)
})?;
tx.execute("PRAGMA user_version = 3", []).map_err(|e| {
StashError::Store(format!("Failed to set schema version: {e}").into())
})?;
} }
// Add expires_at column if it doesn't exist (v4)
if schema_version < 4 { if schema_version < 4 {
let has_expires_at: bool = tx if !column_exists(&tx, "expires_at") {
.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", []) tx.execute("ALTER TABLE clipboard ADD COLUMN expires_at REAL", [])
.map_err(|e| { .map_err(migration_err)?;
StashError::Store(
format!("Failed to add expires_at column: {e}").into(),
)
})?;
} }
// Create partial index for expires_at (only index non-NULL values)
tx.execute( tx.execute(
"CREATE INDEX IF NOT EXISTS idx_expires_at ON clipboard(expires_at) \ "CREATE INDEX IF NOT EXISTS idx_expires_at ON clipboard(expires_at) \
WHERE expires_at IS NOT NULL", WHERE expires_at IS NOT NULL",
[], [],
) )
.map_err(|e| { .map_err(migration_err)?;
StashError::Store( tx.pragma_update(None, "user_version", 4i64)
format!("Failed to create expires_at index: {e}").into(), .map_err(migration_err)?;
)
})?;
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 { if schema_version < 5 {
let has_is_expired: bool = tx if !column_exists(&tx, "is_expired") {
.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( tx.execute(
"ALTER TABLE clipboard ADD COLUMN is_expired INTEGER DEFAULT 0", "ALTER TABLE clipboard ADD COLUMN is_expired INTEGER DEFAULT 0",
[], [],
) )
.map_err(|e| { .map_err(migration_err)?;
StashError::Store(
format!("Failed to add is_expired column: {e}").into(),
)
})?;
} }
// Create index for is_expired (for filtering)
tx.execute( tx.execute(
"CREATE INDEX IF NOT EXISTS idx_is_expired ON clipboard(is_expired) \ "CREATE INDEX IF NOT EXISTS idx_is_expired ON clipboard(is_expired) \
WHERE is_expired = 1", WHERE is_expired = 1",
[], [],
) )
.map_err(|e| { .map_err(migration_err)?;
StashError::Store( tx.pragma_update(None, "user_version", 5i64)
format!("Failed to create is_expired index: {e}").into(), .map_err(migration_err)?;
)
})?;
tx.execute("PRAGMA user_version = 5", []).map_err(|e| {
StashError::Store(format!("Failed to set schema version: {e}").into())
})?;
} }
// 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 { if schema_version < 6 {
let has_mime_types: bool = tx if !column_exists(&tx, "mime_types") {
.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 {
tx.execute("ALTER TABLE clipboard ADD COLUMN mime_types TEXT", []) tx.execute("ALTER TABLE clipboard ADD COLUMN mime_types TEXT", [])
.map_err(|e| { .map_err(migration_err)?;
StashError::Store(
format!("Failed to add mime_types column: {e}").into(),
)
})?;
} }
tx.pragma_update(None, "user_version", 6i64)
tx.execute("PRAGMA user_version = 6", []).map_err(|e| { .map_err(migration_err)?;
StashError::Store(format!("Failed to set schema version: {e}").into())
})?;
} }
tx.commit().map_err(|e| { tx.commit().map_err(|e| {
@ -561,14 +427,29 @@ impl SqliteClipboardDb {
) )
})?; })?;
// Initialize Wayland state in background thread. This will be used to track
// focused window state.
#[cfg(feature = "use-toplevel")] #[cfg(feature = "use-toplevel")]
crate::wayland::init_wayland_state(); crate::wayland::init_wayland_state();
Ok(Self { conn, db_path }) 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 { impl SqliteClipboardDb {
pub fn list_json( pub fn list_json(
&self, &self,
@ -601,24 +482,11 @@ impl SqliteClipboardDb {
.get(2) .get(2)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?; .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) => {
warn!("skipping entry {id}: decryption failed: {e}");
continue;
},
}
} else {
contents
};
let contents_str = match mime.as_deref() { let contents_str = match mime.as_deref() {
Some(m) if m.starts_with("text/") || m == "application/json" => { Some(m) if m.starts_with("text/") || m == "application/json" => {
String::from_utf8_lossy(&decrypted_contents).into_owned() String::from_utf8_lossy(&contents).into_owned()
}, },
_ => base64::prelude::BASE64_STANDARD.encode(&decrypted_contents), _ => base64::prelude::BASE64_STANDARD.encode(&contents),
}; };
entries.push(serde_json::json!({ entries.push(serde_json::json!({
"id": id, "id": id,
@ -708,22 +576,13 @@ impl ClipboardDb for SqliteClipboardDb {
None => None, 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 self
.conn .conn
.execute( .execute(
"INSERT INTO clipboard (contents, mime, content_hash, last_accessed, \ "INSERT INTO clipboard (contents, mime, content_hash, last_accessed, \
mime_types) VALUES (?1, ?2, ?3, ?4, ?5)", mime_types) VALUES (?1, ?2, ?3, ?4, ?5)",
params![ params![
contents_to_store, buf,
mime, mime,
content_hash, content_hash,
std::time::SystemTime::now() std::time::SystemTime::now()
@ -793,7 +652,7 @@ impl ClipboardDb for SqliteClipboardDb {
.conn .conn
.execute( .execute(
"DELETE FROM clipboard WHERE id IN (SELECT id FROM clipboard ORDER \ "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)], params![i64::try_from(to_delete).unwrap_or(i64::MAX)],
) )
.map_err(|e| StashError::Trim(e.to_string().into()))?; .map_err(|e| StashError::Trim(e.to_string().into()))?;
@ -865,20 +724,8 @@ impl ClipboardDb for SqliteClipboardDb {
let mime: Option<String> = row let mime: Option<String> = row
.get(2) .get(2)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let preview_contents = if contents.starts_with(&[0x01u8]) {
match decrypt_data(&contents) {
Ok(decrypted) => decrypted,
Err(e) => {
warn!("skipping entry {id}: decryption failed: {e}");
continue;
},
}
} else {
contents
};
let preview = let preview = preview_entry(&contents, mime.as_deref(), preview_width);
preview_entry(&preview_contents, mime.as_deref(), preview_width);
if writeln!(out, "{id}\t{preview}").is_ok() { if writeln!(out, "{id}\t{preview}").is_ok() {
listed += 1; listed += 1;
} }
@ -912,15 +759,8 @@ impl ClipboardDb for SqliteClipboardDb {
|row| Ok((row.get(0)?, row.get(1)?)), |row| Ok((row.get(0)?, row.get(1)?)),
) )
.map_err(|e| StashError::DecodeGet(e.to_string().into()))?; .map_err(|e| StashError::DecodeGet(e.to_string().into()))?;
let decrypted_contents = if contents.starts_with(&[0x01u8]) {
decrypt_data(&contents)?
} else {
contents
};
out out
.write_all(&decrypted_contents) .write_all(&contents)
.map_err(|e| StashError::DecodeWrite(e.to_string().into()))?; .map_err(|e| StashError::DecodeWrite(e.to_string().into()))?;
log::info!("decoded entry with id {id}"); log::info!("decoded entry with id {id}");
Ok(()) Ok(())
@ -945,23 +785,7 @@ impl ClipboardDb for SqliteClipboardDb {
let contents: Vec<u8> = row let contents: Vec<u8> = row
.get(1) .get(1)
.map_err(|e| StashError::QueryDelete(e.to_string().into()))?; .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(&[0x01u8]) {
match decrypt_data(&contents) {
Ok(decrypted) => decrypted,
Err(e) => {
warn!("skipping entry {id}: decryption failed: {e}");
continue;
},
}
} else {
contents
};
if searchable_contents
.windows(query.len())
.any(|w| w == query.as_bytes())
{
self self
.conn .conn
.execute("DELETE FROM clipboard WHERE id = ?1", params![id]) .execute("DELETE FROM clipboard WHERE id = ?1", params![id])
@ -991,48 +815,25 @@ impl ClipboardDb for SqliteClipboardDb {
&self, &self,
id: i64, id: i64,
) -> Result<(i64, Vec<u8>, Option<String>), StashError> { ) -> Result<(i64, Vec<u8>, Option<String>), StashError> {
let (contents, mime, content_hash): (Vec<u8>, Option<String>, Option<i64>) = let (contents, mime): (Vec<u8>, Option<String>) = self
self
.conn .conn
.query_row( .query_row(
"SELECT contents, mime, content_hash FROM clipboard WHERE id = ?1", "SELECT contents, mime FROM clipboard WHERE id = ?1",
params![id], params![id],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), |row| Ok((row.get(0)?, row.get(1)?)),
) )
.map_err(|e| StashError::DecodeGet(e.to_string().into()))?; .map_err(|e| StashError::DecodeGet(e.to_string().into()))?;
if let Some(hash) = content_hash {
let most_recent_id: Option<i64> = 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 self
.conn .conn
.execute( .execute(
"UPDATE clipboard SET last_accessed = CAST(strftime('%s', 'now') \ "UPDATE clipboard SET last_accessed = CAST(strftime('%s', 'now') AS \
AS INTEGER) WHERE id = ?1", INTEGER) WHERE id = ?1",
params![id], params![id],
) )
.map_err(|e| StashError::Store(e.to_string().into()))?; .map_err(|e| StashError::Store(e.to_string().into()))?;
}
}
let decrypted_contents = if contents.starts_with(&[0x01u8]) { Ok((id, contents, mime))
decrypt_data(&contents)?
} else {
contents
};
Ok((id, decrypted_contents, mime))
} }
} }
@ -1107,20 +908,7 @@ impl SqliteClipboardDb {
let mime: Option<String> = row let mime: Option<String> = row
.get(2) .get(2)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let decrypted_contents = if contents.starts_with(&[0x01u8]) { let preview = preview_entry(&contents, mime.as_deref(), preview_width);
match decrypt_data(&contents) {
Ok(decrypted) => decrypted,
Err(e) => {
warn!("skipping entry {id}: decryption failed: {e}");
continue;
},
}
} else {
contents
};
let preview =
preview_entry(&decrypted_contents, mime.as_deref(), preview_width);
let mime_str = mime.unwrap_or_default(); let mime_str = mime.unwrap_or_default();
window.push((id, preview, mime_str)); window.push((id, preview, mime_str));
} }
@ -1237,23 +1025,10 @@ impl SqliteClipboardDb {
/// changes made after daemon startup. Regex compilation is cached by /// changes made after daemon startup. Regex compilation is cached by
/// pattern to avoid recompilation. /// pattern to avoid recompilation.
fn load_sensitive_regex() -> Option<Regex> { fn load_sensitive_regex() -> Option<Regex> {
use std::process::Command;
// Get the current pattern from env vars // Get the current pattern from env vars
let pattern = if let Ok(cred_dir) = env::var("CREDENTIALS_DIRECTORY") { let pattern = if let Ok(regex_path) = env::var("CREDENTIALS_DIRECTORY") {
let file = format!("{cred_dir}/clipboard_filter"); let file = format!("{regex_path}/clipboard_filter");
fs::read_to_string(&file).ok().map(|s| s.trim().to_string()) 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 { } else {
env::var("STASH_SENSITIVE_REGEX").ok() env::var("STASH_SENSITIVE_REGEX").ok()
}?; }?;
@ -1280,68 +1055,6 @@ fn load_sensitive_regex() -> Option<Regex> {
}) })
} }
fn load_encryption_passphrase() -> Option<age::secrecy::SecretString> {
use std::process::Command;
static PASSPHRASE_CACHE: OnceLock<age::secrecy::SecretString> =
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<Vec<u8>, 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()))?;
// 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<Vec<u8>, StashError> {
let passphrase = load_encryption_passphrase().ok_or_else(|| {
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, data_to_decrypt)
.map_err(|e| StashError::Decryption(e.to_string().into()))?;
Ok(decrypted)
}
pub fn extract_id(input: &str) -> Result<i64, &'static str> { pub fn extract_id(input: &str) -> Result<i64, &'static str> {
let id_str = input.split('\t').next().unwrap_or(""); let id_str = input.split('\t').next().unwrap_or("");
id_str.parse().map_err(|_| "invalid id") id_str.parse().map_err(|_| "invalid id")
@ -1410,17 +1123,10 @@ pub fn size_str(size: usize) -> String {
/// Check if clipboard should be excluded based on excluded apps configuration. /// Check if clipboard should be excluded based on excluded apps configuration.
/// Uses timing correlation and focused window detection to identify source app. /// Uses timing correlation and focused window detection to identify source app.
fn should_exclude_by_app(excluded_apps: Option<&[String]>) -> bool { fn should_exclude_by_app(excluded_apps: Option<&[String]>) -> bool {
let excluded = match excluded_apps { match excluded_apps {
Some(apps) if !apps.is_empty() => apps, Some(apps) if !apps.is_empty() => detect_excluded_app_activity(apps),
_ => return false, _ => false,
};
// Try multiple detection strategies
if detect_excluded_app_activity(excluded) {
return true;
} }
false
} }
/// Detect if clipboard likely came from an excluded app using multiple /// Detect if clipboard likely came from an excluded app using multiple
@ -2395,4 +2101,231 @@ mod tests {
"Regex loading should be deterministic" "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<i64>, Option<i64>, Option<f64>) = 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"
);
}
} }

View file

@ -8,6 +8,7 @@ use crate::db::{ClipboardDb, SqliteClipboardDb, StashError};
/// on a thread pool to avoid blocking the async runtime. Since /// on a thread pool to avoid blocking the async runtime. Since
/// [`rusqlite::Connection`] is not Send, we store the database path and open a /// [`rusqlite::Connection`] is not Send, we store the database path and open a
/// new connection for each operation. /// new connection for each operation.
#[derive(Clone)]
pub struct AsyncClipboardDb { pub struct AsyncClipboardDb {
db_path: PathBuf, db_path: PathBuf,
} }
@ -72,25 +73,11 @@ impl AsyncClipboardDb {
AND (is_expired IS NULL OR is_expired = 0) ORDER BY expires_at ASC", AND (is_expired IS NULL OR is_expired = 0) ORDER BY expires_at ASC",
) )
.map_err(|e| StashError::ListDecode(e.to_string().into()))?; .map_err(|e| StashError::ListDecode(e.to_string().into()))?;
stmt
let mut rows = stmt .query_map([], |row| Ok((row.get::<_, f64>(0)?, row.get::<_, i64>(1)?)))
.query([])
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let mut expirations = Vec::new();
while let Some(row) = rows
.next()
.map_err(|e| StashError::ListDecode(e.to_string().into()))? .map_err(|e| StashError::ListDecode(e.to_string().into()))?
{ .collect::<Result<Vec<_>, _>>()
let exp = row .map_err(|e| StashError::ListDecode(e.to_string().into()))
.get::<_, f64>(0)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
let id = row
.get::<_, i64>(1)
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
expirations.push((exp, id));
}
Ok(expirations)
}) })
.await .await
} }
@ -136,14 +123,6 @@ impl AsyncClipboardDb {
} }
} }
impl Clone for AsyncClipboardDb {
fn clone(&self) -> Self {
Self {
db_path: self.db_path.clone(),
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::{collections::HashSet, hash::Hasher}; use std::{collections::HashSet, hash::Hasher};

View file

@ -222,8 +222,7 @@ fn fork_and_serve(prepared_copy: wl_clipboard_rs::copy::PreparedCopy) {
0 => { 0 => {
// Child process - serve clipboard content // Child process - serve clipboard content
if let Err(e) = prepared_copy.serve() { if let Err(e) = prepared_copy.serve() {
log::error!("background clipboard service failed: {e}"); log::debug!("background clipboard service ended: {e}");
std::process::exit(1);
} }
std::process::exit(0); std::process::exit(0);
}, },

View file

@ -456,6 +456,9 @@ fn handle_regular_paste(
bail!("no content available and --no-newline specified"); bail!("no content available and --no-newline specified");
} }
if let Err(e) = out.write_all(&buf) { if let Err(e) = out.write_all(&buf) {
if e.kind() == io::ErrorKind::BrokenPipe {
return Ok(());
}
bail!("failed to write to stdout: {e}"); bail!("failed to write to stdout: {e}");
} }
@ -471,13 +474,13 @@ fn handle_regular_paste(
|| types == "application/x-sh" || types == "application/x-sh"
}; };
if !args.no_newline if !args.no_newline && is_text_content && !buf.ends_with(b"\n") {
&& is_text_content if let Err(e) = out.write_all(b"\n") {
&& !buf.ends_with(b"\n") if e.kind() != io::ErrorKind::BrokenPipe {
&& let Err(e) = out.write_all(b"\n")
{
bail!("failed to write newline to stdout: {e}"); bail!("failed to write newline to stdout: {e}");
} }
}
}
}, },
Err(PasteError::NoSeats) => { Err(PasteError::NoSeats) => {
bail!("no seats available (is a Wayland compositor running?)"); bail!("no seats available (is a Wayland compositor running?)");