chore(secrets): #134 scaffold deepseek-secrets crate
Adds the `deepseek-secrets` crate with the OS keyring backend, in-memory store for tests, and a JSON-on-disk fallback for headless environments. The Secrets façade collapses keyring -> env into a single resolver; callers layer on CLI flags above and TOML config below to preserve the keyring -> env -> config-file precedence. * `KeyringStore` trait + `DefaultKeyringStore` (keyring 3.6 with per-platform native features). * `InMemoryKeyringStore` for unit tests. * `FileKeyringStore` writes ~/.deepseek/secrets/secrets.json with mode 0600 on unix; rejects world-readable files at read time. * `Secrets::auto_detect` probes the OS keyring and falls back to the file store on headless Linux. * 9 unit tests covering round-trips, precedence, and 0600 perms. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+679
-6
@@ -27,6 +27,17 @@ dependencies = [
|
||||
"pom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -197,6 +208,106 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix 1.1.3",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix 1.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"atomic-waker",
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix 1.1.3",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-stream"
|
||||
version = "0.3.6"
|
||||
@@ -219,6 +330,12 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -364,6 +481,28 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-task",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.1"
|
||||
@@ -419,6 +558,15 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.52"
|
||||
@@ -475,6 +623,16 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.54"
|
||||
@@ -587,6 +745,15 @@ dependencies = [
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.16.2"
|
||||
@@ -635,6 +802,15 @@ version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -775,6 +951,35 @@ dependencies = [
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbus"
|
||||
version = "0.9.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libdbus-sys",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dbus-secret-service"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"block-padding",
|
||||
"cbc",
|
||||
"dbus",
|
||||
"fastrand",
|
||||
"hkdf",
|
||||
"num",
|
||||
"once_cell",
|
||||
"sha2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deadpool"
|
||||
version = "0.12.3"
|
||||
@@ -840,10 +1045,12 @@ name = "deepseek-config"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"deepseek-secrets",
|
||||
"dirs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -906,6 +1113,19 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-secrets"
|
||||
version = "0.6.7"
|
||||
dependencies = [
|
||||
"dirs",
|
||||
"keyring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deepseek-state"
|
||||
version = "0.6.7"
|
||||
@@ -948,6 +1168,7 @@ dependencies = [
|
||||
"colored",
|
||||
"crossterm",
|
||||
"csv",
|
||||
"deepseek-secrets",
|
||||
"dirs",
|
||||
"dotenvy",
|
||||
"futures-util",
|
||||
@@ -999,6 +1220,7 @@ dependencies = [
|
||||
"deepseek-config",
|
||||
"deepseek-execpolicy",
|
||||
"deepseek-mcp",
|
||||
"deepseek-secrets",
|
||||
"deepseek-state",
|
||||
"dirs",
|
||||
"serde",
|
||||
@@ -1068,6 +1290,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1211,12 +1434,39 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "endian-type"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d"
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||
dependencies = [
|
||||
"enumflags2_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2_derive"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1257,6 +1507,27 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener-strategy"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
@@ -1438,6 +1709,19 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||
|
||||
[[package]]
|
||||
name = "futures-lite"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.31"
|
||||
@@ -1626,6 +1910,30 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "hex"
|
||||
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 = "home"
|
||||
version = "0.5.12"
|
||||
@@ -1959,6 +2267,16 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.11"
|
||||
@@ -2089,6 +2407,23 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"dbus-secret-service",
|
||||
"linux-keyutils",
|
||||
"log",
|
||||
"secret-service",
|
||||
"security-framework 2.11.1",
|
||||
"security-framework 3.5.1",
|
||||
"windows-sys 0.60.2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lalrpop"
|
||||
version = "0.19.12"
|
||||
@@ -2132,6 +2467,15 @@ version = "0.2.180"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
|
||||
[[package]]
|
||||
name = "libdbus-sys"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.12"
|
||||
@@ -2153,6 +2497,16 @@ dependencies = [
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-keyutils"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.4.15"
|
||||
@@ -2292,6 +2646,15 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -2397,7 +2760,7 @@ dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"memoffset",
|
||||
"memoffset 0.6.5",
|
||||
"pin-utils",
|
||||
]
|
||||
|
||||
@@ -2423,6 +2786,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"cfg_aliases 0.2.1",
|
||||
"libc",
|
||||
"memoffset 0.9.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2435,6 +2799,20 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
@@ -2445,6 +2823,15 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.0"
|
||||
@@ -2460,6 +2847,28 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -2620,6 +3029,22 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -2701,6 +3126,17 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "piper"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
@@ -2720,6 +3156,20 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix 1.1.3",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pom"
|
||||
version = "1.1.0"
|
||||
@@ -2799,6 +3249,15 @@ dependencies = [
|
||||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.105"
|
||||
@@ -2853,7 +3312,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -2904,14 +3363,35 @@ dependencies = [
|
||||
"nibble_vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[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 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2921,7 +3401,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3349,6 +3838,25 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "secret-service"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
"futures-util",
|
||||
"generic-array",
|
||||
"hkdf",
|
||||
"num",
|
||||
"once_cell",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"sha2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
@@ -3513,6 +4021,28 @@ dependencies = [
|
||||
"serial-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_library"
|
||||
version = "0.1.9"
|
||||
@@ -3636,7 +4166,7 @@ dependencies = [
|
||||
"inventory",
|
||||
"itertools 0.13.0",
|
||||
"maplit",
|
||||
"memoffset",
|
||||
"memoffset 0.6.5",
|
||||
"num-bigint",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
@@ -4074,6 +4604,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.23.10+spec-1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
@@ -4188,6 +4730,17 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset 0.9.1",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
@@ -4869,6 +5422,9 @@ name = "winnow"
|
||||
version = "0.7.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
@@ -4931,6 +5487,16 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xdg-home"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yansi"
|
||||
version = "1.0.1"
|
||||
@@ -4960,6 +5526,62 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "4.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-trait",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"nix 0.29.0",
|
||||
"ordered-stream",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"sha1",
|
||||
"static_assertions",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"windows-sys 0.52.0",
|
||||
"xdg-home",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "4.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.33"
|
||||
@@ -5006,6 +5628,20 @@ 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.114",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
@@ -5060,3 +5696,40 @@ checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"static_assertions",
|
||||
"zvariant_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "4.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.114",
|
||||
]
|
||||
|
||||
@@ -9,6 +9,7 @@ members = [
|
||||
"crates/hooks",
|
||||
"crates/mcp",
|
||||
"crates/protocol",
|
||||
"crates/secrets",
|
||||
"crates/state",
|
||||
"crates/tools",
|
||||
"crates/tui",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "deepseek-secrets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Secret storage backends (OS keyring with file fallback) for DeepSeek workspace"
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
keyring = { version = "3", features = ["apple-native"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
keyring = { version = "3", features = ["windows-native"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
keyring = { version = "3", features = ["linux-native-sync-persistent", "crypto-rust"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.16"
|
||||
@@ -0,0 +1,578 @@
|
||||
//! Secret storage for DeepSeek API keys.
|
||||
//!
|
||||
//! Provides a small abstraction (`KeyringStore`) plus a default
|
||||
//! implementation backed by the OS keyring (`DefaultKeyringStore`),
|
||||
//! a file-based fallback for headless Linux (`FileKeyringStore`), and
|
||||
//! an in-memory store for tests (`InMemoryKeyringStore`).
|
||||
//!
|
||||
//! Higher-level lookup goes through [`Secrets::resolve`], which checks
|
||||
//! the keyring first and falls back to environment variables. The
|
||||
//! caller (typically the config crate) then falls back to plaintext
|
||||
//! TOML if both are empty — that final layer lives outside this crate
|
||||
//! so the precedence is explicit at the call site.
|
||||
//!
|
||||
//! Hard rule: **keyring → env → config-file**. Never swap.
|
||||
#![deny(missing_docs)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Default OS keychain service name. macOS users can verify entries with
|
||||
/// `security find-generic-password -s deepseek -a <provider>`.
|
||||
pub const DEFAULT_SERVICE: &str = "deepseek";
|
||||
|
||||
/// Errors that may arise from a [`KeyringStore`] backend.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SecretsError {
|
||||
/// Underlying OS keyring backend reported an error.
|
||||
#[error("keyring backend error: {0}")]
|
||||
Keyring(String),
|
||||
/// File-backed fallback I/O error.
|
||||
#[error("file-backed secret store I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
/// File-backed fallback JSON (de)serialisation error.
|
||||
#[error("file-backed secret store JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
/// Caught when a stored secret on disk has unsafe permissions.
|
||||
#[error("file-backed secret store at {path} has insecure permissions {mode:o} (expected 0600)")]
|
||||
InsecurePermissions {
|
||||
/// Absolute path to the secrets file.
|
||||
path: PathBuf,
|
||||
/// Observed unix permission mode.
|
||||
mode: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// Abstract secret store; concrete implementations may use the OS
|
||||
/// keyring, a JSON file under `~/.deepseek/secrets/`, or an in-memory
|
||||
/// map (tests).
|
||||
pub trait KeyringStore: Send + Sync {
|
||||
/// Read a secret. Returns `Ok(None)` if no entry exists.
|
||||
fn get(&self, key: &str) -> Result<Option<String>, SecretsError>;
|
||||
/// Write a secret, replacing any existing value.
|
||||
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError>;
|
||||
/// Remove a secret. Should not error if the entry is absent.
|
||||
fn delete(&self, key: &str) -> Result<(), SecretsError>;
|
||||
/// Short, human-readable name of the backend (used by `doctor`).
|
||||
fn backend_name(&self) -> &'static str;
|
||||
}
|
||||
|
||||
/// OS keyring backend (macOS Keychain, Windows Credential Manager,
|
||||
/// Linux Secret Service / kwallet).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DefaultKeyringStore {
|
||||
/// Keyring service name (defaults to [`DEFAULT_SERVICE`]).
|
||||
service: String,
|
||||
}
|
||||
|
||||
impl Default for DefaultKeyringStore {
|
||||
fn default() -> Self {
|
||||
Self::new(DEFAULT_SERVICE)
|
||||
}
|
||||
}
|
||||
|
||||
impl DefaultKeyringStore {
|
||||
/// Build a new store with the given service name.
|
||||
#[must_use]
|
||||
pub fn new(service: impl Into<String>) -> Self {
|
||||
Self {
|
||||
service: service.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe the OS keyring without writing anything. Returns `Ok(())` if
|
||||
/// a backend is reachable, otherwise an error describing why not.
|
||||
pub fn probe(&self) -> Result<(), SecretsError> {
|
||||
// `Entry::new` is enough to surface "no backend / no storage" on
|
||||
// headless Linux; no actual read happens until `.get_password()`.
|
||||
let entry = keyring::Entry::new(&self.service, "__probe__")
|
||||
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
|
||||
match entry.get_password() {
|
||||
Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(keyring::Error::PlatformFailure(err)) => {
|
||||
Err(SecretsError::Keyring(format!("platform failure: {err}")))
|
||||
}
|
||||
Err(keyring::Error::NoStorageAccess(err)) => {
|
||||
Err(SecretsError::Keyring(format!("no storage access: {err}")))
|
||||
}
|
||||
Err(other) => Err(SecretsError::Keyring(other.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyringStore for DefaultKeyringStore {
|
||||
fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
|
||||
let entry = keyring::Entry::new(&self.service, key)
|
||||
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
|
||||
match entry.get_password() {
|
||||
Ok(value) => Ok(Some(value)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(err) => Err(SecretsError::Keyring(err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
|
||||
let entry = keyring::Entry::new(&self.service, key)
|
||||
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
|
||||
entry
|
||||
.set_password(value)
|
||||
.map_err(|err| SecretsError::Keyring(err.to_string()))
|
||||
}
|
||||
|
||||
fn delete(&self, key: &str) -> Result<(), SecretsError> {
|
||||
let entry = keyring::Entry::new(&self.service, key)
|
||||
.map_err(|err| SecretsError::Keyring(err.to_string()))?;
|
||||
match entry.delete_credential() {
|
||||
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(err) => Err(SecretsError::Keyring(err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
fn backend_name(&self) -> &'static str {
|
||||
"system keyring"
|
||||
}
|
||||
}
|
||||
|
||||
/// In-memory keyring (tests only).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct InMemoryKeyringStore {
|
||||
entries: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl InMemoryKeyringStore {
|
||||
/// Create an empty store.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyringStore for InMemoryKeyringStore {
|
||||
fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
|
||||
Ok(self.entries.lock().unwrap().get(key).cloned())
|
||||
}
|
||||
|
||||
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
|
||||
self.entries
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(key.to_string(), value.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete(&self, key: &str) -> Result<(), SecretsError> {
|
||||
self.entries.lock().unwrap().remove(key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn backend_name(&self) -> &'static str {
|
||||
"in-memory (test)"
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON-on-disk fallback for headless environments without a Secret
|
||||
/// Service / dbus. Stored at `<home>/.deepseek/secrets/secrets.json`
|
||||
/// with mode `0600`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileKeyringStore {
|
||||
/// Absolute path to the JSON file.
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct FileSecretsBlob {
|
||||
#[serde(default)]
|
||||
entries: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl FileKeyringStore {
|
||||
/// Build a store backed by the given JSON file path.
|
||||
#[must_use]
|
||||
pub fn new(path: impl Into<PathBuf>) -> Self {
|
||||
Self { path: path.into() }
|
||||
}
|
||||
|
||||
/// Default path: `<home>/.deepseek/secrets/secrets.json`. Honours
|
||||
/// `HOME` (Unix) and `USERPROFILE` (Windows) via the `dirs` crate.
|
||||
pub fn default_path() -> Result<PathBuf, SecretsError> {
|
||||
let home = dirs::home_dir().ok_or_else(|| {
|
||||
SecretsError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"could not resolve home directory for FileKeyringStore",
|
||||
))
|
||||
})?;
|
||||
Ok(home.join(".deepseek").join("secrets").join("secrets.json"))
|
||||
}
|
||||
|
||||
/// Path used for storage.
|
||||
#[must_use]
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
fn load_unlocked(&self) -> Result<FileSecretsBlob, SecretsError> {
|
||||
if !self.path.exists() {
|
||||
return Ok(FileSecretsBlob::default());
|
||||
}
|
||||
// Reject files with unsafe permissions on unix. On Windows the
|
||||
// ACL model is too different to enforce here; the caller is
|
||||
// responsible for placing the file in a per-user directory.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let meta = fs::metadata(&self.path)?;
|
||||
let mode = meta.permissions().mode() & 0o777;
|
||||
if mode & 0o077 != 0 {
|
||||
return Err(SecretsError::InsecurePermissions {
|
||||
path: self.path.clone(),
|
||||
mode,
|
||||
});
|
||||
}
|
||||
}
|
||||
let raw = fs::read_to_string(&self.path)?;
|
||||
if raw.trim().is_empty() {
|
||||
return Ok(FileSecretsBlob::default());
|
||||
}
|
||||
let blob: FileSecretsBlob = serde_json::from_str(&raw)?;
|
||||
Ok(blob)
|
||||
}
|
||||
|
||||
fn store_unlocked(&self, blob: &FileSecretsBlob) -> Result<(), SecretsError> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(parent)?.permissions();
|
||||
perms.set_mode(0o700);
|
||||
let _ = fs::set_permissions(parent, perms);
|
||||
}
|
||||
}
|
||||
let body = serde_json::to_string_pretty(blob)?;
|
||||
fs::write(&self.path, body)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(&self.path)?.permissions();
|
||||
perms.set_mode(0o600);
|
||||
fs::set_permissions(&self.path, perms)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyringStore for FileKeyringStore {
|
||||
fn get(&self, key: &str) -> Result<Option<String>, SecretsError> {
|
||||
let blob = self.load_unlocked()?;
|
||||
Ok(blob.entries.get(key).cloned())
|
||||
}
|
||||
|
||||
fn set(&self, key: &str, value: &str) -> Result<(), SecretsError> {
|
||||
let mut blob = self.load_unlocked().unwrap_or_default();
|
||||
blob.entries.insert(key.to_string(), value.to_string());
|
||||
self.store_unlocked(&blob)
|
||||
}
|
||||
|
||||
fn delete(&self, key: &str) -> Result<(), SecretsError> {
|
||||
let mut blob = self.load_unlocked().unwrap_or_default();
|
||||
blob.entries.remove(key);
|
||||
self.store_unlocked(&blob)
|
||||
}
|
||||
|
||||
fn backend_name(&self) -> &'static str {
|
||||
"file-based (~/.deepseek/secrets/)"
|
||||
}
|
||||
}
|
||||
|
||||
/// High-level façade combining a [`KeyringStore`] with environment
|
||||
/// variable fallbacks.
|
||||
///
|
||||
/// Lookup precedence: **keyring → env → none**. Callers that also have
|
||||
/// a TOML config layer must wire that themselves at the very end of
|
||||
/// the chain.
|
||||
#[derive(Clone)]
|
||||
pub struct Secrets {
|
||||
/// Underlying secret store.
|
||||
pub store: Arc<dyn KeyringStore>,
|
||||
/// Owner identifier within the keyring (typically "deepseek"); the
|
||||
/// `key` parameter passed to `resolve` is mapped to a slot in the
|
||||
/// store as-is, while envs are looked up by canonical name.
|
||||
service: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Secrets {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Secrets")
|
||||
.field("backend", &self.store.backend_name())
|
||||
.field("service", &self.service)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Secrets {
|
||||
/// Build a new façade around a store.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn KeyringStore>) -> Self {
|
||||
Self {
|
||||
store,
|
||||
service: DEFAULT_SERVICE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct the platform-appropriate default backend. On platforms
|
||||
/// where an OS keyring backend is reachable this returns
|
||||
/// [`DefaultKeyringStore`]; otherwise it falls back to
|
||||
/// [`FileKeyringStore`] under `~/.deepseek/secrets/`.
|
||||
pub fn auto_detect() -> Self {
|
||||
let default_store = DefaultKeyringStore::default();
|
||||
match default_store.probe() {
|
||||
Ok(()) => Self::new(Arc::new(default_store)),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"OS keyring unavailable ({err}); falling back to file-backed secret store"
|
||||
);
|
||||
let path = FileKeyringStore::default_path()
|
||||
.unwrap_or_else(|_| PathBuf::from(".deepseek-secrets.json"));
|
||||
Self::new(Arc::new(FileKeyringStore::new(path)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend label, suitable for `doctor` output.
|
||||
#[must_use]
|
||||
pub fn backend_name(&self) -> &'static str {
|
||||
self.store.backend_name()
|
||||
}
|
||||
|
||||
/// Resolve a secret with `keyring → env → none` precedence.
|
||||
///
|
||||
/// `name` is the canonical provider name (`"deepseek"`,
|
||||
/// `"openrouter"`, `"novita"`, `"nvidia"`/`"nvidia-nim"`, `"openai"`).
|
||||
/// Empty strings on either layer are treated as "not set".
|
||||
#[must_use]
|
||||
pub fn resolve(&self, name: &str) -> Option<String> {
|
||||
if let Ok(Some(v)) = self.store.get(name)
|
||||
&& !v.trim().is_empty()
|
||||
{
|
||||
return Some(v);
|
||||
}
|
||||
env_for(name)
|
||||
}
|
||||
|
||||
/// Convenience: write a secret through the underlying store.
|
||||
pub fn set(&self, name: &str, value: &str) -> Result<(), SecretsError> {
|
||||
self.store.set(name, value)
|
||||
}
|
||||
|
||||
/// Convenience: delete a secret through the underlying store.
|
||||
pub fn delete(&self, name: &str) -> Result<(), SecretsError> {
|
||||
self.store.delete(name)
|
||||
}
|
||||
|
||||
/// Convenience: read a secret directly (no env fallback).
|
||||
pub fn get(&self, name: &str) -> Result<Option<String>, SecretsError> {
|
||||
self.store.get(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a canonical provider name to its environment variable, returning
|
||||
/// the value if non-empty.
|
||||
#[must_use]
|
||||
pub fn env_for(name: &str) -> Option<String> {
|
||||
let candidates: &[&str] = match name.to_ascii_lowercase().as_str() {
|
||||
"deepseek" => &["DEEPSEEK_API_KEY"],
|
||||
"openrouter" => &["OPENROUTER_API_KEY"],
|
||||
"novita" => &["NOVITA_API_KEY"],
|
||||
// NVIDIA NIM falls back to `DEEPSEEK_API_KEY` last because the
|
||||
// catalog endpoint accepts the same DeepSeek-issued key when no
|
||||
// dedicated NVIDIA token is set. This mirrors pre-v0.7 behaviour.
|
||||
"nvidia" | "nvidia-nim" | "nvidia_nim" | "nim" => {
|
||||
&["NVIDIA_API_KEY", "NVIDIA_NIM_API_KEY", "DEEPSEEK_API_KEY"]
|
||||
}
|
||||
"openai" => &["OPENAI_API_KEY"],
|
||||
_ => return None,
|
||||
};
|
||||
for var in candidates {
|
||||
if let Ok(value) = std::env::var(var)
|
||||
&& !value.trim().is_empty()
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
/// Serialise env-mutating tests: tests in this module poke
|
||||
/// `DEEPSEEK_API_KEY` etc., which is process-global.
|
||||
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
|
||||
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
LOCK.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
}
|
||||
|
||||
fn clear_known_envs() {
|
||||
for var in [
|
||||
"DEEPSEEK_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"NOVITA_API_KEY",
|
||||
"NVIDIA_API_KEY",
|
||||
"NVIDIA_NIM_API_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
] {
|
||||
// Safety: tests serialise on env_lock(); the broader
|
||||
// workspace has the same pattern in `crates/config`.
|
||||
unsafe { std::env::remove_var(var) };
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn in_memory_store_round_trips() {
|
||||
let store = InMemoryKeyringStore::new();
|
||||
assert_eq!(store.get("deepseek").unwrap(), None);
|
||||
store.set("deepseek", "sk-test").unwrap();
|
||||
assert_eq!(store.get("deepseek").unwrap(), Some("sk-test".to_string()));
|
||||
store.set("deepseek", "sk-replaced").unwrap();
|
||||
assert_eq!(
|
||||
store.get("deepseek").unwrap(),
|
||||
Some("sk-replaced".to_string())
|
||||
);
|
||||
store.delete("deepseek").unwrap();
|
||||
assert_eq!(store.get("deepseek").unwrap(), None);
|
||||
// Deleting an absent key is a no-op.
|
||||
store.delete("missing").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_prefers_keyring_over_env() {
|
||||
let _lock = env_lock();
|
||||
clear_known_envs();
|
||||
// Safety: env mutation guarded by env_lock().
|
||||
unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-key") };
|
||||
|
||||
let store = Arc::new(InMemoryKeyringStore::new());
|
||||
store.set("deepseek", "ring-key").unwrap();
|
||||
let secrets = Secrets::new(store);
|
||||
|
||||
assert_eq!(secrets.resolve("deepseek").as_deref(), Some("ring-key"));
|
||||
// Safety: env mutation guarded by env_lock().
|
||||
unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_falls_back_to_env_when_keyring_empty() {
|
||||
let _lock = env_lock();
|
||||
clear_known_envs();
|
||||
// Safety: env mutation guarded by env_lock().
|
||||
unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-fallback") };
|
||||
|
||||
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
|
||||
assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-fallback"));
|
||||
// Safety: env mutation guarded by env_lock().
|
||||
unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_returns_none_when_both_layers_empty() {
|
||||
let _lock = env_lock();
|
||||
clear_known_envs();
|
||||
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
|
||||
assert_eq!(secrets.resolve("deepseek"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_treats_blank_keyring_value_as_unset() {
|
||||
let _lock = env_lock();
|
||||
clear_known_envs();
|
||||
// Safety: env mutation guarded by env_lock().
|
||||
unsafe { std::env::set_var("DEEPSEEK_API_KEY", "env-real") };
|
||||
|
||||
let store = Arc::new(InMemoryKeyringStore::new());
|
||||
store.set("deepseek", " ").unwrap();
|
||||
let secrets = Secrets::new(store);
|
||||
assert_eq!(secrets.resolve("deepseek").as_deref(), Some("env-real"));
|
||||
// Safety: env mutation guarded by env_lock().
|
||||
unsafe { std::env::remove_var("DEEPSEEK_API_KEY") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvidia_env_aliases_resolve() {
|
||||
let _lock = env_lock();
|
||||
clear_known_envs();
|
||||
// Safety: env mutation guarded by env_lock().
|
||||
unsafe { std::env::set_var("NVIDIA_NIM_API_KEY", "nim-key") };
|
||||
let secrets = Secrets::new(Arc::new(InMemoryKeyringStore::new()));
|
||||
assert_eq!(secrets.resolve("nvidia-nim").as_deref(), Some("nim-key"));
|
||||
assert_eq!(secrets.resolve("nvidia").as_deref(), Some("nim-key"));
|
||||
// Safety: env mutation guarded by env_lock().
|
||||
unsafe { std::env::remove_var("NVIDIA_NIM_API_KEY") };
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn file_store_round_trips_with_secure_perms() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("nested").join("secrets.json");
|
||||
let store = FileKeyringStore::new(path.clone());
|
||||
assert_eq!(store.get("deepseek").unwrap(), None);
|
||||
store.set("deepseek", "sk-disk").unwrap();
|
||||
assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
|
||||
|
||||
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "expected 0600, got {mode:o}");
|
||||
|
||||
store.set("openrouter", "or-disk").unwrap();
|
||||
assert_eq!(
|
||||
store.get("openrouter").unwrap(),
|
||||
Some("or-disk".to_string())
|
||||
);
|
||||
// First entry must still be intact.
|
||||
assert_eq!(store.get("deepseek").unwrap(), Some("sk-disk".to_string()));
|
||||
|
||||
store.delete("deepseek").unwrap();
|
||||
assert_eq!(store.get("deepseek").unwrap(), None);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn file_store_rejects_world_readable_file() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = tmp.path().join("secrets.json");
|
||||
fs::write(&path, "{\"entries\":{\"deepseek\":\"leak\"}}").unwrap();
|
||||
let mut perms = fs::metadata(&path).unwrap().permissions();
|
||||
perms.set_mode(0o644);
|
||||
fs::set_permissions(&path, perms).unwrap();
|
||||
|
||||
let store = FileKeyringStore::new(path);
|
||||
let err = store.get("deepseek").unwrap_err();
|
||||
assert!(
|
||||
matches!(err, SecretsError::InsecurePermissions { .. }),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_store_default_path_uses_home() {
|
||||
// We don't override HOME here (other tests do); we just check the
|
||||
// shape of the path is `<home>/.deepseek/secrets/secrets.json`.
|
||||
let path = FileKeyringStore::default_path().unwrap();
|
||||
assert!(
|
||||
path.ends_with("secrets/secrets.json") || path.ends_with("secrets\\secrets.json"),
|
||||
"unexpected default path: {}",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user