diff --git a/Cargo.lock b/Cargo.lock index 3b17bd98..825df740 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", +] diff --git a/Cargo.toml b/Cargo.toml index a682f491..4ff789c8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/hooks", "crates/mcp", "crates/protocol", + "crates/secrets", "crates/state", "crates/tools", "crates/tui", diff --git a/crates/secrets/Cargo.toml b/crates/secrets/Cargo.toml new file mode 100644 index 00000000..fcc383c3 --- /dev/null +++ b/crates/secrets/Cargo.toml @@ -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" diff --git a/crates/secrets/src/lib.rs b/crates/secrets/src/lib.rs new file mode 100644 index 00000000..3856f0d0 --- /dev/null +++ b/crates/secrets/src/lib.rs @@ -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 `. +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, 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) -> 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, 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>, +} + +impl InMemoryKeyringStore { + /// Create an empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } +} + +impl KeyringStore for InMemoryKeyringStore { + fn get(&self, key: &str) -> Result, 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 `/.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, +} + +impl FileKeyringStore { + /// Build a store backed by the given JSON file path. + #[must_use] + pub fn new(path: impl Into) -> Self { + Self { path: path.into() } + } + + /// Default path: `/.deepseek/secrets/secrets.json`. Honours + /// `HOME` (Unix) and `USERPROFILE` (Windows) via the `dirs` crate. + pub fn default_path() -> Result { + 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 { + 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, 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, + /// 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) -> 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 { + 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, 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 { + 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> = 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 `/.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() + ); + } +}