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:
Hunter Bown
2026-04-28 00:01:10 -05:00
parent 693fbca4ea
commit f3ada0be88
4 changed files with 1284 additions and 6 deletions
Generated
+679 -6
View File
@@ -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",
]
+1
View File
@@ -9,6 +9,7 @@ members = [
"crates/hooks",
"crates/mcp",
"crates/protocol",
"crates/secrets",
"crates/state",
"crates/tools",
"crates/tui",
+26
View File
@@ -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"
+578
View File
@@ -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()
);
}
}