mirror of
https://github.com/NotAShelf/stash.git
synced 2026-05-06 15:48:49 +00:00
Compare commits
5 commits
main
...
notashelf/
| Author | SHA1 | Date | |
|---|---|---|---|
|
86ed3abfae |
|||
|
7866af166e |
|||
|
d013901396 |
|||
|
d78cbd6741 |
|||
|
5153b4d19c |
12 changed files with 1170 additions and 366 deletions
6
.github/workflows/release.yaml
vendored
6
.github/workflows/release.yaml
vendored
|
|
@ -40,7 +40,7 @@ jobs:
|
|||
steps:
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@v2
|
||||
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@v3
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ${{ matrix.name }}
|
||||
|
||||
|
|
@ -120,7 +120,7 @@ jobs:
|
|||
sha256sum stash-* > SHA256SUMS
|
||||
|
||||
- name: Upload Checksums
|
||||
uses: softprops/action-gh-release@v3
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
files: SHA256SUMS
|
||||
|
|
|
|||
649
Cargo.lock
generated
649
Cargo.lock
generated
|
|
@ -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.4.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[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"
|
||||
|
|
@ -1135,12 +1339,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"
|
||||
|
|
@ -1289,6 +1577,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"
|
||||
|
|
@ -1315,6 +1612,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"
|
||||
|
|
@ -1401,9 +1723,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.185"
|
||||
version = "0.2.184"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
|
|
@ -1715,6 +2037,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"
|
||||
|
|
@ -1785,6 +2113,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"
|
||||
|
|
@ -1897,6 +2235,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"
|
||||
|
|
@ -1934,6 +2292,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"
|
||||
|
|
@ -1964,6 +2333,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"
|
||||
|
|
@ -1983,6 +2361,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"
|
||||
|
|
@ -2037,6 +2437,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",
|
||||
]
|
||||
|
||||
|
|
@ -2045,6 +2457,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"
|
||||
|
|
@ -2205,12 +2620,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"
|
||||
|
|
@ -2245,12 +2706,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.28"
|
||||
|
|
@ -2425,8 +2939,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
|||
name = "stash-clipboard"
|
||||
version = "0.3.6"
|
||||
dependencies = [
|
||||
"age",
|
||||
"arc-swap",
|
||||
"base64",
|
||||
"base64 0.22.1",
|
||||
"blocking",
|
||||
"clap",
|
||||
"clap-verbosity-flag",
|
||||
|
|
@ -2491,6 +3006,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"
|
||||
|
|
@ -2577,7 +3098,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",
|
||||
|
|
@ -2689,9 +3210,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"
|
||||
|
|
@ -2786,6 +3317,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"
|
||||
|
|
@ -2809,6 +3349,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"
|
||||
|
|
@ -2844,6 +3403,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"
|
||||
|
|
@ -2908,6 +3477,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"
|
||||
|
|
@ -3170,6 +3749,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"
|
||||
|
|
@ -3441,6 +4029,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"
|
||||
|
|
@ -3525,6 +4125,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"
|
||||
|
|
@ -3546,6 +4166,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"
|
||||
|
|
@ -3563,6 +4203,7 @@ version = "0.11.6"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
|
|
|
|||
|
|
@ -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.1", optional = true }
|
||||
base64 = "0.22.1"
|
||||
blocking = "1.6.2"
|
||||
|
|
@ -27,7 +28,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.185"
|
||||
libc = "0.2.184"
|
||||
log = "0.4.29"
|
||||
mime-sniffer = "0.1.3"
|
||||
notify-rust = { version = "4.14.0", optional = true }
|
||||
|
|
|
|||
121
README.md
121
README.md
|
|
@ -20,9 +20,10 @@
|
|||
</div>
|
||||
|
||||
<div align="center">
|
||||
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.
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
|
|
@ -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:
|
||||
|
||||
|
|
@ -124,7 +126,7 @@ releases are made when a version gets tagged, and are available under
|
|||
- Build and install from source with Cargo:
|
||||
|
||||
```bash
|
||||
cargo install stash-clipboard --locked
|
||||
cargo install stash --locked
|
||||
```
|
||||
|
||||
Additionally, you may get Stash from source via `cargo install` using
|
||||
|
|
@ -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
|
||||
|
|
|
|||
12
flake.lock
generated
12
flake.lock
generated
|
|
@ -2,11 +2,11 @@
|
|||
"nodes": {
|
||||
"crane": {
|
||||
"locked": {
|
||||
"lastModified": 1777830388,
|
||||
"narHash": "sha256-2uoQAqUk2H0ijQtGiWAyNeQYGYc6yfAcRRLlJAz4Gp8=",
|
||||
"lastModified": 1775839657,
|
||||
"narHash": "sha256-SPm9ck7jh3Un9nwPuMGbRU04UroFmOHjLP56T10MOeM=",
|
||||
"owner": "ipetkov",
|
||||
"repo": "crane",
|
||||
"rev": "d459c1350e96ce1a7e3859c513ef5e9869d67d6f",
|
||||
"rev": "7cf72d978629469c4bd4206b95c402514c1f6000",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
@ -17,11 +17,11 @@
|
|||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1777954456,
|
||||
"narHash": "sha256-hGdgeU2Nk87RAuZyYjyDjFL6LK7dAZN5RE9+hrDTkDU=",
|
||||
"lastModified": 1775710090,
|
||||
"narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "549bd84d6279f9852cae6225e372cc67fb91a4c1",
|
||||
"rev": "4c1018dae018162ec878d42fec712642d214fdfa",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
|
|
|||
|
|
@ -196,7 +196,8 @@ fn serve_clipboard_child(prepared: PreparedCopy) {
|
|||
},
|
||||
|
||||
Err(e) => {
|
||||
log::debug!("clipboard persistence: serve ended: {e}");
|
||||
log::error!("clipboard persistence: serve failed: {e}");
|
||||
exit(1);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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::Autodetect,
|
||||
None => MimeType::Text,
|
||||
};
|
||||
let copy_result = opts
|
||||
.copy(Source::Bytes(contents.clone().into()), mime_type);
|
||||
|
|
|
|||
|
|
@ -435,14 +435,6 @@ 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);
|
||||
|
|
@ -526,8 +518,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");
|
||||
}
|
||||
|
||||
|
|
@ -558,14 +550,14 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_pick_html_fallback_when_only_html() {
|
||||
// text/html is used when it is the only offered type.
|
||||
// When text/html is the only type, pick it
|
||||
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() {
|
||||
// html + plain with no image type; plain text wins over html.
|
||||
// Rich text copy: html + plain, no image — prefer plain text
|
||||
let offered = vec!["text/html".to_string(), "text/plain".to_string()];
|
||||
assert_eq!(pick_mime(&offered, "any").unwrap(), "text/plain");
|
||||
}
|
||||
|
|
|
|||
677
src/db/mod.rs
677
src/db/mod.rs
|
|
@ -215,12 +215,18 @@ pub enum StashError {
|
|||
QueryDelete(Box<str>),
|
||||
#[error("Failed to delete entry with id {0}: {1}")]
|
||||
DeleteEntry(i64, Box<str>),
|
||||
|
||||
#[error("Encryption error: {0}")]
|
||||
Encryption(Box<str>),
|
||||
#[error("Decryption error: {0}")]
|
||||
Decryption(Box<str>),
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -338,87 +344,215 @@ 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(migration_err)?;
|
||||
tx.pragma_update(None, "user_version", 1i64)
|
||||
.map_err(migration_err)?;
|
||||
.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())
|
||||
})?;
|
||||
}
|
||||
|
||||
// Add content_hash column if it doesn't exist. Migration MUST be done to
|
||||
// avoid breaking existing installations.
|
||||
if schema_version < 2 {
|
||||
if !column_exists(&tx, "content_hash") {
|
||||
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 {
|
||||
tx.execute("ALTER TABLE clipboard ADD COLUMN content_hash INTEGER", [])
|
||||
.map_err(migration_err)?;
|
||||
.map_err(|e| {
|
||||
StashError::Store(
|
||||
format!("Failed to add content_hash column: {e}").into(),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
// 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(migration_err)?;
|
||||
tx.pragma_update(None, "user_version", 2i64)
|
||||
.map_err(migration_err)?;
|
||||
.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())
|
||||
})?;
|
||||
}
|
||||
|
||||
// Add last_accessed column if it doesn't exist
|
||||
if schema_version < 3 {
|
||||
if !column_exists(&tx, "last_accessed") {
|
||||
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 {
|
||||
tx.execute("ALTER TABLE clipboard ADD COLUMN last_accessed INTEGER", [
|
||||
])
|
||||
.map_err(migration_err)?;
|
||||
.map_err(|e| {
|
||||
StashError::Store(
|
||||
format!("Failed to add last_accessed column: {e}").into(),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
// 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(migration_err)?;
|
||||
tx.pragma_update(None, "user_version", 3i64)
|
||||
.map_err(migration_err)?;
|
||||
.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())
|
||||
})?;
|
||||
}
|
||||
|
||||
// Add expires_at column if it doesn't exist (v4)
|
||||
if schema_version < 4 {
|
||||
if !column_exists(&tx, "expires_at") {
|
||||
let has_expires_at: bool = tx
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND \
|
||||
name='clipboard'",
|
||||
[],
|
||||
|row| {
|
||||
let sql: String = row.get(0)?;
|
||||
Ok(sql.to_lowercase().contains("expires_at"))
|
||||
},
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_expires_at {
|
||||
tx.execute("ALTER TABLE clipboard ADD COLUMN expires_at REAL", [])
|
||||
.map_err(migration_err)?;
|
||||
.map_err(|e| {
|
||||
StashError::Store(
|
||||
format!("Failed to add expires_at column: {e}").into(),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
// Create partial index for expires_at (only index non-NULL values)
|
||||
tx.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_expires_at ON clipboard(expires_at) \
|
||||
WHERE expires_at IS NOT NULL",
|
||||
[],
|
||||
)
|
||||
.map_err(migration_err)?;
|
||||
tx.pragma_update(None, "user_version", 4i64)
|
||||
.map_err(migration_err)?;
|
||||
.map_err(|e| {
|
||||
StashError::Store(
|
||||
format!("Failed to create expires_at index: {e}").into(),
|
||||
)
|
||||
})?;
|
||||
|
||||
tx.execute("PRAGMA user_version = 4", []).map_err(|e| {
|
||||
StashError::Store(format!("Failed to set schema version: {e}").into())
|
||||
})?;
|
||||
}
|
||||
|
||||
// Add is_expired column if it doesn't exist (v5)
|
||||
if schema_version < 5 {
|
||||
if !column_exists(&tx, "is_expired") {
|
||||
let has_is_expired: bool = tx
|
||||
.query_row(
|
||||
"SELECT sql FROM sqlite_master WHERE type='table' AND \
|
||||
name='clipboard'",
|
||||
[],
|
||||
|row| {
|
||||
let sql: String = row.get(0)?;
|
||||
Ok(sql.to_lowercase().contains("is_expired"))
|
||||
},
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !has_is_expired {
|
||||
tx.execute(
|
||||
"ALTER TABLE clipboard ADD COLUMN is_expired INTEGER DEFAULT 0",
|
||||
[],
|
||||
)
|
||||
.map_err(migration_err)?;
|
||||
.map_err(|e| {
|
||||
StashError::Store(
|
||||
format!("Failed to add is_expired column: {e}").into(),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
// Create index for is_expired (for filtering)
|
||||
tx.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_is_expired ON clipboard(is_expired) \
|
||||
WHERE is_expired = 1",
|
||||
[],
|
||||
)
|
||||
.map_err(migration_err)?;
|
||||
tx.pragma_update(None, "user_version", 5i64)
|
||||
.map_err(migration_err)?;
|
||||
.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())
|
||||
})?;
|
||||
}
|
||||
|
||||
// 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 !column_exists(&tx, "mime_types") {
|
||||
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 {
|
||||
tx.execute("ALTER TABLE clipboard ADD COLUMN mime_types TEXT", [])
|
||||
.map_err(migration_err)?;
|
||||
.map_err(|e| {
|
||||
StashError::Store(
|
||||
format!("Failed to add mime_types column: {e}").into(),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
tx.pragma_update(None, "user_version", 6i64)
|
||||
.map_err(migration_err)?;
|
||||
|
||||
tx.execute("PRAGMA user_version = 6", []).map_err(|e| {
|
||||
StashError::Store(format!("Failed to set schema version: {e}").into())
|
||||
})?;
|
||||
}
|
||||
|
||||
tx.commit().map_err(|e| {
|
||||
|
|
@ -427,29 +561,14 @@ 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,
|
||||
|
|
@ -482,11 +601,24 @@ 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) => {
|
||||
warn!("skipping entry {id}: 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,
|
||||
|
|
@ -576,13 +708,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()
|
||||
|
|
@ -652,7 +793,7 @@ impl ClipboardDb for SqliteClipboardDb {
|
|||
.conn
|
||||
.execute(
|
||||
"DELETE FROM clipboard WHERE id IN (SELECT id FROM clipboard ORDER \
|
||||
BY COALESCE(last_accessed, 0) ASC, id ASC LIMIT ?1)",
|
||||
BY id ASC LIMIT ?1)",
|
||||
params![i64::try_from(to_delete).unwrap_or(i64::MAX)],
|
||||
)
|
||||
.map_err(|e| StashError::Trim(e.to_string().into()))?;
|
||||
|
|
@ -724,8 +865,20 @@ impl ClipboardDb for SqliteClipboardDb {
|
|||
let mime: Option<String> = row
|
||||
.get(2)
|
||||
.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 = preview_entry(&contents, mime.as_deref(), preview_width);
|
||||
let preview =
|
||||
preview_entry(&preview_contents, mime.as_deref(), preview_width);
|
||||
if writeln!(out, "{id}\t{preview}").is_ok() {
|
||||
listed += 1;
|
||||
}
|
||||
|
|
@ -759,8 +912,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(&[0x01u8]) {
|
||||
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(())
|
||||
|
|
@ -785,7 +945,23 @@ impl ClipboardDb for SqliteClipboardDb {
|
|||
let contents: Vec<u8> = 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(&[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
|
||||
.conn
|
||||
.execute("DELETE FROM clipboard WHERE id = ?1", params![id])
|
||||
|
|
@ -815,25 +991,48 @@ impl ClipboardDb for SqliteClipboardDb {
|
|||
&self,
|
||||
id: i64,
|
||||
) -> Result<(i64, Vec<u8>, Option<String>), StashError> {
|
||||
let (contents, mime): (Vec<u8>, Option<String>) = 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()))?;
|
||||
let (contents, mime, content_hash): (Vec<u8>, Option<String>, Option<i64>) =
|
||||
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()))?;
|
||||
|
||||
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()))?;
|
||||
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()))?;
|
||||
|
||||
Ok((id, contents, mime))
|
||||
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()))?;
|
||||
}
|
||||
}
|
||||
|
||||
let decrypted_contents = if contents.starts_with(&[0x01u8]) {
|
||||
decrypt_data(&contents)?
|
||||
} else {
|
||||
contents
|
||||
};
|
||||
|
||||
Ok((id, decrypted_contents, mime))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -908,7 +1107,20 @@ impl SqliteClipboardDb {
|
|||
let mime: Option<String> = 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(&[0x01u8]) {
|
||||
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();
|
||||
window.push((id, preview, mime_str));
|
||||
}
|
||||
|
|
@ -1025,10 +1237,23 @@ impl SqliteClipboardDb {
|
|||
/// changes made after daemon startup. Regex compilation is cached by
|
||||
/// pattern to avoid recompilation.
|
||||
fn load_sensitive_regex() -> Option<Regex> {
|
||||
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()
|
||||
}?;
|
||||
|
|
@ -1055,6 +1280,68 @@ 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> {
|
||||
let id_str = input.split('\t').next().unwrap_or("");
|
||||
id_str.parse().map_err(|_| "invalid id")
|
||||
|
|
@ -1123,10 +1410,17 @@ 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 {
|
||||
match excluded_apps {
|
||||
Some(apps) if !apps.is_empty() => detect_excluded_app_activity(apps),
|
||||
_ => false,
|
||||
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;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Detect if clipboard likely came from an excluded app using multiple
|
||||
|
|
@ -2101,231 +2395,4 @@ 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<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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ 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,
|
||||
}
|
||||
|
|
@ -73,11 +72,25 @@ 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()))?;
|
||||
stmt
|
||||
.query_map([], |row| Ok((row.get::<_, f64>(0)?, row.get::<_, i64>(1)?)))
|
||||
|
||||
let mut rows = stmt
|
||||
.query([])
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
let mut expirations = Vec::new();
|
||||
|
||||
while let Some(row) = rows
|
||||
.next()
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))
|
||||
{
|
||||
let exp = row
|
||||
.get::<_, f64>(0)
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
let id = row
|
||||
.get::<_, i64>(1)
|
||||
.map_err(|e| StashError::ListDecode(e.to_string().into()))?;
|
||||
expirations.push((exp, id));
|
||||
}
|
||||
Ok(expirations)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
|
@ -123,6 +136,14 @@ 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};
|
||||
|
|
|
|||
|
|
@ -222,7 +222,8 @@ 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::debug!("background clipboard service ended: {e}");
|
||||
log::error!("background clipboard service failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
std::process::exit(0);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -456,9 +456,6 @@ 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}");
|
||||
}
|
||||
|
||||
|
|
@ -474,12 +471,12 @@ fn handle_regular_paste(
|
|||
|| types == "application/x-sh"
|
||||
};
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
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}");
|
||||
}
|
||||
},
|
||||
Err(PasteError::NoSeats) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue