diff --git a/Cargo.lock b/Cargo.lock index 16c08dcf..7d252e14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,7 +84,7 @@ dependencies = [ "const-random", "once_cell", "version_check", - "zerocopy 0.8.27", + "zerocopy 0.8.31", ] [[package]] @@ -239,6 +239,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "as-any" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f477b951e452a0b6b4a10b53ccd569042d1d01729b519e02074a9c0958a063" + [[package]] name = "assert-json-diff" version = "2.0.2" @@ -280,7 +286,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -291,7 +297,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -332,9 +338,9 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "aws-lc-rs" -version = "1.15.0" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5932a7d9d28b0d2ea34c6b3779d35e3dd6f6345317c34e73438c4f1f29144151" +checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -343,11 +349,10 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.33.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1826f2e4cfc2cd19ee53c42fbf68e2f81ec21108e0b7ecf6a71cf062137360fc" +checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" dependencies = [ - "bindgen 0.72.1", "cc", "cmake", "dunce", @@ -392,7 +397,7 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "hyper 1.8.1", @@ -440,7 +445,7 @@ checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" dependencies = [ "bytes", "futures-core", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "mime", @@ -460,7 +465,7 @@ dependencies = [ "arc-swap", "bytes", "fs-err", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "hyper 1.8.1", "hyper-util", @@ -536,30 +541,10 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.110", + "syn 2.0.111", "which", ] -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags 2.10.0", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 2.1.1", - "shlex", - "syn 2.0.110", -] - [[package]] name = "bitflags" version = "1.3.2" @@ -624,9 +609,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytemuck" @@ -690,9 +675,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.46" +version = "1.2.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97463e1064cb1b1c1384ad0a0b9c8abd0988e2a91f52606c80ef14aadb63e36" +checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" dependencies = [ "find-msvc-tools", "jobserver", @@ -803,9 +788,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.51" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", "clap_derive 4.5.49", @@ -813,14 +798,15 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.51" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ "anstream", "anstyle", "clap_lex 0.7.6", "strsim 0.11.1", + "terminal_size", ] [[package]] @@ -845,7 +831,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -874,9 +860,9 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.54" +version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" dependencies = [ "cc", ] @@ -929,6 +915,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "condtype" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" + [[package]] name = "config" version = "0.14.1" @@ -1004,9 +996,9 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.7.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ "unicode-segmentation", ] @@ -1171,7 +1163,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1207,7 +1199,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1241,7 +1233,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1255,7 +1247,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1266,7 +1258,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1277,7 +1269,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1300,7 +1292,7 @@ checksum = "780eb241654bf097afb00fc5f054a09b687dad862e485fdcf8399bb056565370" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1331,28 +1323,29 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" dependencies = [ - "convert_case 0.7.1", + "convert_case 0.10.0", "proc-macro2", "quote", - "syn 2.0.110", + "rustc_version", + "syn 2.0.111", "unicode-xid", ] @@ -1407,7 +1400,32 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", +] + +[[package]] +name = "divan" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a405457ec78b8fe08b0e32b4a3570ab5dff6dd16eb9e76a5ee0a9d9cbd898933" +dependencies = [ + "cfg-if", + "clap 4.5.53", + "condtype", + "divan-macros", + "libc", + "regex-lite", +] + +[[package]] +name = "divan-macros" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9556bc800956545d6420a640173e5ba7dfa82f38d3ea5a167eb555bc69ac3323" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", ] [[package]] @@ -1428,6 +1446,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "downcast-rs" version = "1.2.1" @@ -1551,7 +1575,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1664,7 +1688,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -1788,9 +1812,9 @@ dependencies = [ [[package]] name = "fs-err" -version = "3.2.0" +version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62d91fd049c123429b018c47887d3f75a265540dd3c30ba9cb7bae9197edb03a" +checksum = "824f08d01d0f496b3eca4f001a13cf17690a6ee930043d20817f547455fd98f8" dependencies = [ "autocfg", "tokio", @@ -1867,7 +1891,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -2006,7 +2030,7 @@ dependencies = [ "futures-sink", "futures-util", "http 0.2.12", - "indexmap 2.12.0", + "indexmap 2.12.1", "slab", "tokio", "tokio-util", @@ -2024,8 +2048,8 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.3.1", - "indexmap 2.12.0", + "http 1.4.0", + "indexmap 2.12.1", "slab", "tokio", "tokio-util", @@ -2040,7 +2064,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", - "zerocopy 0.8.27", + "zerocopy 0.8.31", ] [[package]] @@ -2072,9 +2096,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", @@ -2169,12 +2193,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -2196,7 +2219,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.3.1", + "http 1.4.0", ] [[package]] @@ -2207,7 +2230,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "pin-project-lite", ] @@ -2265,7 +2288,7 @@ dependencies = [ "futures-channel", "futures-core", "h2 0.4.12", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "httparse", "httpdate", @@ -2301,7 +2324,7 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http 1.3.1", + "http 1.4.0", "hyper 1.8.1", "hyper-util", "rustls 0.23.35", @@ -2342,23 +2365,28 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e9a2a24dc5c6821e71a7030e1e14b7b632acac55c40e9d2e082c621261bb56" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", "futures-core", "futures-util", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "hyper 1.8.1", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2 0.6.1", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2433,9 +2461,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -2447,9 +2475,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -2522,7 +2550,7 @@ dependencies = [ "png", "tiff", "zune-core 0.5.0", - "zune-jpeg 0.5.5", + "zune-jpeg 0.5.7", ] [[package]] @@ -2537,12 +2565,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.16.1", ] [[package]] @@ -2586,15 +2614,15 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" +checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" dependencies = [ "darling 0.20.11", "indoc", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -2640,6 +2668,16 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -2732,9 +2770,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -2821,9 +2859,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "libloading" @@ -2863,13 +2901,13 @@ checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" dependencies = [ "bitflags 2.10.0", "libc", - "redox_syscall", + "redox_syscall 0.6.0", ] [[package]] @@ -2906,7 +2944,7 @@ dependencies = [ "tonic", "tonic-web", "tower 0.4.13", - "tower-http", + "tower-http 0.4.4", "tracing", "uuid", "zerocopy 0.7.35", @@ -2918,7 +2956,7 @@ version = "0.9.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6cd1c1662822495393327856774f6803be25d85bfdcd5b9d4af35458f5daaf75" dependencies = [ - "bindgen 0.66.1", + "bindgen", "cc", "cmake", "glob", @@ -2959,7 +2997,7 @@ dependencies = [ "bitflags 2.10.0", "cc", "fallible-iterator 0.3.0", - "indexmap 2.12.0", + "indexmap 2.12.1", "log", "memchr", "phf", @@ -3010,9 +3048,9 @@ dependencies = [ [[package]] name = "libz-rs-sys" -version = "0.5.2" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "840db8cf39d9ec4dd794376f38acc40d0fc65eec2a8f484f7fd375b84602becd" +checksum = "15413ef615ad868d4d65dce091cb233b229419c7c0c4bcaa746c0901c49ff39c" dependencies = [ "zlib-rs", ] @@ -3058,9 +3096,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lol_html" @@ -3072,7 +3110,7 @@ dependencies = [ "cfg-if", "cssparser", "encoding_rs", - "hashbrown 0.16.0", + "hashbrown 0.16.1", "memchr", "mime", "precomputed-hash", @@ -3143,6 +3181,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3161,9 +3209,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "log", @@ -3181,7 +3229,7 @@ dependencies = [ "bytes", "colored", "futures-core", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "hyper 1.8.1", @@ -3198,9 +3246,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.9" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" dependencies = [ "num-traits", "pxfm", @@ -3518,9 +3566,9 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "open" -version = "5.3.2" +version = "5.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" dependencies = [ "is-wsl", "libc", @@ -3550,7 +3598,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -3577,6 +3625,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-multimap" version = "0.7.3" @@ -3671,7 +3728,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link 0.2.1", ] @@ -3695,9 +3752,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pastey" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57d6c094ee800037dff99e02cab0eaf3142826586742a270ab3d7a62656bd27a" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" [[package]] name = "pathdiff" @@ -3748,9 +3805,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.3" +version = "2.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" +checksum = "cbcfd20a6d4eeba40179f05735784ad32bdaef05ce8e8af05f180d45bb3e7e22" dependencies = [ "memchr", "ucd-trie", @@ -3758,9 +3815,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.3" +version = "2.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" +checksum = "51f72981ade67b1ca6adc26ec221be9f463f2b5839c7508998daa17c23d94d7f" dependencies = [ "pest", "pest_generator", @@ -3768,22 +3825,22 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.3" +version = "2.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" +checksum = "dee9efd8cdb50d719a80088b76f81aec7c41ed6d522ee750178f83883d271625" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] name = "pest_meta" -version = "2.8.3" +version = "2.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" +checksum = "bf1d70880e76bdc13ba52eafa6239ce793d85c8e43896507e43dd8984ff05b82" dependencies = [ "pest", "sha2", @@ -3829,7 +3886,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -3859,7 +3916,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -3925,7 +3982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", - "indexmap 2.12.0", + "indexmap 2.12.1", "quick-xml", "serde", "time", @@ -4015,7 +4072,7 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.27", + "zerocopy 0.8.31", ] [[package]] @@ -4031,7 +4088,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -4092,7 +4149,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3ef4f2f0422f23a82ec9f628ea2acd12871c81a9362b02c43c1aa86acfc3ba1" dependencies = [ "futures", - "indexmap 2.12.0", + "indexmap 2.12.1", "nix 0.30.1", "tokio", "tracing", @@ -4119,14 +4176,14 @@ dependencies = [ "itertools 0.12.1", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] name = "pxfm" -version = "0.1.25" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" dependencies = [ "num-traits", ] @@ -4317,6 +4374,15 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_syscall" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -4345,7 +4411,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -4371,6 +4437,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d942b98df5e658f56f20d592c7f868833fe38115e65c33003d8cd224b0155da" + [[package]] name = "regex-syntax" version = "0.8.8" @@ -4379,9 +4451,9 @@ checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" -version = "0.12.15" +version = "0.12.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d19c46a6fdd48bc4dab94b6103fccc55d34c67cc0ad04653aad4ea2a07cd7bbb" +checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" dependencies = [ "base64 0.22.1", "bytes", @@ -4389,43 +4461,40 @@ dependencies = [ "futures-core", "futures-util", "h2 0.4.12", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "hyper 1.8.1", "hyper-rustls 0.27.7", "hyper-tls", "hyper-util", - "ipnet", "js-sys", "log", "mime", + "mime_guess", "native-tls", - "once_cell", "percent-encoding", "pin-project-lite", "quinn", "rustls 0.23.35", - "rustls-pemfile", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper 1.0.2", - "system-configuration", "tokio", "tokio-native-tls", "tokio-rustls 0.26.4", "tokio-util", "tower 0.5.2", + "tower-http 0.6.8", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 0.26.11", - "windows-registry", + "webpki-roots 1.0.4", ] [[package]] @@ -4452,7 +4521,7 @@ checksum = "57f17d28a6e6acfe1733fe24bcd30774d13bffa4b8a22535b4c8c98423088d4e" dependencies = [ "anyhow", "async-trait", - "http 1.3.1", + "http 1.4.0", "reqwest", "serde", "thiserror 1.0.69", @@ -4469,7 +4538,7 @@ dependencies = [ "async-trait", "futures", "getrandom 0.2.16", - "http 1.3.1", + "http 1.4.0", "hyper 1.8.1", "reqwest", "reqwest-middleware", @@ -4499,6 +4568,38 @@ dependencies = [ "subtle", ] +[[package]] +name = "rig-core" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3799afd8ba38d90d9886be5bf596b0159043f88598b40e1f5aa08aad488f2223" +dependencies = [ + "as-any", + "async-stream", + "base64 0.22.1", + "bytes", + "eventsource-stream", + "fastrand", + "futures", + "futures-timer", + "glob", + "http 1.4.0", + "mime", + "mime_guess", + "ordered-float", + "pin-project-lite", + "reqwest", + "rmcp 0.9.1", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tracing", + "tracing-futures", + "url", +] + [[package]] name = "ring" version = "0.17.14" @@ -4513,6 +4614,33 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmcp" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaa07b85b779d1e1df52dd79f6c6bffbe005b191f07290136cc42a142da3409a" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "http 1.4.0", + "paste", + "pin-project-lite", + "process-wrap", + "reqwest", + "rmcp-macros 0.9.1", + "schemars", + "serde", + "serde_json", + "sse-stream", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", +] + [[package]] name = "rmcp" version = "0.11.0" @@ -4524,7 +4652,7 @@ dependencies = [ "bytes", "chrono", "futures", - "http 1.3.1", + "http 1.4.0", "http-body 1.0.1", "http-body-util", "pastey", @@ -4532,7 +4660,7 @@ dependencies = [ "process-wrap", "rand 0.9.2", "reqwest", - "rmcp-macros", + "rmcp-macros 0.11.0", "schemars", "serde", "serde_json", @@ -4546,6 +4674,19 @@ dependencies = [ "uuid", ] +[[package]] +name = "rmcp-macros" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f6fa09933cac0d0204c8a5d647f558425538ed6a0134b1ebb1ae4dc00c96db3" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.111", +] + [[package]] name = "rmcp-macros" version = "0.11.0" @@ -4556,7 +4697,7 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -4841,9 +4982,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ "web-time", "zeroize", @@ -4961,7 +5102,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -4995,6 +5136,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "serde", + "zeroize", +] + [[package]] name = "security-framework" version = "2.11.1" @@ -5089,7 +5240,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -5100,7 +5251,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -5154,7 +5305,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.12.1", "itoa", "ryu", "serde", @@ -5255,9 +5406,9 @@ dependencies = [ [[package]] name = "shell-words" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" [[package]] name = "shlex" @@ -5288,9 +5439,9 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" dependencies = [ "libc", ] @@ -5307,9 +5458,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "simdutf8" @@ -5433,7 +5584,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stakai" -version = "0.3.11" +version = "0.3.12-beta.1" dependencies = [ "anyhow", "async-stream", @@ -5454,13 +5605,13 @@ dependencies = [ [[package]] name = "stakpak" -version = "0.3.11" +version = "0.3.12-beta.1" dependencies = [ "agent-client-protocol", "async-trait", "base64 0.22.1", "chrono", - "clap 4.5.51", + "clap 4.5.53", "config", "crossterm 0.29.0", "flate2", @@ -5474,7 +5625,7 @@ dependencies = [ "rand 0.9.2", "regex", "reqwest", - "rmcp", + "rmcp 0.11.0", "rpassword", "rustls 0.23.35", "serde", @@ -5501,7 +5652,7 @@ dependencies = [ [[package]] name = "stakpak-api" -version = "0.3.11" +version = "0.3.12-beta.1" dependencies = [ "async-stream", "async-trait", @@ -5512,7 +5663,7 @@ dependencies = [ "once_cell", "regex", "reqwest", - "rmcp", + "rmcp 0.11.0", "serde", "serde_json", "serde_yaml", @@ -5525,12 +5676,12 @@ dependencies = [ [[package]] name = "stakpak-mcp-client" -version = "0.3.11" +version = "0.3.12-beta.1" dependencies = [ "anyhow", "futures", "reqwest", - "rmcp", + "rmcp 0.11.0", "serde_json", "stakpak-shared", "tokio", @@ -5540,13 +5691,13 @@ dependencies = [ [[package]] name = "stakpak-mcp-proxy" -version = "0.3.11" +version = "0.3.12-beta.1" dependencies = [ "anyhow", "axum 0.8.7", "axum-server", "reqwest", - "rmcp", + "rmcp 0.11.0", "serde", "serde_json", "stakpak-shared", @@ -5555,9 +5706,27 @@ dependencies = [ "tracing", ] +[[package]] +name = "stakpak-mcp-rust-client" +version = "0.1.0" +dependencies = [ + "anyhow", + "dotenvy", + "reqwest", + "rig-core", + "rmcp 0.9.1", + "rustls 0.23.35", + "rustls-pemfile", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "stakpak-mcp-server" -version = "0.3.11" +version = "0.3.12-beta.1" dependencies = [ "anyhow", "axum 0.8.7", @@ -5566,7 +5735,8 @@ dependencies = [ "fast_html2md", "rand 0.9.2", "reqwest", - "rmcp", + "rmcp 0.11.0", + "rustls 0.23.35", "serde", "serde_json", "similar", @@ -5583,7 +5753,7 @@ dependencies = [ [[package]] name = "stakpak-popup-widget" -version = "0.3.11" +version = "0.3.12-beta.1" dependencies = [ "crossterm 0.29.0", "ratatui", @@ -5591,7 +5761,7 @@ dependencies = [ [[package]] name = "stakpak-shared" -version = "0.3.11" +version = "0.3.12-beta.1" dependencies = [ "anyhow", "async-trait", @@ -5599,6 +5769,7 @@ dependencies = [ "axum-server", "chrono", "dirs", + "divan", "futures", "futures-util", "hyper 1.8.1", @@ -5610,11 +5781,14 @@ dependencies = [ "reqwest", "reqwest-middleware", "reqwest-retry", - "rmcp", + "rmcp 0.11.0", "russh", "russh-sftp", "rustls 0.23.35", + "rustls-pemfile", "rustls-platform-verifier", + "schemars", + "secrecy", "serde", "serde_json", "stakai", @@ -5632,7 +5806,7 @@ dependencies = [ [[package]] name = "stakpak-tui" -version = "0.3.11" +version = "0.3.12-beta.1" dependencies = [ "ansi-to-tui", "arboard", @@ -5713,7 +5887,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -5725,7 +5899,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -5747,9 +5921,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.110" +version = "2.0.111" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a99801b5bd34ede4cf3fc688c5919368fea4e4814a4664359503e6015b280aea" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", @@ -5779,7 +5953,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -5857,6 +6031,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix 1.1.2", + "windows-sys 0.60.2", +] + [[package]] name = "termios" version = "0.2.2" @@ -5884,7 +6068,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -5895,7 +6079,7 @@ checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", "test-case-core", ] @@ -5936,7 +6120,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -5947,7 +6131,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -6073,7 +6257,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -6181,7 +6365,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.12.0", + "indexmap 2.12.1", "serde", "serde_spanned", "toml_datetime", @@ -6236,7 +6420,7 @@ dependencies = [ "pin-project", "tokio-stream", "tonic", - "tower-http", + "tower-http 0.4.4", "tower-layer", "tower-service", "tracing", @@ -6298,6 +6482,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower 0.5.2", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -6312,9 +6514,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -6324,25 +6526,37 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", ] +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "futures", + "futures-task", + "pin-project", + "tracing", +] + [[package]] name = "tracing-log" version = "0.2.0" @@ -6356,9 +6570,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -6399,6 +6613,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -6506,13 +6726,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "getrandom 0.3.4", "js-sys", - "serde", + "serde_core", "wasm-bindgen", ] @@ -6603,9 +6823,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", @@ -6616,9 +6836,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" dependencies = [ "cfg-if", "js-sys", @@ -6629,9 +6849,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6639,22 +6859,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] @@ -6688,9 +6908,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" dependencies = [ "js-sys", "wasm-bindgen", @@ -6881,7 +7101,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -6892,7 +7112,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -6903,7 +7123,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -6914,7 +7134,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -7322,9 +7542,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] @@ -7425,7 +7645,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", "synstructure", ] @@ -7441,11 +7661,11 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" dependencies = [ - "zerocopy-derive 0.8.27", + "zerocopy-derive 0.8.31", ] [[package]] @@ -7456,18 +7676,18 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -7487,7 +7707,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", "synstructure", ] @@ -7508,7 +7728,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -7541,7 +7761,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.110", + "syn 2.0.111", ] [[package]] @@ -7559,7 +7779,7 @@ dependencies = [ "flate2", "getrandom 0.3.4", "hmac", - "indexmap 2.12.0", + "indexmap 2.12.1", "liblzma", "memchr", "pbkdf2", @@ -7573,9 +7793,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.5.2" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f06ae92f42f5e5c42443fd094f245eb656abf56dd7cce9b8b263236565e00f2" +checksum = "51f936044d677be1a1168fae1d03b583a285a5dd9d8cbf7b24c23aa1fc775235" [[package]] name = "zopfli" @@ -7640,9 +7860,9 @@ dependencies = [ [[package]] name = "zune-jpeg" -version = "0.5.5" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fb7703e32e9a07fb3f757360338b3a567a5054f21b5f52a666752e333d58e" +checksum = "51d915729b0e7d5fe35c2f294c5dc10b30207cc637920e5b59077bfa3da63f28" dependencies = [ "zune-core 0.5.0", ] diff --git a/Cargo.toml b/Cargo.toml index 42065f28..83cde62a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,12 +9,13 @@ members = [ "libs/mcp/client", "libs/mcp/server", "libs/mcp/proxy", + "examples/mcp-clients/rust-client" ] default-members = ["cli"] [workspace.package] -version = "0.3.11" +version = "0.3.12-beta.1" edition = "2024" description = "Stakpak: Your DevOps AI Agent. Generate infrastructure code, debug Kubernetes, configure CI/CD, automate deployments, without giving an LLM the keys to production." license = "Apache-2.0" @@ -23,14 +24,14 @@ homepage = "https://stakpak.io" [workspace.dependencies] -stakai = { path = "libs/ai", version = "0.3.11" } -stakpak-api = { path = "libs/api", version = "0.3.11" } -stakpak-mcp-server = { path = "libs/mcp/server", version = "0.3.11" } -stakpak-mcp-client = { path = "libs/mcp/client", version = "0.3.11" } -stakpak-mcp-proxy = { path = "libs/mcp/proxy", version = "0.3.11" } -stakpak-tui = { path = "tui", version = "0.3.11" } -stakpak-shared = { path = "libs/shared", version = "0.3.11" } -popup-widget = { package = "stakpak-popup-widget", path = "libs/popup-widget", version = "0.3.11" } +stakai = { path = "libs/ai", version = "0.3.12-beta.1" } +stakpak-api = { path = "libs/api", version = "0.3.12-beta.1" } +stakpak-mcp-server = { path = "libs/mcp/server", version = "0.3.12-beta.1" } +stakpak-mcp-client = { path = "libs/mcp/client", version = "0.3.12-beta.1" } +stakpak-mcp-proxy = { path = "libs/mcp/proxy", version = "0.3.12-beta.1" } +stakpak-tui = { path = "tui", version = "0.3.12-beta.1" } +stakpak-shared = { path = "libs/shared", version = "0.3.12-beta.1" } +popup-widget = { package = "stakpak-popup-widget", path = "libs/popup-widget", version = "0.3.12-beta.1" } serde = { version = "1.0.215", features = ["derive"] } serde_json = "1.0.133" uuid = { version = "1.10.0", features = ["serde", "v4"] } @@ -56,7 +57,7 @@ futures = "0.3.31" futures-util = "0.3.31" regex = "1.11.1" chrono = { version = "0.4.38", features = ["serde"] } -reqwest = { version = "=0.12.15", features = [ +reqwest = { version = "0.12.26", features = [ "json", "stream", "rustls-tls", @@ -82,7 +83,7 @@ rustls-platform-verifier = "0.5" crossterm = "0.29" tempfile = "3.0" similar = { version = "2.7.0", features = ["inline"] } -schemars = { version = "1.1.0", features = ["chrono"] } +schemars = { version = "1.1.0", features = ["chrono04"] } async-trait = "0.1" open = "5.3.2" log = "0.4" diff --git a/cli/src/commands/acp/server.rs b/cli/src/commands/acp/server.rs index bc4f3ef1..08d657fc 100644 --- a/cli/src/commands/acp/server.rs +++ b/cli/src/commands/acp/server.rs @@ -1,5 +1,6 @@ use crate::commands::agent::run::helpers::{system_message, user_message}; use crate::config::ProviderType; +use crate::utils::network; use crate::{commands::agent::run::helpers::convert_tools_with_filter, config::AppConfig}; use agent_client_protocol::{self as acp, Client as AcpClient, SessionNotification}; use futures_util::StreamExt; @@ -10,6 +11,8 @@ use stakpak_api::{ remote::{ClientConfig, RemoteClient}, }; use stakpak_mcp_client::McpClient; +use stakpak_mcp_server::{EnabledToolsConfig, MCPServerConfig, ToolMode, start_server, tool_names}; +use stakpak_shared::cert_utils::CertificateStrategy; use stakpak_shared::models::integrations::mcp::CallToolResultExt; use stakpak_shared::models::integrations::openai::{ AgentModel, ChatCompletionChoice, ChatCompletionResponse, ChatCompletionStreamResponse, @@ -101,10 +104,10 @@ impl StakpakAcpAgent { // Initialize MCP client and tools (optional for ACP) let (mcp_client, mcp_tools, tools) = - match Self::initialize_mcp_server_and_tools(&config).await { + match Self::initialize_mcp_server_and_tools(&config, client.clone()).await { Ok((client, mcp_tools, tool_list)) => { log::info!("MCP client initialized successfully"); - (Some(client), mcp_tools, tool_list) + (Some(Arc::new(client)), mcp_tools, tool_list) } Err(e) => { log::warn!( @@ -303,7 +306,6 @@ impl StakpakAcpAgent { // Helper method to generate appropriate tool title based on tool type and arguments fn generate_tool_title(&self, tool_name: &str, raw_input: &serde_json::Value) -> String { - use super::tool_names; match tool_name { tool_names::VIEW => { // Extract path from arguments for view tool @@ -393,7 +395,6 @@ impl StakpakAcpAgent { // Helper method to get appropriate ToolKind based on tool name fn get_tool_kind(&self, tool_name: &str) -> acp::ToolKind { - use super::tool_names; if tool_names::is_fs_file_read(tool_name) || tool_name == tool_names::READ_RULEBOOK { acp::ToolKind::Read } else if tool_names::is_fs_file_write(tool_name) { @@ -412,17 +413,17 @@ impl StakpakAcpAgent { // Helper method to determine if a tool should use Diff content type fn should_use_diff_content(&self, tool_name: &str) -> bool { - super::tool_names::is_fs_file_write(tool_name) + tool_names::is_fs_file_write(tool_name) } // Helper method to determine if a tool is a file creation tool fn is_file_creation_tool(&self, tool_name: &str) -> bool { - tool_name == super::tool_names::CREATE || tool_name == super::tool_names::CREATE_FILE + tool_name == tool_names::CREATE || tool_name == tool_names::CREATE_FILE } // Helper method to determine if a tool should be auto-approved fn is_auto_approved_tool(&self, tool_name: &str) -> bool { - super::tool_names::is_auto_approved(tool_name) + tool_names::is_auto_approved(tool_name) } // Helper method to create proper rawInput for tool calls @@ -861,15 +862,15 @@ impl StakpakAcpAgent { // Check if this is a filesystem tool that should use native ACP // Decide if this should be handled by native ACP FS. Avoid read_text_file for directories. - let is_view_directory = if tool_call.function.name == super::tool_names::VIEW { + let tool_name = tool_call.function.name.as_str(); + let is_view_directory = if tool_name == tool_names::VIEW { Path::new(&abs_path).is_dir() } else { false }; - let tool_name = tool_call.function.name.as_str(); - let is_read_tool = super::tool_names::is_fs_file_read(tool_name) && !is_view_directory; - let is_write_tool = super::tool_names::is_fs_file_write(tool_name); + let is_read_tool = tool_names::is_fs_file_read(tool_name) && !is_view_directory; + let is_write_tool = tool_names::is_fs_file_write(tool_name); // Delegate fs operations to the client so it can access unsaved editor // state and track modifications. Per ACP spec, both read and write @@ -1065,13 +1066,58 @@ impl StakpakAcpAgent { pub async fn initialize_mcp_server_and_tools( config: &AppConfig, - ) -> Result<(Arc, Vec, Vec), String> { - // Initialize MCP client via stdio proxy - let mcp_client = Arc::new( - stakpak_mcp_client::connect(None) // progress_tx will be set later in run_stdio - .await - .map_err(|e| format!("Failed to connect to MCP proxy: {}", e))?, + client: Arc, + ) -> Result<(McpClient, Vec, Vec), String> { + // Find available bind address + let (bind_address, listener) = network::find_available_bind_address_with_listener() + .await + .map_err(|e| e.to_string())?; + + // Generate ephemeral certificates for mTLS + let strategy = CertificateStrategy::Ephemeral; + let certificate_chain = Some(Arc::new( + strategy + .get_certificate_chain() + .map_err(|e| e.to_string())?, + )); + + let protocol = "https"; + let local_mcp_server_host = format!("{}://{}", protocol, bind_address); + + // Start MCP server in background + let server_config_for_server = Some( + strategy + .load_server_config() + .map_err(|e| format!("Failed to create server config: {}", e))?, ); + let client_for_server = client.clone(); + + tokio::spawn(async move { + let _ = start_server( + MCPServerConfig { + client: Some(client_for_server), + redact_secrets: true, + privacy_mode: false, + enabled_tools: EnabledToolsConfig { slack: false }, + tool_mode: ToolMode::Combined, + bind_address, + server_config: Arc::new(server_config_for_server), + subagent_configs: None, + }, + Some(listener), + None, + ) + .await; + }); + + // Initialize MCP client + let mcp_client = stakpak_mcp_client::connect_https( + &local_mcp_server_host, + certificate_chain.clone(), + None, // progress_tx will be set later in run_stdio + ) + .await + .map_err(|e| format!("Failed to connect to MCP server: {}", e))?; // Get tools from MCP client let mcp_tools = stakpak_mcp_client::get_tools(&mcp_client) @@ -1363,10 +1409,10 @@ impl StakpakAcpAgent { let (progress_tx, mut progress_rx) = tokio::sync::mpsc::channel::(100); // Reinitialize MCP client with progress channel - let (mcp_client, mcp_tools, tools) = match Self::initialize_mcp_server_and_tools(&self.config).await { + let (mcp_client, mcp_tools, tools) = match Self::initialize_mcp_server_and_tools(&self.config, self.client.clone()).await { Ok((client, mcp_tools, tool_list)) => { log::info!("MCP client reinitialized with progress channel"); - (Some(client), mcp_tools, tool_list) + (Some(Arc::new(client)), mcp_tools, tool_list) } Err(e) => { log::warn!("Failed to reinitialize MCP client with progress channel: {}, continuing without tools", e); diff --git a/cli/src/commands/agent/run/mcp_init.rs b/cli/src/commands/agent/run/mcp_init.rs index 843f95dd..f78f351f 100644 --- a/cli/src/commands/agent/run/mcp_init.rs +++ b/cli/src/commands/agent/run/mcp_init.rs @@ -15,7 +15,7 @@ use stakpak_mcp_client::McpClient; use stakpak_mcp_proxy::client::{ClientPoolConfig, ServerConfig}; use stakpak_mcp_proxy::server::start_proxy_server; use stakpak_mcp_server::{EnabledToolsConfig, MCPServerConfig, ToolMode, start_server}; -use stakpak_shared::cert_utils::CertificateChain; +use stakpak_shared::cert_utils::{CertificateChain, CertificateStrategy}; use stakpak_shared::models::integrations::openai::ToolCallResultProgress; use std::collections::HashMap; use std::sync::Arc; @@ -61,12 +61,12 @@ pub struct McpInitResult { pub proxy_shutdown_tx: broadcast::Sender<()>, } -/// Certificate chains for server and proxy communication -struct CertificateChains { - /// Certificate chain for MCP server <-> Proxy communication - server_chain: Arc>, - /// Certificate chain for Proxy <-> Client communication - proxy_chain: Arc, +/// Certificate strategies for server and proxy communication +struct CertificateStrategies { + /// Certificate strategy for MCP server <-> Proxy communication + server_strategy: CertificateStrategy, + /// Certificate strategy for Proxy <-> Client communication + proxy_strategy: CertificateStrategy, } /// Server binding information @@ -75,23 +75,13 @@ struct ServerBinding { listener: TcpListener, } -impl CertificateChains { - /// Generate two separate certificate chains for server and proxy - fn generate() -> Result { - let server_chain = - Arc::new(Some(CertificateChain::generate().map_err(|e| { - format!("Failed to generate server certificates: {}", e) - })?)); - - let proxy_chain = Arc::new( - CertificateChain::generate() - .map_err(|e| format!("Failed to generate proxy certificates: {}", e))?, - ); - - Ok(Self { - server_chain, - proxy_chain, - }) +impl CertificateStrategies { + /// Create ephemeral certificate strategies for server and proxy + fn ephemeral() -> Self { + Self { + server_strategy: CertificateStrategy::Ephemeral, + proxy_strategy: CertificateStrategy::Ephemeral, + } } } @@ -128,7 +118,13 @@ async fn start_mcp_server( let enabled_tools = mcp_config.enabled_tools.clone(); tokio::spawn(async move { - let server_config = MCPServerConfig { + // Load server config from certificate chain + let server_config_for_mcp = cert_chain + .as_ref() + .as_ref() + .and_then(|chain| chain.create_server_config().ok()); + + let config = MCPServerConfig { client: Some(api_client), bind_address, redact_secrets, @@ -136,14 +132,13 @@ async fn start_mcp_server( enabled_tools, tool_mode: ToolMode::Combined, subagent_configs: None, - certificate_chain: cert_chain, + server_config: Arc::new(server_config_for_mcp), }; // Signal that we're about to start let _ = ready_tx.send(Ok(())); - if let Err(e) = start_server(server_config, Some(binding.listener), Some(shutdown_rx)).await - { + if let Err(e) = start_server(config, Some(binding.listener), Some(shutdown_rx)).await { tracing::error!("Local MCP server error: {}", e); } }); @@ -195,7 +190,7 @@ async fn start_proxy( pool_config: ClientPoolConfig, mcp_config: &McpInitConfig, binding: ServerBinding, - cert_chain: Arc, + cert_strategy: CertificateStrategy, shutdown_rx: broadcast::Receiver<()>, ) -> Result<(), String> { let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::>(); @@ -210,7 +205,7 @@ async fn start_proxy( if let Err(e) = start_proxy_server( pool_config, binding.listener, - cert_chain, + cert_strategy, redact_secrets, privacy_mode, Some(shutdown_rx), @@ -233,20 +228,22 @@ async fn start_proxy( /// Connect to the proxy with retry logic async fn connect_to_proxy( proxy_url: &str, - cert_chain: Arc, + cert_strategy: &CertificateStrategy, progress_tx: Option>, ) -> Result, String> { const MAX_RETRIES: u32 = 5; let mut retry_delay = tokio::time::Duration::from_millis(50); let mut last_error = None; + // Get certificate chain from strategy for client connection + let cert_chain = + Some(Arc::new(cert_strategy.get_certificate_chain().map_err( + |e| format!("Failed to get certificate chain: {}", e), + )?)); + for attempt in 1..=MAX_RETRIES { - match stakpak_mcp_client::connect_https( - proxy_url, - Some(cert_chain.clone()), - progress_tx.clone(), - ) - .await + match stakpak_mcp_client::connect_https(proxy_url, cert_chain.clone(), progress_tx.clone()) + .await { Ok(client) => return Ok(Arc::new(client)), Err(e) => { @@ -269,7 +266,7 @@ async fn connect_to_proxy( /// Initialize the MCP server, proxy, and client infrastructure /// /// This function sets up the complete MCP infrastructure: -/// 1. Generates certificate chains for mTLS +/// 1. Creates certificate strategies for mTLS /// 2. Starts the local MCP server with tools /// 3. Starts the proxy server that aggregates MCP servers /// 4. Connects a client to the proxy @@ -280,8 +277,8 @@ pub async fn initialize_mcp_server_and_tools( mcp_config: McpInitConfig, progress_tx: Option>, ) -> Result { - // 1. Generate certificate chains - let certs = CertificateChains::generate()?; + // 1. Create certificate strategies + let certs = CertificateStrategies::ephemeral(); // 2. Find available ports let server_binding = ServerBinding::new("MCP server").await?; @@ -295,28 +292,35 @@ pub async fn initialize_mcp_server_and_tools( let (proxy_shutdown_tx, proxy_shutdown_rx) = broadcast::channel::<()>(1); // 4. Start local MCP server + // Get certificate chain for server config + let server_chain = Arc::new(Some( + certs + .server_strategy + .get_certificate_chain() + .map_err(|e| format!("Failed to get server certificate chain: {}", e))?, + )); start_mcp_server( app_config, &mcp_config, server_binding, - certs.server_chain.clone(), + server_chain.clone(), server_shutdown_rx, ) .await?; // 5. Build and start proxy - let pool_config = build_proxy_config(local_mcp_server_url, certs.server_chain); + let pool_config = build_proxy_config(local_mcp_server_url, server_chain); start_proxy( pool_config, &mcp_config, proxy_binding, - certs.proxy_chain.clone(), + certs.proxy_strategy.clone(), proxy_shutdown_rx, ) .await?; // 6. Connect client to proxy - let mcp_client = connect_to_proxy(&proxy_url, certs.proxy_chain, progress_tx).await?; + let mcp_client = connect_to_proxy(&proxy_url, &certs.proxy_strategy, progress_tx).await?; // 7. Get tools from MCP client let mcp_tools = stakpak_mcp_client::get_tools(&mcp_client) diff --git a/cli/src/commands/mcp/mod.rs b/cli/src/commands/mcp/mod.rs index 0c50d439..7116f706 100644 --- a/cli/src/commands/mcp/mod.rs +++ b/cli/src/commands/mcp/mod.rs @@ -57,8 +57,25 @@ fn find_mcp_proxy_config_file() -> Result { #[derive(Subcommand, PartialEq)] pub enum McpCommands { - /// Start the MCP server (standalone HTTP/HTTPS server with tools) + /// Generate and save mTLS certificates (one-time setup) + Setup { + /// Directory to save certificates (default: ~/.stakpak/certs) + #[arg(long = "out-dir")] + out_dir: Option, + + /// Overwrite existing certificates + #[arg(long, short)] + force: bool, + }, + /// Start the MCP server Start { + /// Directory to load certificates from (default: ~/.stakpak/certs) + #[arg(long = "config-dir")] + config_dir: Option, + + /// Port to bind to, overrides automatic port selection + #[arg(long, short)] + port: Option, /// Disable secret redaction (WARNING: this will print secrets to the console) #[arg(long = "disable-secret-redaction", default_value_t = false)] disable_secret_redaction: bool, @@ -102,7 +119,12 @@ pub enum McpCommands { impl McpCommands { pub async fn run(self, config: AppConfig) -> Result<(), String> { match self { + McpCommands::Setup { out_dir, force } => { + server::setup_certificates(out_dir, force).await + } McpCommands::Start { + config_dir, + port, disable_secret_redaction, privacy_mode, tool_mode, @@ -112,12 +134,16 @@ impl McpCommands { } => { server::run_server( config, - disable_secret_redaction, - privacy_mode, - tool_mode, - enable_slack_tools, - index_big_project, - disable_mcp_mtls, + server::ServerOptions { + config_dir, + port, + disable_secret_redaction, + privacy_mode, + tool_mode, + enable_slack_tools, + index_big_project, + disable_mcp_mtls, + }, ) .await } diff --git a/cli/src/commands/mcp/server.rs b/cli/src/commands/mcp/server.rs index 0f173f63..18c9e616 100644 --- a/cli/src/commands/mcp/server.rs +++ b/cli/src/commands/mcp/server.rs @@ -1,64 +1,149 @@ +use std::path::PathBuf; use std::sync::Arc; use stakpak_mcp_server::{EnabledToolsConfig, MCPServerConfig, ToolMode, start_server}; -use stakpak_shared::cert_utils::CertificateChain; - -use crate::utils::network; -use crate::{commands::get_client, config::AppConfig}; - -/// Start the MCP server (standalone HTTP/HTTPS server with tools) -pub async fn run_server( - config: AppConfig, - disable_secret_redaction: bool, - privacy_mode: bool, - tool_mode: ToolMode, - enable_slack_tools: bool, - _index_big_project: bool, - disable_mcp_mtls: bool, -) -> Result<(), String> { - match tool_mode { +use stakpak_shared::cert_utils::{CertificateChain, CertificateStrategy}; +use tokio::net::TcpListener; + +use crate::{commands::get_client, config::AppConfig, utils::network}; + +/// Configuration options for running the MCP server +pub struct ServerOptions { + pub config_dir: Option, + pub port: Option, + pub disable_secret_redaction: bool, + pub privacy_mode: bool, + pub tool_mode: ToolMode, + pub enable_slack_tools: bool, + #[allow(dead_code)] + pub index_big_project: bool, + pub disable_mcp_mtls: bool, +} + +pub async fn setup_certificates(out_dir: Option, force: bool) -> Result<(), String> { + println!("Stakpak MCP Certificate Setup\n"); + + let cert_dir = out_dir + .or_else(|| stakpak_shared::cert_utils::default_cert_dir().ok()) + .ok_or_else(|| "Could not determine certificate directory".to_string())?; + + if CertificateChain::exists_in_directory(&cert_dir) && !force { + println!("Certificates already exist at: {}", cert_dir.display()); + println!("Options:"); + println!("1. Use existing certificates: `cargo run -- mcp start`"); + println!("2. Regenerate with --force: `cargo run -- mcp setup --force`"); + println!("3. Delete manually `rm -rf {}`", cert_dir.display()); + return Err("Certificates already exist".to_string()); + } + + match CertificateChain::generate_and_save(Some(&cert_dir), force) { + Ok(_chain) => { + println!("Certificate generation complete!\n"); + println!("Certificates saved to: {}", cert_dir.display()); + println!("Files created:"); + println!("ca.pem"); + println!("server-cert.pem"); + println!("server-key.pem"); + println!("client-cert.pem"); + println!("client-key.pem"); + println!(); + println!("Next steps:"); + println!("1. Start the server: `cargo run -- mcp start`"); + println!( + "2. Configure clients with the certificates from: {}", + cert_dir.display() + ); + Ok(()) + } + Err(e) => { + eprintln!("Failed to generate certificates: {}", e); + Err(format!("Certificate generation failed: {}", e)) + } + } +} + +pub async fn run_server(config: AppConfig, options: ServerOptions) -> Result<(), String> { + match options.tool_mode { ToolMode::RemoteOnly | ToolMode::Combined => { // Placeholder for code indexing logic } ToolMode::LocalOnly => {} } - let (bind_address, listener) = network::find_available_bind_address_with_listener().await?; - - let certificate_chain = if !disable_mcp_mtls { - match CertificateChain::generate() { - Ok(chain) => { - println!("🔐 mTLS enabled - generated certificate chain"); - if let Ok(ca_pem) = chain.get_ca_cert_pem() { - println!("📜 CA Certificate (copy this to your client):"); - println!("{}", ca_pem); - } - Some(chain) + // Bind to port (custom port or auto-select) + let listener = if let Some(port) = options.port { + TcpListener::bind(format!("0.0.0.0:{}", port)) + .await + .map_err(|e| format!("Failed to bind to port {}: {}", port, e))? + } else { + network::find_available_bind_address_with_listener() + .await? + .1 + }; + let bind_address = listener + .local_addr() + .map_err(|e| format!("Failed to get local address: {}", e))? + .to_string(); + + // Load persisted certificates + let server_config = if !options.disable_mcp_mtls { + let cert_dir = options + .config_dir + .or_else(|| stakpak_shared::cert_utils::default_cert_dir().ok()) + .ok_or_else(|| "Could not determine certificate directory".to_string())?; + + let strategy = CertificateStrategy::Persistent(cert_dir.clone()); + + // Check if certificates exist + if !strategy.exists() { + eprintln!("No certificates found at: {}", cert_dir.display()); + eprintln!("Please run the setup command first: `cargo run -- mcp setup`"); + eprintln!("Or disable mTLS: `cargo run -- mcp start --disable-mcp-mtls`"); + return Err("Certificates not found, please run 'mcp setup' first".to_string()); + } + + println!( + "🔐 mTLS enabled - loading certificates from disk: {}", + cert_dir.display() + ); + + match strategy.load_server_config() { + Ok(config) => { + println!("Client certificates available at: {}/", cert_dir.display()); + Some(config) } Err(e) => { - eprintln!("Failed to generate certificate chain: {}", e); - std::process::exit(1); + eprintln!("Failed to load certificates: {}", e); + eprintln!( + "Run 'cargo run -- mcp setup' to generate certificates, if there's not generated certificates" + ); + return Err(format!("Failed to load certificates: {}", e)); } } } else { + println!("mTLS disabled"); None }; - let protocol = if !disable_mcp_mtls { "https" } else { "http" }; + let protocol = if server_config.is_some() { + "https" + } else { + "http" + }; println!("MCP server started at {}://{}/mcp", protocol, bind_address); start_server( MCPServerConfig { client: Some(get_client(&config).await?), - redact_secrets: !disable_secret_redaction, - privacy_mode, + redact_secrets: !options.disable_secret_redaction, + privacy_mode: options.privacy_mode, enabled_tools: EnabledToolsConfig { - slack: enable_slack_tools, + slack: options.enable_slack_tools, }, - tool_mode, + tool_mode: options.tool_mode, subagent_configs: None, bind_address, - certificate_chain: Arc::new(certificate_chain), + server_config: Arc::new(server_config), }, Some(listener), None, diff --git a/examples/mcp-clients/MTLS.md b/examples/mcp-clients/MTLS.md new file mode 100644 index 00000000..b20f82ba --- /dev/null +++ b/examples/mcp-clients/MTLS.md @@ -0,0 +1,81 @@ +# mTLS Setup Guide + +This guide explains how to set up mutual TLS (mTLS) authentication for the Stakpak MCP server. + +## Quick Setup + +```bash +# 1. Generate certificates (one-time setup) +cargo run -- mcp setup + +# 2. Start server with mTLS enabled +cargo run -- mcp start --port 8420 +``` + +This creates certificates in `~/.stakpak/certs/`: +- `ca.pem` +- `server-cert.pem` +- `server-key.pem` +- `client-cert.pem` +- `client-key.pem` + +## Using Certificates in Clients + +### Environment Variables + +export them + +```bash +export MCP_SERVER_URL="https://127.0.0.1:8420" +export CERTS_DIR="$HOME/.stakpak/certs" +# for the LLM integration example +export GEMINI_API_KEY=your_gemini_api_key +``` + +or copy `.env.example` to `.env` and modify it + +### Certificate Files Needed + +All clients need these 3 files: +- `ca.pem` +- `client-cert.pem` +- `client-key.pem` + +## Server Configuration + +### Default Setup +```bash +# Uses ~/.stakpak/certs by default +cargo run -- mcp setup +cargo run -- mcp start --port 8420 +``` + +### Custom Certificate Directory +```bash +# Generate in custom location +cargo run -- mcp setup --out-dir /path/to/certs + +# Start server with custom certs +cargo run -- mcp start --config-dir /path/to/certs --port 8420 +``` + +### Regenerate Certificates +```bash +# Force regenerate (overwrites existing certificates) +cargo run -- mcp setup --force +``` + +## Testing Without mTLS + +```bash +# Start server without mTLS +cargo run -- mcp start --disable-mcp-mtls + +# Use HTTP instead of HTTPS +export MCP_SERVER_PORT=8420 +``` + +## Port Configuration +The server binds to `0.0.0.0:8420` (all interfaces), but clients should connect to `127.0.0.1:8420` (localhost): + +Use `--port 8420` to ensure server and client use the same port. diff --git a/examples/mcp-clients/README.md b/examples/mcp-clients/README.md new file mode 100644 index 00000000..6ff221b8 --- /dev/null +++ b/examples/mcp-clients/README.md @@ -0,0 +1,101 @@ +# MCP Client Examples + +Example Rust clients for connecting to Stakpak MCP servers. + +## Quick Start + +```bash +# 1. Generate certificates +cargo run -- mcp setup + +# 2. Start server +cargo run -- mcp start --port 8420 + +# 3. Run client +cd examples/mcp-clients/rust-client +cargo run --example mtls_client +``` + +## Examples + +- **`basic_client.rs`** - Simple connection (testing only, no TLS) +- **`mtls_client.rs`** - Secure mTLS connection (recommended) +- **`tool_calling.rs`** - Call MCP tools with LLM integration (Gemini) +- **`proxy_client.rs`** - Connect via MCP proxy + + +## Proxy Client Example + +The proxy client connects to `stakpak mcp proxy`, which aggregates tools from multiple upstream MCP servers. + +### Setup + +1. **Create proxy config** at `~/.stakpak/mcp.toml`: + ```toml + [mcpServers.filesystem] + command = "npx" + args = ["-y", "@modelcontextprotocol/server-filesystem", "/etc"] + + # Connect to Stakpak MCP server (requires mTLS certs at ~/.stakpak/certs/) + [mcpServers.stakpak] + url = "https://127.0.0.1:8420/mcp" + ``` + +2. **Start Stakpak MCP server** (in a separate terminal): + ```bash + cargo run -- mcp start --port 8420 + ``` + +3. **Run the proxy client**: + ```bash + cargo run --example proxy_client + ``` + +### How It Works + +- The proxy spawns configured MCP servers as child processes (stdio) or connects via HTTP/HTTPS +- Tools are prefixed with server name: `filesystem__read_file`, `stakpak__generate_password` +- **mTLS auto-loading**: HTTPS connections automatically load certificates from `~/.stakpak/certs/` + +## Direct Client Usage + +```rust +use stakpak_mcp_rust_client::StakpakMCPClient; +use std::path::PathBuf; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let certs_dir = PathBuf::from(std::env::var("HOME")?) + .join(".stakpak").join("certs"); + + let client = StakpakMCPClient::new_with_mtls( + "https://127.0.0.1:8420", + &certs_dir.join("ca.pem"), + &certs_dir.join("client-cert.pem"), + &certs_dir.join("client-key.pem"), + ).await?; + + let tools = client.list_tools().await?; + println!("Available tools: {:?}", tools); + + Ok(()) +} +``` + +## Troubleshooting + +**Connection refused?** Ensure server is running: +```bash +cargo run -- mcp start --port 8420 +``` + +**Certificate errors?** Regenerate certificates: +```bash +cargo run -- mcp setup --force +``` + +## Resources + +- [MCP Specification](https://modelcontextprotocol.io/) +- [Stakpak GitHub](https://github.com/stakpak/stakpak) + diff --git a/examples/mcp-clients/rust-client/.env.example b/examples/mcp-clients/rust-client/.env.example new file mode 100644 index 00000000..46f32879 --- /dev/null +++ b/examples/mcp-clients/rust-client/.env.example @@ -0,0 +1,13 @@ +# Server connection +# Start server with fixed port: `cargo run -- mcp start --port 8420` +# Or check server output for the actual port and update this value +MCP_SERVER_PORT=8420 + +# Certificate directory +# Use `cargo run -- mcp setup --out-dir` to override the default directory (~/.stakpak/certs) +# Make sure it contains: ca.pem, client-cert.pem, client-key.pem +CERTS_DIR=$HOME/.stakpak/certs + +# LLM API Key (for tool_calling example with LLM integration) +# for different providers +GEMINI_API_KEY=your_gemini_api_key diff --git a/examples/mcp-clients/rust-client/.gitignore b/examples/mcp-clients/rust-client/.gitignore new file mode 100644 index 00000000..fedaa2b1 --- /dev/null +++ b/examples/mcp-clients/rust-client/.gitignore @@ -0,0 +1,2 @@ +/target +.env diff --git a/examples/mcp-clients/rust-client/Cargo.toml b/examples/mcp-clients/rust-client/Cargo.toml new file mode 100644 index 00000000..58fe102c --- /dev/null +++ b/examples/mcp-clients/rust-client/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "stakpak-mcp-rust-client" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = { workspace = true } +dotenvy = "0.15" +reqwest = { workspace = true } +rig-core = { version = "0.27.0", features = ["rmcp"] } +rmcp = { version = "0.9", features = ["client", "transport-streamable-http-client", "transport-streamable-http-client-reqwest", "transport-child-process"] } +rustls = { workspace = true } +rustls-pemfile = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[[example]] +name = "basic_client" +path = "examples/basic_client.rs" + +[[example]] +name = "mtls_client" +path = "examples/mtls_client.rs" + +[[example]] +name = "tool_calling" +path = "examples/tool_calling.rs" + +[[example]] +name = "proxy_client" +path = "examples/proxy_client.rs" diff --git a/examples/mcp-clients/rust-client/examples/basic_client.rs b/examples/mcp-clients/rust-client/examples/basic_client.rs new file mode 100644 index 00000000..46360cb8 --- /dev/null +++ b/examples/mcp-clients/rust-client/examples/basic_client.rs @@ -0,0 +1,123 @@ +use anyhow::{Context, Result}; +use reqwest::Client; +use rmcp::{ + model::{ClientCapabilities, ClientInfo, Implementation}, + service::RunningService, + transport::{ + streamable_http_client::StreamableHttpClientTransportConfig, StreamableHttpClientTransport, + }, + RoleClient, ServiceExt, +}; +use tracing::info; +use tracing_subscriber; + +#[tokio::main] +async fn main() -> Result<()> { + let _ = dotenvy::dotenv(); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let port = std::env::var("MCP_SERVER_PORT").unwrap_or_else(|_| "8420".to_string()); + let server_url = format!("http://127.0.0.1:{}", port); + + info!("Connecting to MCP server"); + info!("Server: {}", server_url); + + let http_client = Client::builder() + .build() + .context("Failed to build HTTP client")?; + + let transport = StreamableHttpClientTransport::with_client( + http_client, + StreamableHttpClientTransportConfig::with_uri(format!("{}/mcp", server_url)), + ); + + let client_info = ClientInfo { + protocol_version: Default::default(), + capabilities: ClientCapabilities::default(), + client_info: Implementation { + name: "stakpak-rust-client".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + title: Some("Stakpak Rust MCP Client".to_string()), + icons: Some(vec![]), + website_url: Some("https://stakpak.dev".to_string()), + }, + }; + + let service: RunningService = client_info + .serve(transport) + .await + .context("Failed to initialize MCP service")?; + + info!("Connected to MCP server successfully!"); + + if let Some(server_info) = service.peer_info() { + info!("Server info: {:?}", server_info.server_info); + } + + info!("Getting available tools from MCP server"); + let response = service + .list_tools(Default::default()) + .await + .context("Failed to list tools")?; + + let tools = response.tools; + + info!("Available tools:"); + for tool in &tools { + info!( + "- {} : {}", + tool.name, + tool.description.as_deref().unwrap_or("No description") + ); + } + + if tools.is_empty() { + info!("No tools available from server"); + return Ok(()); + } + + if tools.iter().any(|t| t.name == "generate_password") { + info!("\n=== Calling generate_password tool ==="); + + use rmcp::model::CallToolRequestParam; + + let tool_params = CallToolRequestParam { + name: "generate_password".into(), + arguments: Some(rmcp::object!({ + "length": 20, + "with_symbols": true + })), + }; + + info!("Requesting password generation (length=20, with symbols)"); + + match service.call_tool(tool_params).await { + Ok(result) => { + info!("Tool call successful!"); + info!("Result: {:?}", result); + + if !result.content.is_empty() { + info!("Generated password:"); + for content_item in &result.content { + if let Some(text) = content_item.as_text() { + info!("{}", text.text); + } + } + } + } + Err(e) => { + info!("Failed to call tool: {:?}", e); + } + } + } else { + info!("generate_password tool is not available on this server"); + } + + Ok(()) +} diff --git a/examples/mcp-clients/rust-client/examples/mtls_client.rs b/examples/mcp-clients/rust-client/examples/mtls_client.rs new file mode 100644 index 00000000..b5483c15 --- /dev/null +++ b/examples/mcp-clients/rust-client/examples/mtls_client.rs @@ -0,0 +1,200 @@ +use anyhow::{Context, Result}; +use reqwest::Client; +use rmcp::{ + model::{ClientCapabilities, ClientInfo, Implementation}, + service::RunningService, + transport::{ + streamable_http_client::StreamableHttpClientTransportConfig, StreamableHttpClientTransport, + }, + RoleClient, ServiceExt, +}; +use rustls::{ClientConfig, RootCertStore}; +use rustls_pemfile::{certs, pkcs8_private_keys}; +use std::{ + fs::File, + io::BufReader, + path::{Path, PathBuf}, +}; +use tracing::info; +use tracing_subscriber; + +fn load_certs(path: &Path) -> Result>> { + let file = File::open(path).context(format!("Failed to open cert file: {:?}", path))?; + let mut reader = BufReader::new(file); + let certs: Vec<_> = certs(&mut reader) + .collect::, _>>() + .context("Failed to parse certificates")?; + Ok(certs) +} + +fn load_private_key(path: &Path) -> Result> { + let file = File::open(path).context(format!("Failed to open key file: {:?}", path))?; + let mut reader = BufReader::new(file); + let keys = pkcs8_private_keys(&mut reader) + .collect::, _>>() + .context("Failed to parse private key")?; + + if keys.is_empty() { + anyhow::bail!("No private key found in file"); + } + + Ok(rustls::pki_types::PrivateKeyDer::Pkcs8(keys[0].clone_key())) +} + +fn create_mtls_client_config( + ca_cert_path: &Path, + client_cert_path: &Path, + client_key_path: &Path, +) -> Result { + // Install default crypto provider if not already installed + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + let ca_certs = load_certs(ca_cert_path)?; + let mut root_cert_store = RootCertStore { + roots: Vec::with_capacity(ca_certs.len()), + }; + for cert in ca_certs { + root_cert_store + .add(cert) + .context("Failed to add CA cert to root store")?; + } + + let client_certs = load_certs(client_cert_path)?; + let client_key = load_private_key(client_key_path)?; + + let config = ClientConfig::builder() + .with_root_certificates(root_cert_store) + .with_client_auth_cert(client_certs, client_key) + .context("Failed to build client config with mTLS")?; + + Ok(config) +} + +#[tokio::main] +async fn main() -> Result<()> { + dotenvy::dotenv().ok(); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let certs_dir = std::env::var("CERTS_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| { + std::env::var("HOME") + .map(PathBuf::from) + .map(|p| p.join(".stakpak").join("certs")) + .expect("HOME environment variable not set") + }); + + let port = std::env::var("MCP_SERVER_PORT").unwrap_or_else(|_| "8420".to_string()); + let server_url = format!("https://127.0.0.1:{}", port); + + let ca_cert = certs_dir.join("ca.pem"); + let client_cert = certs_dir.join("client-cert.pem"); + let client_key = certs_dir.join("client-key.pem"); + + info!("Connecting to MCP server with mTLS"); + info!("Server: {}", server_url); + info!("Certificates directory: {}", certs_dir.display()); + + let tls_config = create_mtls_client_config(&ca_cert, &client_cert, &client_key) + .context("Failed to create mTLS configuration")?; + + let http_client = Client::builder() + .use_preconfigured_tls(tls_config) + .build() + .context("Failed to build HTTP client")?; + + let transport = StreamableHttpClientTransport::with_client( + http_client, + StreamableHttpClientTransportConfig::with_uri(format!("{}/mcp", server_url)), + ); + + // Initialize MCP client + let client_info = ClientInfo { + protocol_version: Default::default(), + capabilities: ClientCapabilities::default(), + client_info: Implementation { + name: "stakpak-rust-client".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + title: Some("Stakpak Rust MCP Client".to_string()), + icons: Some(vec![]), + website_url: Some("https://stakpak.dev".to_string()), + }, + }; + + let service: RunningService = client_info + .serve(transport) + .await + .context("Failed to initialize MCP service")?; + + info!("Connected to MCP server successfully!"); + + if let Some(server_info) = service.peer_info() { + info!("Server info: {:?}", server_info.server_info); + } + + info!("Getting available tools from MCP server"); + let response = service + .list_tools(Default::default()) + .await + .context("Failed to list tools")?; + + let tools = response.tools; + + info!("Available tools:"); + for tool in &tools { + info!( + "- {} : {}", + tool.name, + tool.description.as_deref().unwrap_or("No description") + ); + } + + if tools.is_empty() { + info!("No tools available from server"); + return Ok(()); + } + + if tools.iter().any(|t| t.name == "generate_password") { + info!("\n=== Calling generate_password tool ==="); + + use rmcp::model::CallToolRequestParam; + + let tool_params = CallToolRequestParam { + name: "generate_password".into(), + arguments: Some(rmcp::object!({ + "length": 20, + "with_symbols": true + })), + }; + + info!("Requesting password generation (length=20, with symbols)"); + + match service.call_tool(tool_params).await { + Ok(result) => { + info!("Tool call successful!"); + info!("Result: {:?}", result); + + if !result.content.is_empty() { + info!("Generated password:"); + for content_item in &result.content { + let x = content_item.as_text().unwrap().text.to_string(); + info!("{}", x); + } + } + } + Err(e) => { + info!("Failed to call tool: {:?}", e); + } + } + } else { + info!("generate_password tool is not available on this server"); + } + + Ok(()) +} diff --git a/examples/mcp-clients/rust-client/examples/proxy_client.rs b/examples/mcp-clients/rust-client/examples/proxy_client.rs new file mode 100644 index 00000000..6478003b --- /dev/null +++ b/examples/mcp-clients/rust-client/examples/proxy_client.rs @@ -0,0 +1,198 @@ +use anyhow::{Context, Result}; +use rmcp::{ + model::{ClientCapabilities, ClientInfo, Implementation}, + service::RunningService, + transport::TokioChildProcess, + ClientHandler, RoleClient, ServiceExt, +}; +use tokio::process::Command; +use tokio::time::{sleep, Duration}; +use tracing::info; + +/// Handler for progress notifications from the MCP proxy +#[derive(Clone)] +struct ProxyClientHandler; + +impl ClientHandler for ProxyClientHandler { + async fn on_progress( + &self, + progress: rmcp::model::ProgressNotificationParam, + _ctx: rmcp::service::NotificationContext, + ) { + if let Some(message) = &progress.message { + info!( + "Progress [{}%]: {}", + progress.progress * 100.0 / progress.total.unwrap_or(100.0), + message + ); + } + } + + fn get_info(&self) -> ClientInfo { + ClientInfo { + protocol_version: Default::default(), + capabilities: ClientCapabilities::default(), + client_info: Implementation { + name: "stakpak-proxy-client".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + title: Some("Stakpak MCP Proxy Client Example".to_string()), + icons: Some(vec![]), + website_url: Some("https://stakpak.dev".to_string()), + }, + } + } +} + +#[tokio::main] +async fn main() -> Result<()> { + let _ = dotenvy::dotenv(); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + // Get the stakpak binary path (defaults to "stakpak" in PATH) + let stakpak_bin = std::env::var("STAKPAK_BIN").unwrap_or_else(|_| "stakpak".to_string()); + + info!("Starting MCP proxy via: {} mcp proxy", stakpak_bin); + info!("The proxy will read config from ~/.stakpak/mcp.toml (or mcp.json)"); + + // Spawn the MCP proxy as a child process + let mut cmd = Command::new(&stakpak_bin); + cmd.arg("mcp").arg("proxy"); + + // Optional: specify a custom config file + // cmd.arg("--config-file").arg("/path/to/mcp.toml"); + + let proc = TokioChildProcess::new(cmd).context("Failed to spawn stakpak mcp proxy")?; + + // Connect to the proxy via stdio + let client_handler = ProxyClientHandler; + let service: RunningService = client_handler + .serve(proc) + .await + .context("Failed to initialize MCP proxy connection")?; + + info!("Connected to MCP proxy successfully!"); + + if let Some(server_info) = service.peer_info() { + info!("Proxy server info: {:?}", server_info.server_info); + } + + // Wait for upstream servers to initialize + // The proxy connects to upstream MCP servers asynchronously + info!("Waiting for upstream servers to initialize..."); + sleep(Duration::from_secs(2)).await; + + // List all tools available through the proxy with retry logic + info!("Getting available tools from MCP proxy (aggregated from all configured servers)"); + + let mut tools = Vec::new(); + for attempt in 1..=3 { + let response = service + .list_tools(Default::default()) + .await + .context("Failed to list tools")?; + tools = response.tools; + + if !tools.is_empty() { + break; + } + + if attempt < 3 { + info!( + "No tools found yet, retrying in 1 second... (attempt {}/3)", + attempt + ); + sleep(Duration::from_secs(1)).await; + } + } + + info!("Available tools ({} total):", tools.len()); + for tool in &tools { + info!( + "- {} : {}", + tool.name, + tool.description.as_deref().unwrap_or("No description") + ); + } + + if tools.is_empty() { + info!( + "No tools available. Make sure you have MCP servers configured in ~/.stakpak/mcp.toml" + ); + return Ok(()); + } + + // Example: Try calling a filesystem tool if available + // Note: The proxy prefixes tool names with "{server_name}__" (e.g., "filesystem__read_file") + if tools.iter().any(|t| t.name == "filesystem__read_file") { + info!("\n=== Calling filesystem__read_file tool ==="); + + use rmcp::model::CallToolRequestParam; + + let tool_params = CallToolRequestParam { + name: "filesystem__read_file".into(), + arguments: Some(rmcp::object!({ + "path": "/etc/hostname" + })), + }; + + info!("Reading /etc/hostname"); + + match service.call_tool(tool_params).await { + Ok(result) => { + info!("Tool call successful!"); + for content_item in &result.content { + if let Some(text) = content_item.as_text() { + info!("Content: {}", text.text); + } + } + } + Err(e) => { + info!("Failed to call tool: {:?}", e); + } + } + } + + // Example: Try Stakpak's generate_password tool if available + // This requires adding Stakpak's MCP server to your proxy config: + // [mcpServers.stakpak] + // url = "https://127.0.0.1:8420/mcp" + if tools.iter().any(|t| t.name == "stakpak__generate_password") { + info!("\n=== Calling stakpak__generate_password tool ==="); + + use rmcp::model::CallToolRequestParam; + + let tool_params = CallToolRequestParam { + name: "stakpak__generate_password".into(), + arguments: Some(rmcp::object!({ + "length": 16, + "with_symbols": true + })), + }; + + info!("Generating password (length=16, with symbols)"); + + match service.call_tool(tool_params).await { + Ok(result) => { + info!("Tool call successful!"); + for content_item in &result.content { + if let Some(text) = content_item.as_text() { + info!("Generated password: {}", text.text); + } + } + } + Err(e) => { + info!("Failed to call tool: {:?}", e); + } + } + } + + info!("\nDone! The proxy will shut down when this client exits."); + + Ok(()) +} diff --git a/examples/mcp-clients/rust-client/examples/tool_calling.rs b/examples/mcp-clients/rust-client/examples/tool_calling.rs new file mode 100644 index 00000000..fe7219eb --- /dev/null +++ b/examples/mcp-clients/rust-client/examples/tool_calling.rs @@ -0,0 +1,186 @@ +use anyhow::{Context, Result}; +use reqwest::Client; +use rig::{ + client::CompletionClient, + completion::Prompt, + providers::gemini::{self, completion::GEMINI_2_5_FLASH}, +}; +use rmcp::{ + model::{ClientCapabilities, ClientInfo, Implementation}, + service::RunningService, + transport::{ + streamable_http_client::StreamableHttpClientTransportConfig, StreamableHttpClientTransport, + }, + RoleClient, ServiceExt, +}; +use rustls::{ClientConfig, RootCertStore}; +use rustls_pemfile::{certs, pkcs8_private_keys}; +use std::{ + fs::File, + io::BufReader, + path::{Path, PathBuf}, +}; +use tracing::info; +use tracing_subscriber; + +fn load_certs(path: &Path) -> Result>> { + let file = File::open(path).context(format!("Failed to open cert file: {:?}", path))?; + let mut reader = BufReader::new(file); + let certs: Vec<_> = certs(&mut reader) + .collect::, _>>() + .context("Failed to parse certificates")?; + Ok(certs) +} + +fn load_private_key(path: &Path) -> Result> { + let file = File::open(path).context(format!("Failed to open key file: {:?}", path))?; + let mut reader = BufReader::new(file); + let keys = pkcs8_private_keys(&mut reader) + .collect::, _>>() + .context("Failed to parse private key")?; + + if keys.is_empty() { + anyhow::bail!("No private key found in file"); + } + + Ok(rustls::pki_types::PrivateKeyDer::Pkcs8(keys[0].clone_key())) +} + +fn create_mtls_client_config( + ca_cert_path: &Path, + client_cert_path: &Path, + client_key_path: &Path, +) -> Result { + // Install default crypto provider if not already installed + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + + let ca_certs = load_certs(ca_cert_path)?; + let mut root_cert_store = RootCertStore { + roots: Vec::with_capacity(ca_certs.len()), + }; + for cert in ca_certs { + root_cert_store + .add(cert) + .context("Failed to add CA cert to root store")?; + } + + let client_certs = load_certs(client_cert_path)?; + let client_key = load_private_key(client_key_path)?; + + let config = ClientConfig::builder() + .with_root_certificates(root_cert_store) + .with_client_auth_cert(client_certs, client_key) + .context("Failed to build client config with mTLS")?; + + Ok(config) +} + +#[tokio::main] +async fn main() -> Result<()> { + dotenvy::dotenv().ok(); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let certs_dir = std::env::var("CERTS_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| { + std::env::var("HOME") + .map(PathBuf::from) + .map(|p| p.join(".stakpak").join("certs")) + .expect("HOME environment variable not set") + }); + + let port = std::env::var("MCP_SERVER_PORT").unwrap_or_else(|_| "8420".to_string()); + let server_url = format!("https://127.0.0.1:{}", port); + + let ca_cert = certs_dir.join("ca.pem"); + let client_cert = certs_dir.join("client-cert.pem"); + let client_key = certs_dir.join("client-key.pem"); + + info!("Connecting to MCP server with mTLS"); + info!("Server: {}", server_url); + info!("Certificates directory: {}", certs_dir.display()); + + let tls_config = create_mtls_client_config(&ca_cert, &client_cert, &client_key) + .context("Failed to create mTLS configuration")?; + + // Create HTTP client with mTLS + let http_client = Client::builder() + .use_preconfigured_tls(tls_config) + .build() + .context("Failed to build HTTP client")?; + + let transport = StreamableHttpClientTransport::with_client( + http_client, + StreamableHttpClientTransportConfig::with_uri(format!("{}/mcp", server_url)), + ); + + let client_info = ClientInfo { + protocol_version: Default::default(), + capabilities: ClientCapabilities::default(), + client_info: Implementation { + name: "stakpak-rust-client".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + title: Some("Stakpak Rust MCP Client".to_string()), + icons: Some(vec![]), + website_url: Some("https://stakpak.dev".to_string()), + }, + }; + + let service: RunningService = client_info + .serve(transport) + .await + .context("Failed to initialize MCP service")?; + + info!("Connected to MCP server successfully!"); + + if let Some(server_info) = service.peer_info() { + info!("Server info: {:?}", server_info.server_info); + } + + info!("Getting available tools from MCP server"); + let response = service + .list_tools(Default::default()) + .await + .context("Failed to list tools")?; + + let tools = response.tools; + + info!("Available tools:"); + for tool in &tools { + info!( + "- {} : {}", + tool.name, + tool.description.as_deref().unwrap_or("No description") + ); + } + + if tools.is_empty() { + info!("No tools available from server"); + return Ok(()); + } + + info!("Setting up LLM agent with automatic tool calling"); + let api_key = + std::env::var("GEMINI_API_KEY").expect("`GEMINI_API_KEY` environment variable must be set"); + let gemini_client = gemini::Client::new(&api_key)?; + let agent = gemini_client + .agent(GEMINI_2_5_FLASH) + .preamble("You are a helpful devops assistant with access to tools related to devops") + .rmcp_tools(tools, service.peer().to_owned()) + .build(); + + info!("LLM agent initialized with automatic tool calling"); + let response = agent + .prompt("generate a password with length of 20 and add symbols") + .await?; + info!("LLM response:"); + info!("{}", response); + + Ok(()) +} diff --git a/libs/mcp/proxy/src/server/mod.rs b/libs/mcp/proxy/src/server/mod.rs index 0ce71ac4..fba9d1d8 100644 --- a/libs/mcp/proxy/src/server/mod.rs +++ b/libs/mcp/proxy/src/server/mod.rs @@ -19,8 +19,8 @@ use rmcp::ServiceExt; use rmcp::transport::StreamableHttpClientTransport; use rmcp::transport::TokioChildProcess; use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig; -use stakpak_shared::cert_utils::CertificateChain; -use stakpak_shared::secret_manager::SecretManager; +use stakpak_shared::cert_utils::{CertificateChain, CertificateStrategy}; +use stakpak_shared::secret_manager::{SecretManagerHandle, launch_secret_manager}; use std::collections::HashMap; use std::future::Future; use std::sync::Arc; @@ -54,17 +54,19 @@ pub struct ProxyServer { // Track if upstream clients have been initialized clients_initialized: Arc>, // Secret manager for redacting secrets in tool responses - secret_manager: SecretManager, + secret_manager: Arc, } impl ProxyServer { pub fn new(config: ClientPoolConfig, redact_secrets: bool, privacy_mode: bool) -> Self { + let secret_manager_handle = launch_secret_manager(redact_secrets, privacy_mode, None); + Self { pool: Arc::new(ClientPool::new()), request_id_to_client: Arc::new(Mutex::new(HashMap::new())), client_config: Arc::new(Mutex::new(Some(config))), clients_initialized: Arc::new(Mutex::new(false)), - secret_manager: SecretManager::new(redact_secrets, privacy_mode), + secret_manager: secret_manager_handle, } } @@ -172,7 +174,7 @@ impl ProxyServer { } /// Prepare tool parameters, restoring any redacted secrets - fn prepare_tool_params( + async fn prepare_tool_params( &self, params: &CallToolRequestParam, tool_name: &str, @@ -183,11 +185,14 @@ impl ProxyServer { if let Some(arguments) = &tool_params.arguments && let Ok(arguments_str) = serde_json::to_string(arguments) { - let restored = self + if let Ok(restored) = self .secret_manager - .restore_secrets_in_string(&arguments_str); - if let Ok(restored_arguments) = serde_json::from_str(&restored) { - tool_params.arguments = Some(restored_arguments); + .restore_secrets_in_string(&arguments_str) + .await + { + if let Ok(restored_arguments) = serde_json::from_str(&restored) { + tool_params.arguments = Some(restored_arguments); + } } } @@ -223,20 +228,21 @@ impl ProxyServer { } /// Redact secrets in content items - fn redact_content(&self, content: Vec) -> Vec { - content - .into_iter() - .map(|item| { - if let Some(text_content) = item.raw.as_text() { - let redacted = self - .secret_manager - .redact_and_store_secrets(&text_content.text, None); - Content::text(&redacted) - } else { - item - } - }) - .collect() + async fn redact_content(&self, content: Vec) -> Vec { + let mut result = Vec::with_capacity(content.len()); + for item in content { + if let Some(text_content) = item.raw.as_text() { + let redacted = self + .secret_manager + .redact_and_store_secrets(&text_content.text, None) + .await + .unwrap_or_else(|_| text_content.text.clone()); + result.push(Content::text(&redacted)); + } else { + result.push(item); + } + } + result } /// Initialize a single upstream client from server configuration @@ -297,7 +303,7 @@ impl ProxyServer { .pool_max_idle_per_host(10) .tcp_keepalive(std::time::Duration::from_secs(60)); - // Configure mTLS if certificate chain is provided + // Configure mTLS: use provided chain, or auto-load from ~/.stakpak/certs/ for HTTPS if let Some(cert_chain) = certificate_chain.as_ref() { match cert_chain.create_client_config() { Ok(tls_config) => { @@ -308,6 +314,36 @@ impl ProxyServer { return; } } + } else if url.starts_with("https://") { + // Try to auto-load certificates from default location + if let Ok(certs_dir) = stakpak_shared::cert_utils::default_cert_dir() { + if CertificateChain::exists_in_directory(&certs_dir) { + match CertificateChain::load_client_config(&certs_dir) { + Ok(tls_config) => { + tracing::info!( + "Loaded mTLS certificates from {:?} for {}", + certs_dir, + name + ); + client_builder = + client_builder.use_preconfigured_tls(tls_config); + } + Err(e) => { + tracing::warn!( + "Failed to load mTLS certificates for {}: {:?}", + name, + e + ); + } + } + } else { + tracing::debug!( + "No mTLS certificates found at {:?} for HTTPS connection to {}", + certs_dir, + name + ); + } + } } if let Some(headers_map) = headers { @@ -465,7 +501,7 @@ impl ServerHandler for ProxyServer { .await; // Prepare and execute the tool call - let tool_params = self.prepare_tool_params(¶ms, &tool_name); + let tool_params = self.prepare_tool_params(¶ms, &tool_name).await; let result = self .execute_with_cancellation(&ctx, &client_peer, tool_params) .await; @@ -484,7 +520,7 @@ impl ServerHandler for ProxyServer { ) })?; - result.content = self.redact_content(result.content); + result.content = self.redact_content(result.content).await; Ok(result) } @@ -631,7 +667,7 @@ impl ServerHandler for ProxyServer { pub async fn start_proxy_server( config: ClientPoolConfig, tcp_listener: TcpListener, - certificate_chain: Arc, + cert_strategy: CertificateStrategy, redact_secrets: bool, privacy_mode: bool, shutdown_rx: Option>, @@ -650,7 +686,7 @@ pub async fn start_proxy_server( let router = axum::Router::new().nest_service("/mcp", service); - let tls_config = certificate_chain.create_server_config()?; + let tls_config = cert_strategy.load_server_config()?; let rustls_config = axum_server::tls_rustls::RustlsConfig::from_config(Arc::new(tls_config)); let handle = axum_server::Handle::new(); diff --git a/libs/mcp/server/Cargo.toml b/libs/mcp/server/Cargo.toml index f89082de..4cfd61ec 100644 --- a/libs/mcp/server/Cargo.toml +++ b/libs/mcp/server/Cargo.toml @@ -27,6 +27,7 @@ fast_html2md = "=0.0.48" walkdir = { workspace = true } toml = "0.8" similar = { workspace = true } +rustls = { workspace = true } [dev-dependencies] tempfile = "3.8" diff --git a/libs/mcp/server/README_SECRETS.md b/libs/mcp/server/README_SECRETS.md index a6a1281e..242bddbb 100644 --- a/libs/mcp/server/README_SECRETS.md +++ b/libs/mcp/server/README_SECRETS.md @@ -178,6 +178,58 @@ data: # LLM can still work with the structure and reference the token ``` +## Password Type Protection + +### Overview + +use a dedicated `Password` type from the `secrecy` crate to provide compile time protection against accidental password logging + +### Implementation + +The `Password` type (defined in `libs/shared/src/models/password.rs`) wraps `SecretString` and provides: + +- **Automatic Log Protection**: Accidental `println!("{:?}", struct)` won't leak passwords +- **Type Safety**: Enforces at compile time that any Password value is not empty and longer than 8 characters + +### Usage + +All password fields in structs now use `Option` instead of `Option`: + +```rust +use stakpak_shared::models::Password; + +#[derive(Serialize, Deserialize)] +pub struct RemoteConnectionInfo { + pub connection_string: String, + pub password: Option, + pub private_key_path: Option, +} + +// Creating a password +let password = Password::new("my_secret_password"); + +// Debug output (redacted) +println!("{:?}", password); +// => Password(SecretString(Secret([REDACTED]))) + +// Accessing the password when needed +let connection_result = session.authenticate_password( + username, + password.expose_secret() // Explicit call required +); +``` + +### Structs Using Password Type + +The following structs have been updated to use `Password`: + +- `RemoteConnectionInfo` (libs/shared/src/remote_connection.rs) +- `RunCommandRequest` (libs/mcp/server/src/local_tools.rs) +- `ViewRequest` (libs/mcp/server/src/local_tools.rs) +- `StrReplaceRequest` (libs/mcp/server/src/local_tools.rs) +- `CreateRequest` (libs/mcp/server/src/local_tools.rs) +- `RemoveRequest` (libs/mcp/server/src/local_tools.rs) + ## Configuration The feature is controlled by the `redact_secrets` flag in the MCP server configuration: diff --git a/libs/mcp/server/src/lib.rs b/libs/mcp/server/src/lib.rs index 4815456c..68ca48e9 100644 --- a/libs/mcp/server/src/lib.rs +++ b/libs/mcp/server/src/lib.rs @@ -13,7 +13,6 @@ pub use tool_container::ToolContainer; use tracing::error; use stakpak_api::AgentProvider; -use stakpak_shared::cert_utils::CertificateChain; use stakpak_shared::models::subagent::SubagentConfigs; use stakpak_shared::task_manager::{TaskManager, TaskManagerHandle}; @@ -101,24 +100,6 @@ impl std::str::FromStr for ToolMode { } } -#[derive(Clone)] -pub struct AuthConfig { - pub token: Option, -} - -impl AuthConfig { - pub async fn new(disabled: bool) -> Self { - let token = if disabled { - None - } else { - let token = stakpak_shared::utils::generate_password(64, true); - Some(token) - }; - - Self { token } - } -} - pub struct MCPServerConfig { pub client: Option>, pub bind_address: String, @@ -127,23 +108,7 @@ pub struct MCPServerConfig { pub enabled_tools: EnabledToolsConfig, pub tool_mode: ToolMode, pub subagent_configs: Option, - pub certificate_chain: Arc>, -} - -/// Initialize gitleaks configuration if secret redaction is enabled -async fn init_gitleaks_if_needed(redact_secrets: bool, privacy_mode: bool) { - if redact_secrets { - tokio::spawn(async move { - match std::panic::catch_unwind(|| { - stakpak_shared::secrets::initialize_gitleaks_config(privacy_mode) - }) { - Ok(_rule_count) => {} - Err(_) => { - // Failed to initialize, will initialize on first use - } - } - }); - } + pub server_config: Arc>, } /// Create graceful shutdown handler @@ -288,13 +253,11 @@ async fn start_server_internal( tcp_listener: TcpListener, shutdown_rx: Option>, ) -> Result<()> { - init_gitleaks_if_needed(config.redact_secrets, config.privacy_mode).await; - // Create and start TaskManager let task_manager = TaskManager::new(); let task_manager_handle = task_manager.handle(); - // Spawn the task manager to run in background_manager_handle_for_ + // Spawn the task manager to run in background tokio::spawn(async move { task_manager.run().await; }); @@ -309,10 +272,9 @@ async fn start_server_internal( let router = axum::Router::new().nest_service("/mcp", service); - if let Some(cert_chain) = config.certificate_chain.as_ref() { - let tls_config = cert_chain.create_server_config()?; + if let Some(server_config) = config.server_config.as_ref() { let rustls_config = - axum_server::tls_rustls::RustlsConfig::from_config(Arc::new(tls_config)); + axum_server::tls_rustls::RustlsConfig::from_config(Arc::new(server_config.clone())); let handle = axum_server::Handle::new(); let shutdown_handle = handle.clone(); @@ -356,8 +318,6 @@ pub async fn start_server_stdio( config: MCPServerConfig, shutdown_rx: Option>, ) -> Result<()> { - init_gitleaks_if_needed(config.redact_secrets, config.privacy_mode).await; - // Create and start TaskManager let task_manager = TaskManager::new(); let task_manager_handle = task_manager.handle(); diff --git a/libs/mcp/server/src/local_tools.rs b/libs/mcp/server/src/local_tools.rs index d2bc1b1b..34bed493 100644 --- a/libs/mcp/server/src/local_tools.rs +++ b/libs/mcp/server/src/local_tools.rs @@ -4,6 +4,7 @@ use rmcp::{ErrorData as McpError, handler::server::wrapper::Parameters, model::* use rmcp::{RoleServer, tool_router}; use serde::Deserialize; use stakpak_shared::file_backup_manager::FileBackupManager; +use stakpak_shared::models::password::Password; use stakpak_shared::remote_connection::{ PathLocation, RemoteConnection, RemoteConnectionInfo, RemoteFileSystemProvider, }; @@ -42,7 +43,7 @@ pub struct RunCommandRequest { )] pub remote: Option, #[schemars(description = "Optional password for remote connection")] - pub password: Option, + pub password: Option, #[schemars(description = "Optional path to private key for remote connection")] pub private_key_path: Option, } @@ -90,7 +91,7 @@ pub struct ViewRequest { )] pub view_range: Option<[i32; 2]>, #[schemars(description = "Optional password for remote connection (if path is remote)")] - pub password: Option, + pub password: Option, #[schemars( description = "Optional path to private key for remote connection (if path is remote)" )] @@ -118,7 +119,7 @@ pub struct StrReplaceRequest { )] pub replace_all: Option, #[schemars(description = "Optional password for remote connection (if path is remote)")] - pub password: Option, + pub password: Option, #[schemars( description = "Optional path to private key for remote connection (if path is remote)" )] @@ -136,7 +137,7 @@ pub struct CreateRequest { )] pub file_text: String, #[schemars(description = "Optional password for remote connection (if path is remote)")] - pub password: Option, + pub password: Option, #[schemars( description = "Optional path to private key for remote connection (if path is remote)" )] @@ -145,10 +146,13 @@ pub struct CreateRequest { #[derive(Debug, Deserialize, schemars::JsonSchema)] pub struct GeneratePasswordRequest { - #[schemars(description = "The length of the password to generate")] + #[schemars( + description = "The length of the password to generate (minimum: 8 characters, default: 15)", + range(min = 8) + )] pub length: Option, - #[schemars(description = "Whether to disallow symbols in the password (default: false)")] - pub no_symbols: Option, + #[schemars(description = "Whether to include symbols in the password (default: true)")] + pub include_symbols: Option, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -162,7 +166,7 @@ pub struct RemoveRequest { )] pub recursive: Option, #[schemars(description = "Optional password for remote connection (if path is remote)")] - pub password: Option, + pub password: Option, #[schemars( description = "Optional path to private key for remote connection (if path is remote)" )] @@ -289,11 +293,6 @@ Use the get_all_tasks tool to monitor task progress, or the cancel_task tool to private_key_path, }): Parameters, ) -> Result { - // Restore secrets in the command before execution - let actual_command = self - .get_secret_manager() - .restore_secrets_in_string(&command); - let timeout_duration = timeout.map(std::time::Duration::from_secs); // Handle both local and remote async commands using TaskManager @@ -306,12 +305,12 @@ Use the get_all_tasks tool to monitor task progress, or the cancel_task tool to }; self.get_task_manager() - .start_task(actual_command, timeout_duration, Some(remote_connection)) + .start_task(command, timeout_duration, Some(remote_connection)) .await } else { // Local async command (existing logic) self.get_task_manager() - .start_task(actual_command, timeout_duration, None) + .start_task(command, timeout_duration, None) .await }; @@ -382,12 +381,6 @@ Use the full Task ID from this output with cancel_task to cancel specific tasks. "N/A".to_string() }; - let output_str = if let Some(ref out) = task.output { - out.clone() - } else { - "No output yet".to_string() - }; - let escaped_command = task .command .chars() @@ -395,7 +388,10 @@ Use the full Task ID from this output with cancel_task to cancel specific tasks. .collect::() .replace('|', "\\|") .replace('\n', " "); - let escaped_output = output_str + let escaped_output = task + .output + .as_deref() + .unwrap_or("No output yet") .chars() .take(100) .collect::() @@ -548,7 +544,7 @@ Use this tool to check the progress and results of long-running background tasks "N/A".to_string() }; - let output_str = if let Some(ref output) = task_info.output { + let task_output = if let Some(ref output) = task_info.output { match handle_large_output(output, "task.output", 300, false) { Ok(result) => result, Err(e) => { @@ -570,7 +566,7 @@ Use this tool to check the progress and results of long-running background tasks task_info.start_time.format("%Y-%m-%d %H:%M:%S UTC"), duration_str, task_info.command, - output_str + task_output ); Ok(CallToolResult::success(vec![Content::text(output)])) @@ -701,7 +697,6 @@ When replacing code, ensure the new text maintains proper syntax, indentation, a } else { // Handle local file replacement self.str_replace_local(&path, &old_str, &new_str, replace_all) - .await } } @@ -755,13 +750,13 @@ SECRET HANDLING: description = "Generate a cryptographically secure password with the specified constraints. The generated password will be automatically redacted in the response for security. PARAMETERS: -- length: The length of the password to generate (default: 15 characters) -- no_symbols: Whether to exclude symbols from the password (default: false, includes symbols) +- length: The length of the password to generate (minimum: 8 characters, default: 15) +- include_symbols: Whether to include symbols in the password (default: true) CHARACTER SETS: - Letters: A-Z, a-z (always included) -- Numbers: 0-9 (always included) -- Symbols: !@#$%^&*()_+-=[]{}|;:,.<>? (included unless no_symbols=true) +- Numbers: 0-9 (always included) +- Symbols: !@#$%^&*()_+-=[]{}|;:,.<>? (included unless include_symbols=false) SECURITY FEATURES: - Uses cryptographically secure random number generation @@ -771,18 +766,22 @@ SECURITY FEATURES: )] pub async fn generate_password( &self, - Parameters(GeneratePasswordRequest { length, no_symbols }): Parameters< - GeneratePasswordRequest, - >, + Parameters(GeneratePasswordRequest { + length, + include_symbols, + }): Parameters, ) -> Result { let length = length.unwrap_or(15); - let no_symbols = no_symbols.unwrap_or(false); - - let password = stakpak_shared::utils::generate_password(length, no_symbols); + let include_symbols = include_symbols.unwrap_or(true); + // Delegate generation and redaction to the SecretManager actor let redacted_password = self .get_secret_manager() - .redact_and_store_password(&password, &password); + .generate_password(length, include_symbols) + .await + .map_err(|e| { + McpError::internal_error(format!("Failed to generate password: {}", e), None) + })?; Ok(CallToolResult::success(vec![Content::text( &redacted_password, @@ -955,7 +954,7 @@ SAFETY NOTES: async fn get_remote_connection( &self, path: &str, - password: Option, + password: Option, private_key_path: Option, ) -> Result<(Arc, String), CallToolResult> { let path_location = PathLocation::parse(path).map_err(|e| { @@ -1012,12 +1011,10 @@ SAFETY NOTES: command: &str, timeout: Option, remote: Option, - password: Option, + password: Option, private_key_path: Option, ctx: &RequestContext, ) -> Result { - let actual_command = self.get_secret_manager().restore_secrets_in_string(command); - if let Some(remote_str) = &remote { // Remote execution let connection_info = RemoteConnectionInfo { @@ -1040,7 +1037,7 @@ SAFETY NOTES: let timeout_duration = timeout.map(std::time::Duration::from_secs); let (output, exit_code) = connection - .execute_command(&actual_command, timeout_duration, Some(ctx)) + .execute_command(command, timeout_duration, Some(ctx)) .await .map_err(|e| { error!("Failed to execute remote command: {}", e); @@ -1061,8 +1058,7 @@ SAFETY NOTES: }) } else { // Local execution - existing logic - self.execute_local_command(&actual_command, timeout, ctx) - .await + self.execute_local_command(command, timeout, ctx).await } } @@ -1514,10 +1510,7 @@ SAFETY NOTES: new_str: &str, replace_all: Option, ) -> Result { - let actual_old_str = self.get_secret_manager().restore_secrets_in_string(old_str); - let actual_new_str = self.get_secret_manager().restore_secrets_in_string(new_str); - - if actual_old_str == actual_new_str { + if old_str == new_str { return Ok(CallToolResult::error(vec![ Content::text("OLD_STR_NEW_STR_IDENTICAL"), Content::text( @@ -1537,7 +1530,7 @@ SAFETY NOTES: } }; - if !content.contains(&actual_old_str) { + if !content.contains(old_str) { return Ok(CallToolResult::error(vec![ Content::text("STRING_NOT_FOUND"), Content::text("The string old_str was not found in the file"), @@ -1545,14 +1538,14 @@ SAFETY NOTES: } let new_content = if replace_all.unwrap_or(false) { - content.replace(&actual_old_str, &actual_new_str) + content.replace(old_str, new_str) } else { - content.replacen(&actual_old_str, &actual_new_str, 1) + content.replacen(old_str, new_str, 1) }; let replaced_count = if replace_all.unwrap_or(false) { - content.matches(&actual_old_str).count() - } else if content.contains(&actual_old_str) { + content.matches(old_str).count() + } else if content.contains(old_str) { 1 } else { 0 @@ -1574,21 +1567,18 @@ SAFETY NOTES: replaced_count, unified_diff ); - Ok(CallToolResult::success(vec![Content::text(&output)])) + Ok(CallToolResult::success(vec![Content::text(output)])) } /// Replace a specific string in a local file - async fn str_replace_local( + fn str_replace_local( &self, path: &str, old_str: &str, new_str: &str, replace_all: Option, ) -> Result { - let actual_old_str = self.get_secret_manager().restore_secrets_in_string(old_str); - let actual_new_str = self.get_secret_manager().restore_secrets_in_string(new_str); - - if actual_old_str == actual_new_str { + if old_str == new_str { return Ok(CallToolResult::error(vec![ Content::text("OLD_STR_NEW_STR_IDENTICAL"), Content::text( @@ -1608,7 +1598,7 @@ SAFETY NOTES: } }; - if !original_content.contains(&actual_old_str) { + if !original_content.contains(old_str) { return Ok(CallToolResult::error(vec![ Content::text("STRING_NOT_FOUND"), Content::text("The string old_str was not found in the file"), @@ -1616,14 +1606,14 @@ SAFETY NOTES: } let new_content = if replace_all.unwrap_or(false) { - original_content.replace(&actual_old_str, &actual_new_str) + original_content.replace(old_str, new_str) } else { - original_content.replacen(&actual_old_str, &actual_new_str, 1) + original_content.replacen(old_str, new_str, 1) }; let replaced_count = if replace_all.unwrap_or(false) { - original_content.matches(&actual_old_str).count() - } else if original_content.contains(&actual_old_str) { + original_content.matches(old_str).count() + } else if original_content.contains(old_str) { 1 } else { 0 @@ -1644,7 +1634,7 @@ SAFETY NOTES: replaced_count, unified_diff ); - Ok(CallToolResult::success(vec![Content::text(&output)])) + Ok(CallToolResult::success(vec![Content::text(output)])) } /// Create a remote file with the specified content @@ -1683,16 +1673,8 @@ SAFETY NOTES: } } - // Restore secrets in the file content before writing - let actual_file_text = self - .get_secret_manager() - .restore_secrets_in_string(file_text); - // Create the file using the correct SFTP method - if let Err(e) = conn - .create_file(remote_path, actual_file_text.as_bytes()) - .await - { + if let Err(e) = conn.create_file(remote_path, file_text.as_bytes()).await { error!("Failed to create remote file '{}': {}", remote_path, e); return Ok(CallToolResult::error(vec![ Content::text("CREATE_ERROR"), @@ -1703,7 +1685,7 @@ SAFETY NOTES: ])); } - let lines = actual_file_text.lines().count(); + let lines = file_text.lines().count(); Ok(CallToolResult::success(vec![Content::text(format!( "Successfully created remote file {} with {} lines", original_path, lines @@ -1732,16 +1714,9 @@ SAFETY NOTES: ])); } - // Restore secrets in the file content before writing - let actual_file_text = self - .get_secret_manager() - .restore_secrets_in_string(file_text); - - match fs::write(path, actual_file_text) { + match fs::write(path, file_text) { Ok(_) => { - let lines = fs::read_to_string(path) - .map(|content| content.lines().count()) - .unwrap_or(0); + let lines = file_text.lines().count(); Ok(CallToolResult::success(vec![Content::text(format!( "Successfully created file {} with {} lines", path, lines diff --git a/libs/mcp/server/src/tool_container.rs b/libs/mcp/server/src/tool_container.rs index 3be45fbb..30d78277 100644 --- a/libs/mcp/server/src/tool_container.rs +++ b/libs/mcp/server/src/tool_container.rs @@ -7,14 +7,14 @@ use rmcp::{ use stakpak_api::AgentProvider; use stakpak_shared::models::subagent::SubagentConfigs; use stakpak_shared::remote_connection::RemoteConnectionManager; -use stakpak_shared::secret_manager::SecretManager; +use stakpak_shared::secret_manager::{SecretManagerHandle, launch_secret_manager}; use stakpak_shared::task_manager::TaskManagerHandle; use std::sync::Arc; #[derive(Clone)] pub struct ToolContainer { pub client: Option>, - pub secret_manager: SecretManager, + pub secret_manager: Arc, pub task_manager: Arc, pub remote_connection_manager: Arc, pub subagent_configs: Option, @@ -33,9 +33,11 @@ impl ToolContainer { subagent_configs: Option, tool_router: ToolRouter, ) -> Result { + let secret_manager = launch_secret_manager(redact_secrets, privacy_mode, None); + Ok(Self { client, - secret_manager: SecretManager::new(redact_secrets, privacy_mode), + secret_manager, task_manager, remote_connection_manager: Arc::new(RemoteConnectionManager::new()), subagent_configs, @@ -44,7 +46,7 @@ impl ToolContainer { }) } - pub fn get_secret_manager(&self) -> &SecretManager { + pub fn get_secret_manager(&self) -> &SecretManagerHandle { &self.secret_manager } diff --git a/libs/shared/Cargo.toml b/libs/shared/Cargo.toml index 27caa2d9..abf0606a 100644 --- a/libs/shared/Cargo.toml +++ b/libs/shared/Cargo.toml @@ -16,10 +16,13 @@ tokio = { workspace = true } anyhow = { workspace = true } toml = { workspace = true } rand = { workspace = true } +schemars = { workspace = true } +secrecy = { version = "0.10.3", features = ["serde"] } walkdir = { workspace = true } thiserror = { workspace = true } notify = { workspace = true } rustls = { workspace = true } +rustls-pemfile = { workspace = true } rcgen = { workspace = true } time = { workspace = true } regex = { workspace = true } @@ -47,3 +50,12 @@ tower = "0.4" hyper = "1.0" tokio-test = "0.4" axum-server = { version = "0.7", features = ["tls-rustls-no-provider"] } +divan = "0.1" + +[[bench]] +name = "actor_model_memory" +harness = false + +[[bench]] +name = "gitleaks_memory" +harness = false diff --git a/libs/shared/benches/README.md b/libs/shared/benches/README.md new file mode 100644 index 00000000..ab2ae348 --- /dev/null +++ b/libs/shared/benches/README.md @@ -0,0 +1,30 @@ +# Benchmarks + +Performance and memory profiling benchmarks for `stakpak-shared` library. + +## Running Benchmarks + +### Actor Model Memory Profiling +```bash +# All benchmarks +cargo bench -p stakpak-shared --bench actor_model_memory + +# Specific groups +cargo bench -p stakpak-shared --bench actor_model_memory actor_initialization +cargo bench -p stakpak-shared --bench actor_model_memory async_operations +cargo bench -p stakpak-shared --bench actor_model_memory concurrent_operations +cargo bench -p stakpak-shared --bench actor_model_memory throughput +cargo bench -p stakpak-shared --bench actor_model_memory content_scaling +``` + +### Gitleaks Memory Profiling +```bash +# Full benchmark suite +cargo bench -p stakpak-shared --bench gitleaks_memory + +# Specific categories +cargo bench -p stakpak-shared --bench gitleaks_memory config_initialization +cargo bench -p stakpak-shared --bench gitleaks_memory secret_detection +cargo bench -p stakpak-shared --bench gitleaks_memory real_world_scenarios +cargo bench -p stakpak-shared --bench gitleaks_memory batch_processing + diff --git a/libs/shared/benches/actor_model_memory.rs b/libs/shared/benches/actor_model_memory.rs new file mode 100644 index 00000000..342f4012 --- /dev/null +++ b/libs/shared/benches/actor_model_memory.rs @@ -0,0 +1,454 @@ +//! Memory profiling benchmarks for SecretManager Actor Model +//! +//! This benchmark suite measures memory allocations and performance of the +//! actor-based SecretManager implementation, including: +//! - Actor initialization overhead +//! - Message passing memory costs +//! - Async operation allocations +//! - Concurrent operation scaling +//! - Channel capacity impact +//! +//! Run with: cargo bench --bench actor_model_memory + +use divan::AllocProfiler; +use stakpak_shared::secret_manager; +use tokio::runtime::Runtime; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + divan::main(); +} + +/// Helper to create test content with various secret patterns +fn generate_content_with_secrets(num_secrets: usize) -> String { + let mut content = String::from("# Configuration file\n\n"); + + for i in 0..num_secrets { + match i % 5 { + 0 => content.push_str(&format!( + "export AWS_ACCESS_KEY_ID_{i}=AKIAIOSFODNN7EXAMPLE{i:04}\n" + )), + 1 => content.push_str(&format!( + "export GITHUB_TOKEN_{i}=ghp_1234567890abcdef1234567890abcde{i:05}\n" + )), + 2 => content.push_str(&format!( + "export API_KEY_{i}=Kx9mP2nQ8rT4vW7yZ3cF6hJ1lN5sA0bD{i:04}\n" + )), + 3 => content.push_str(&format!( + "export SECRET_TOKEN_{i}=xy9mP2nQ8rT4vW7yZ3cF6hJ1lN5sAdef{i:04}\n" + )), + 4 => content.push_str(&format!( + "export PRIVATE_KEY_{i}=sk-proj-abcdefghijklmnopqrstuvwxyz12{i:04}\n" + )), + _ => unreachable!(), + } + } + + content.push_str("\n# Non-secret configuration\n"); + content.push_str("export DEBUG=true\n"); + content.push_str("export PORT=3000\n"); + content +} + +/// Helper to generate content without secrets +fn generate_content_without_secrets(lines: usize) -> String { + let mut content = String::from("# Configuration file\n\n"); + for i in 0..lines { + content.push_str(&format!("export CONFIG_VALUE_{i}=some_value_{i}\n")); + } + content +} + +// ============================================================================ +// ACTOR INITIALIZATION BENCHMARKS +// ============================================================================ + +mod actor_initialization { + use super::*; + + /// Measure memory allocated during actor launch (default mode) + #[divan::bench] + fn launch_default_actor(bencher: divan::Bencher) { + // Keep runtime alive outside the benchmark to prevent panic + let rt = Runtime::new().unwrap(); + bencher.bench_local(|| { + let _guard = rt.enter(); + let _handle = + divan::black_box(secret_manager::launch_secret_manager(true, false, None)); + // Ensure actor finishes initialization by performing a dummy operation + drop(_handle); + }); + } + + /// Measure memory allocated during actor launch (privacy mode) + #[divan::bench] + fn launch_privacy_actor(bencher: divan::Bencher) { + let rt = Runtime::new().unwrap(); + bencher.bench_local(|| { + let _guard = rt.enter(); + let _handle = divan::black_box(secret_manager::launch_secret_manager(true, true, None)); + drop(_handle); + }); + } + + /// Measure memory allocated during actor launch (redaction disabled) + #[divan::bench] + fn launch_no_redaction_actor(bencher: divan::Bencher) { + let rt = Runtime::new().unwrap(); + bencher.bench_local(|| { + let _guard = rt.enter(); + let _handle = + divan::black_box(secret_manager::launch_secret_manager(false, false, None)); + drop(_handle); + }); + } + + /// Compare initialization modes + #[divan::bench(consts = [false, true])] + fn launch_with_privacy(bencher: divan::Bencher) { + let rt = Runtime::new().unwrap(); + bencher.bench_local(|| { + let _guard = rt.enter(); + let _handle = divan::black_box(secret_manager::launch_secret_manager( + true, + PRIVACY_MODE, + None, + )); + drop(_handle); + }); + } +} + +// ============================================================================ +// ASYNC OPERATIONS BENCHMARKS +// ============================================================================ + +mod async_operations { + use super::*; + + /// Benchmark async redaction with varying numbers of secrets + #[divan::bench(args = [0, 1, 5, 10, 20])] + fn redact_async(bencher: divan::Bencher, num_secrets: usize) { + let rt = Runtime::new().unwrap(); + let _guard = rt.enter(); + let handle = secret_manager::launch_secret_manager(true, false, None); + let content = generate_content_with_secrets(num_secrets); + + bencher + .with_inputs(|| content.clone()) + .bench_values(|content| { + rt.block_on(async { + divan::black_box( + handle + .redact_and_store_secrets(&content, None) + .await + .unwrap(), + ) + }) + }); + } + + /// Benchmark async restoration with varying numbers of secrets + #[divan::bench(args = [1, 5, 10, 20])] + fn restore_async(bencher: divan::Bencher, num_secrets: usize) { + let rt = Runtime::new().unwrap(); + let _guard = rt.enter(); + let handle = secret_manager::launch_secret_manager(true, false, None); + let content = generate_content_with_secrets(num_secrets); + + // Redact first to populate the map + let redacted = rt.block_on(async { + handle + .redact_and_store_secrets(&content, None) + .await + .unwrap() + }); + + bencher + .with_inputs(|| redacted.clone()) + .bench_values(|redacted| { + rt.block_on(async { + divan::black_box(handle.restore_secrets_in_string(&redacted).await.unwrap()) + }) + }); + } + + /// Benchmark async password redaction + #[divan::bench] + fn redact_password_async(bencher: divan::Bencher) { + let rt = Runtime::new().unwrap(); + let _guard = rt.enter(); + let handle = secret_manager::launch_secret_manager(true, false, None); + let password = "supersecretpassword123"; + + bencher.bench(|| { + rt.block_on(async { + divan::black_box(handle.redact_and_store_password(password).await.unwrap()) + }) + }); + } + + /// Benchmark privacy mode overhead + #[divan::bench(consts = [false, true])] + fn privacy_mode_async(bencher: divan::Bencher) { + let rt = Runtime::new().unwrap(); + let _guard = rt.enter(); + let handle = secret_manager::launch_secret_manager(true, PRIVACY_MODE, None); + let content = generate_content_with_secrets(5); + + bencher + .with_inputs(|| content.clone()) + .bench_values(|content| { + rt.block_on(async { + divan::black_box( + handle + .redact_and_store_secrets(&content, None) + .await + .unwrap(), + ) + }) + }); + } + + /// Benchmark with redaction disabled (passthrough mode) + #[divan::bench(args = [0, 5, 10])] + fn redact_disabled(bencher: divan::Bencher, num_secrets: usize) { + let rt = Runtime::new().unwrap(); + let _guard = rt.enter(); + let handle = secret_manager::launch_secret_manager(false, false, None); + let content = generate_content_with_secrets(num_secrets); + + bencher + .with_inputs(|| content.clone()) + .bench_values(|content| { + rt.block_on(async { + divan::black_box( + handle + .redact_and_store_secrets(&content, None) + .await + .unwrap(), + ) + }) + }); + } +} + +// ============================================================================ +// CONCURRENT OPERATIONS BENCHMARKS +// ============================================================================ + +mod concurrent_operations { + use super::*; + + /// Benchmark concurrent redaction operations + #[divan::bench(args = [2, 4, 8, 16])] + fn concurrent_redact(bencher: divan::Bencher, num_tasks: usize) { + let rt = Runtime::new().unwrap(); + let _guard = rt.enter(); + let handle = secret_manager::launch_secret_manager(true, false, None); + let content = generate_content_with_secrets(5); + + bencher.bench(|| { + rt.block_on(async { + let tasks: Vec<_> = (0..num_tasks) + .map(|_| { + let h = handle.clone(); + let c = content.clone(); + tokio::spawn( + async move { h.redact_and_store_secrets(&c, None).await.unwrap() }, + ) + }) + .collect(); + + for task in tasks { + let _ = divan::black_box(task.await.unwrap()); + } + }); + }); + } + + /// Benchmark concurrent restoration (async through actor) + #[divan::bench(args = [2, 4, 8, 16])] + fn concurrent_restore_async(bencher: divan::Bencher, num_tasks: usize) { + let rt = Runtime::new().unwrap(); + let _guard = rt.enter(); + let handle = secret_manager::launch_secret_manager(true, false, None); + let content = generate_content_with_secrets(5); + + let redacted = rt.block_on(async { + handle + .redact_and_store_secrets(&content, None) + .await + .unwrap() + }); + + bencher.bench(|| { + rt.block_on(async { + let tasks: Vec<_> = (0..num_tasks) + .map(|_| { + let h = handle.clone(); + let r = redacted.clone(); + tokio::spawn(async move { h.restore_secrets_in_string(&r).await.unwrap() }) + }) + .collect(); + + for task in tasks { + let _ = divan::black_box(task.await.unwrap()); + } + }); + }); + } + + /// Benchmark mixed read/write workload + #[divan::bench(args = [4, 8, 16])] + fn mixed_workload(bencher: divan::Bencher, num_tasks: usize) { + let rt = Runtime::new().unwrap(); + let _guard = rt.enter(); + let handle = secret_manager::launch_secret_manager(true, false, None); + let content = generate_content_with_secrets(5); + + let redacted = rt.block_on(async { + handle + .redact_and_store_secrets(&content, None) + .await + .unwrap() + }); + + bencher.bench(|| { + rt.block_on(async { + let tasks: Vec<_> = (0..num_tasks) + .map(|i| { + let h = handle.clone(); + let c = content.clone(); + let r = redacted.clone(); + tokio::spawn(async move { + if i % 2 == 0 { + // Write operation + h.redact_and_store_secrets(&c, None).await.unwrap() + } else { + // Read operation (through actor) + h.restore_secrets_in_string(&r).await.unwrap() + } + }) + }) + .collect(); + + for task in tasks { + let _ = divan::black_box(task.await.unwrap()); + } + }); + }); + } +} + +// ============================================================================ +// THROUGHPUT BENCHMARKS +// ============================================================================ + +mod throughput { + use super::*; + + /// Benchmark sequential redaction throughput + #[divan::bench(args = [10, 50, 100])] + fn sequential_redact_throughput(bencher: divan::Bencher, num_operations: usize) { + let rt = Runtime::new().unwrap(); + let _guard = rt.enter(); + let handle = secret_manager::launch_secret_manager(true, false, None); + let content = generate_content_with_secrets(3); + + bencher.bench(|| { + rt.block_on(async { + for _ in 0..num_operations { + let _ = divan::black_box( + handle + .redact_and_store_secrets(&content, None) + .await + .unwrap(), + ); + } + }); + }); + } + + /// Benchmark sequential restoration throughput (async through actor) + #[divan::bench(args = [10, 50, 100])] + fn sequential_restore_async_throughput(bencher: divan::Bencher, num_operations: usize) { + let rt = Runtime::new().unwrap(); + let _guard = rt.enter(); + let handle = secret_manager::launch_secret_manager(true, false, None); + let content = generate_content_with_secrets(3); + + let redacted = rt.block_on(async { + handle + .redact_and_store_secrets(&content, None) + .await + .unwrap() + }); + + bencher.bench(|| { + rt.block_on(async { + for _ in 0..num_operations { + let _ = divan::black_box( + handle.restore_secrets_in_string(&redacted).await.unwrap(), + ); + } + }); + }); + } +} + +// ============================================================================ +// CONTENT SIZE SCALING BENCHMARKS +// ============================================================================ + +mod content_scaling { + use super::*; + + /// Benchmark redaction with varying content sizes + #[divan::bench(args = [10, 100, 500, 1000])] + fn redact_varying_size(bencher: divan::Bencher, lines: usize) { + let rt = Runtime::new().unwrap(); + let _guard = rt.enter(); + let handle = secret_manager::launch_secret_manager(true, false, None); + let content = generate_content_without_secrets(lines); + + bencher + .with_inputs(|| content.clone()) + .bench_values(|content| { + rt.block_on(async { + divan::black_box( + handle + .redact_and_store_secrets(&content, None) + .await + .unwrap(), + ) + }) + }); + } + + /// Benchmark async restoration with varying content sizes + #[divan::bench(args = [10, 100, 500, 1000])] + fn restore_async_varying_size(bencher: divan::Bencher, lines: usize) { + let rt = Runtime::new().unwrap(); + let _guard = rt.enter(); + let handle = secret_manager::launch_secret_manager(true, false, None); + let content = generate_content_without_secrets(lines); + + let redacted = rt.block_on(async { + handle + .redact_and_store_secrets(&content, None) + .await + .unwrap() + }); + + bencher + .with_inputs(|| redacted.clone()) + .bench_values(|redacted| { + rt.block_on(async { + divan::black_box(handle.restore_secrets_in_string(&redacted).await.unwrap()) + }) + }); + } +} diff --git a/libs/shared/benches/benchmark_raw_data.md b/libs/shared/benches/benchmark_raw_data.md new file mode 100644 index 00000000..81730fa9 --- /dev/null +++ b/libs/shared/benches/benchmark_raw_data.md @@ -0,0 +1,187 @@ +# Raw Benchmark Data Comparison + +## 1. Redaction Operations - Varying Number of Secrets + +### Actor Model (async) +``` +async_operations::redact_async +├─ 1 secret: 130.7 µs (median: 130.6 µs) +├─ 5 secrets: 557.8 µs (median: 536.9 µs) +├─ 10 secrets: 916.6 µs (median: 891 µs) +└─ 20 secrets: 1.71 ms (median: 1.676 ms) +``` + +### Direct Implementation (sync) +``` +redaction_operations::redact_secrets +├─ 0 secrets: 40.7 µs (median: 36.71 µs) +├─ 1 secret: 105.3 µs (median: 101.8 µs) +├─ 5 secrets: 492.9 µs (median: 484.5 µs) +├─ 10 secrets: 890.2 µs (median: 873.4 µs) +└─ 20 secrets: 1.739 ms (median: 1.678 ms) +``` + +--- + +## 2. Secret Restoration Operations + +### Actor Model (async) +``` +async_operations::restore_async +├─ 1 secret: 20.59 µs (median: 20.19 µs) +├─ 5 secrets: 20.97 µs (median: 20.24 µs) +├─ 10 secrets: 21.68 µs (median: 21.11 µs) +└─ 20 secrets: 23.7 µs (median: 22.75 µs) +``` + +### Direct Implementation (sync) +``` +redaction_operations::restore_secrets +├─ 1 secret: 9.173 µs (median: 8.952 µs) +├─ 5 secrets: 10.03 µs (median: 9.607 µs) +├─ 10 secrets: 11.13 µs (median: 10.49 µs) +└─ 20 secrets: 12.99 µs (median: 12.36 µs) +``` + +--- + +## 3. Password Redaction + +### Actor Model (async) +``` +async_operations::redact_password_async +Mean: 225.8 µs | Median: 220.1 µs +``` + +### Direct Implementation (sync) +``` +redaction_operations::redact_password +Mean: 209.5 µs | Median: 160.4 µs +``` + +--- + +## 4. Privacy Mode Comparison + +### Actor Model +``` +async_operations::privacy_mode_async +├─ Privacy OFF: 544.7 µs (median: 530.5 µs) +└─ Privacy ON: 542.8 µs (median: 527.5 µs) +Overhead: ~0.3% (negligible) +``` + +### Direct Implementation +``` +privacy_mode::privacy_mode_comparison +├─ Privacy OFF: 19.94 ms (median: 461.5 µs) +└─ Privacy ON: 20.33 ms (median: 431.4 µs) +Overhead: ~2% (negligible) +``` + +--- + +## 5. Concurrent Operations + +### Actor Model Only +``` +concurrent_operations::concurrent_redact (5 secrets) +├─ 2 tasks: 645.3 µs (median: 616 µs) +├─ 4 tasks: 1.165 ms (median: 1.118 ms) +├─ 8 tasks: 2.251 ms (median: 2.172 ms) +└─ 16 tasks: 4.591 ms (median: 4.424 ms) + +concurrent_operations::concurrent_restore_async +├─ 2 tasks: 126.7 µs (median: 122.3 µs) +├─ 4 tasks: 336.5 µs (median: 324.4 µs) +├─ 8 tasks: 796.1 µs (median: 767.1 µs) +└─ 16 tasks: 2.523 ms (median: 2.326 ms) + +concurrent_operations::mixed_workload +├─ 4 tasks: 1.269 ms (median: 1.224 ms) +├─ 8 tasks: 2.512 ms (median: 2.429 ms) +└─ 16 tasks: 5.01 ms (median: 4.835 ms) +``` + +--- + +## 6. Sequential Throughput Tests + +### Redaction Throughput (3 secrets per operation) + +**Actor Model:** +``` +throughput::sequential_redact_throughput +├─ 10 ops: 24.44 ms (median: 4.798 ms) [~205 ops/sec median, ~41 ops/sec mean] +├─ 50 ops: 44.42 ms (median: 24.25 ms) [~2,062 ops/sec median, ~1,126 ops/sec mean] +└─ 100 ops: 67.57 ms (median: 48.98 ms) [~2,042 ops/sec median, ~1,480 ops/sec mean] +``` + +**Direct Implementation:** +``` +throughput::sequential_redact_operations +├─ 10 ops: 4.135 ms (median: 3.703 ms) [~2,700 ops/sec median, ~2,418 ops/sec mean] +├─ 50 ops: 18.79 ms (median: 18.44 ms) [~2,712 ops/sec median, ~2,661 ops/sec mean] +└─ 100 ops: 38.22 ms (median: 37.91 ms) [~2,637 ops/sec median, ~2,616 ops/sec mean] +``` + +### Restoration Throughput + +**Actor Model:** +``` +throughput::sequential_restore_async_throughput +├─ 10 ops: 319.8 µs (median: 341 µs) [~29,325 ops/sec median, ~31,271 ops/sec mean] +├─ 50 ops: 1.715 ms (median: 1.767 ms) [~28,298 ops/sec median, ~29,155 ops/sec mean] +└─ 100 ops: 3.39 ms (median: 3.505 ms) [~28,531 ops/sec median, ~29,498 ops/sec mean] +``` + +**Direct Implementation:** +``` +throughput::sequential_restore_operations +├─ 10 ops: 94.81 µs (median: 92.47 µs) [~108,144 ops/sec median, ~105,474 ops/sec mean] +├─ 50 ops: 485 µs (median: 463.1 µs) [~107,984 ops/sec median, ~103,093 ops/sec mean] +└─ 100 ops: 956 µs (median: 912.1 µs) [~109,635 ops/sec median, ~104,603 ops/sec mean] +``` + +--- + +## 7. Content Size Scaling (No Secrets) + +### Actor Model +``` +content_scaling::redact_varying_size +├─ 10 lines: 28.82 µs (median: 28.42 µs) +├─ 100 lines: 30.17 µs (median: 29.84 µs) +├─ 500 lines: 47.29 µs (median: 45.09 µs) +└─ 1000 lines: 58.61 µs (median: 55.44 µs) +``` + +### Direct Implementation +``` +redaction_operations::redact_varying_content_size +├─ 10 lines: 26 µs (median: 25.07 µs) +├─ 100 lines: 83.3 µs (median: 79.82 µs) +├─ 500 lines: 306.5 µs (median: 302.4 µs) +└─ 1000 lines: 579.4 µs (median: 578.1 µs) +``` + +--- + +## 8. Actor Initialization Overhead + +**Only applicable to Actor Model:** +``` +actor_initialization +├─ launch_default_actor: 8.171 µs (median: 4.824 µs) +├─ launch_privacy_actor: 5.237 µs (median: 4.334 µs) +├─ launch_no_redaction: 5.309 µs (median: 4.579 µs) +└─ launch_with_privacy: + ├─ false: 5.823 µs (median: 4.319 µs) + └─ true: 5.261 µs (median: 4.329 µs) + +Memory per actor: +- Max allocation: 4.2 KB +- Total allocation: 4.947 KB +- Deallocation: 655 B +- Net memory: ~4.3 KB per actor +``` diff --git a/libs/shared/benches/gitleaks_memory.rs b/libs/shared/benches/gitleaks_memory.rs new file mode 100644 index 00000000..31e27016 --- /dev/null +++ b/libs/shared/benches/gitleaks_memory.rs @@ -0,0 +1,287 @@ +//! Memory profiling benchmarks for Gitleaks secret detection +//! +//! This benchmark suite measures memory allocations during: +//! - Initial loading of gitleaks configuration and rules +//! - Secret detection operations +//! - Different privacy modes +//! - Various content sizes +//! +//! Run with: cargo bench --bench gitleaks_memory + +use divan::AllocProfiler; +use stakpak_shared::secrets::gitleaks::{create_gitleaks_config, detect_secrets}; + +#[global_allocator] +static ALLOC: AllocProfiler = AllocProfiler::system(); + +fn main() { + divan::main(); +} + +/// Helper to generate test content with various secret patterns +fn generate_content_with_secrets(num_secrets: usize) -> String { + let mut content = String::from("# Configuration file\n\n"); + + for i in 0..num_secrets { + match i % 5 { + 0 => { + // AWS-style key + content.push_str(&format!( + "export AWS_ACCESS_KEY_ID_{i}=AKIAIOSFODNN7EXAMPLE{i:04}\n" + )); + } + 1 => { + // GitHub token + content.push_str(&format!( + "export GITHUB_TOKEN_{i}=ghp_1234567890abcdef1234567890abcde{i:05}\n" + )); + } + 2 => { + // Generic API key with high entropy + content.push_str(&format!( + "export API_KEY_{i}=Kx9mP2nQ8rT4vW7yZ3cF6hJ1lN5sA0bD{i:04}\n" + )); + } + 3 => { + // Secret token + content.push_str(&format!( + "export SECRET_TOKEN_{i}=xy9mP2nQ8rT4vW7yZ3cF6hJ1lN5sAdef{i:04}\n" + )); + } + 4 => { + // Private key pattern + content.push_str(&format!( + "export PRIVATE_KEY_{i}=sk-proj-abcdefghijklmnopqrstuvwxyz12{i:04}\n" + )); + } + _ => unreachable!(), + } + } + + content.push_str("\n# Non-secret configuration\n"); + content.push_str("export DEBUG=true\n"); + content.push_str("export PORT=3000\n"); + content.push_str("export LOG_LEVEL=info\n"); + + content +} + +/// Helper to generate content without secrets (for baseline) +fn generate_content_without_secrets(lines: usize) -> String { + let mut content = String::from("# Configuration file\n\n"); + + for i in 0..lines { + content.push_str(&format!("export CONFIG_VALUE_{i}=some_value_{i}\n")); + } + + content +} + +mod config_initialization { + use super::*; + + /// Measure memory allocated when creating the default gitleaks config. + #[divan::bench] + fn load_default_config() { + let config = divan::black_box(create_gitleaks_config(false)); + drop(config); + } + + /// Measure memory allocated when creating gitleaks config with privacy rules. + #[divan::bench] + fn load_privacy_config() { + let config = divan::black_box(create_gitleaks_config(true)); + drop(config); + } + + /// Count the number of rules in the default config + #[divan::bench] + fn count_default_rules() -> usize { + let config = create_gitleaks_config(false); + config.rules.len() + } + + /// Count the number of rules in the privacy config + #[divan::bench] + fn count_privacy_rules() -> usize { + let config = create_gitleaks_config(true); + config.rules.len() + } +} + +mod secret_detection { + use super::*; + + /// Benchmark memory allocations during secret detection with varying numbers of secrets + #[divan::bench(args = [1, 5, 10, 20, 50])] + fn detect_with_secrets(bencher: divan::Bencher, num_secrets: usize) { + let content = generate_content_with_secrets(num_secrets); + let config = create_gitleaks_config(false); + + bencher + .with_inputs(|| content.clone()) + .bench_values(|content| divan::black_box(detect_secrets(&content, None, &config))); + } + + /// Benchmark memory allocations during secret detection with varying content sizes (no secrets) + #[divan::bench(args = [10, 100, 500, 1000])] + fn detect_varying_content_size(bencher: divan::Bencher, lines: usize) { + let content = generate_content_without_secrets(lines); + let config = create_gitleaks_config(false); + + bencher + .with_inputs(|| content.clone()) + .bench_values(|content| divan::black_box(detect_secrets(&content, None, &config))); + } + + /// Benchmark memory with privacy mode enabled + #[divan::bench(args = [1, 5, 10])] + fn detect_with_privacy_mode(bencher: divan::Bencher, num_secrets: usize) { + let content = generate_content_with_secrets(num_secrets); + let config = create_gitleaks_config(true); + + bencher + .with_inputs(|| content.clone()) + .bench_values(|content| divan::black_box(detect_secrets(&content, None, &config))); + } + + /// Compare privacy mode on vs off + #[divan::bench(consts = [false, true])] + fn privacy_mode_comparison(bencher: divan::Bencher) { + let content = generate_content_with_secrets(5); + let config = create_gitleaks_config(PRIVACY_MODE); + + bencher + .with_inputs(|| content.clone()) + .bench_values(|content| divan::black_box(detect_secrets(&content, None, &config))); + } +} + +mod real_world_scenarios { + use super::*; + + /// Simulate scanning a typical configuration file + #[divan::bench] + fn scan_typical_config_file(bencher: divan::Bencher) { + let content = r#" +# Database Configuration +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=myapp +DB_USER=admin +DB_PASSWORD=Kx9mP2nQ8rT4vW7yZ3cF6hJ1lN5sA0bD + +# AWS Configuration +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + +# GitHub Token +GITHUB_TOKEN=ghp_1234567890abcdef1234567890abcdef12 + +# Application Settings +APP_NAME=MyApp +APP_ENV=production +LOG_LEVEL=info +"#; + let config = create_gitleaks_config(false); + + bencher.bench(|| divan::black_box(detect_secrets(content, Some("config.env"), &config))); + } + + /// Simulate scanning a large log file + #[divan::bench] + fn scan_large_log_file(bencher: divan::Bencher) { + let mut content = String::new(); + + // Simulate 1000 lines of logs with occasional secrets + for i in 0..1000 { + if i % 100 == 0 { + // Every 100th line has a secret + content.push_str(&format!( + "[{}] INFO: User authenticated with token: ghp_1234567890abcdef1234567890abcde{i:05}\n", + i + )); + } else { + content.push_str(&format!( + "[{}] INFO: Processing request from user_{}\n", + i, i + )); + } + } + let config = create_gitleaks_config(false); + + bencher + .with_inputs(|| content.clone()) + .bench_values(|content| { + divan::black_box(detect_secrets(&content, Some("app.log"), &config)) + }); + } + + /// Simulate scanning source code with embedded secrets + #[divan::bench] + fn scan_source_code(bencher: divan::Bencher) { + let content = r#" +package main + +import "fmt" + +const ( + // Don't commit this! + apiKey = "Kx9mP2nQ8rT4vW7yZ3cF6hJ1lN5sA0bD" + + // Production database + dbUrl = "postgresql://admin:SuperSecret123@db.example.com/prod" +) + +func main() { + // AWS credentials + awsKey := "AKIAIOSFODNN7EXAMPLE" + awsSecret := "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" + + fmt.Println("Starting application...") +} +"#; + let config = create_gitleaks_config(false); + + bencher.bench(|| divan::black_box(detect_secrets(content, Some("main.go"), &config))); + } +} + +mod batch_processing { + use super::*; + + /// Simulate scanning multiple files in sequence + #[divan::bench(args = [5, 10, 20])] + fn scan_multiple_files(bencher: divan::Bencher, num_files: usize) { + let files: Vec = (0..num_files) + .map(|i| generate_content_with_secrets(i + 1)) + .collect(); + let config = create_gitleaks_config(false); + + bencher.with_inputs(|| files.clone()).bench_values(|files| { + let mut total_secrets = 0; + for (i, content) in files.iter().enumerate() { + let secrets = detect_secrets(content, Some(&format!("file_{}.txt", i)), &config); + total_secrets += secrets.len(); + } + divan::black_box(total_secrets) + }); + } + + /// Simulate repeated scans of the same content (cache behavior) + #[divan::bench(args = [10, 50, 100])] + fn repeated_scans(bencher: divan::Bencher, num_scans: usize) { + let content = generate_content_with_secrets(5); + let config = create_gitleaks_config(false); + + bencher.bench(|| { + let mut total_secrets = 0; + for _ in 0..num_scans { + let secrets = detect_secrets(&content, None, &config); + total_secrets += secrets.len(); + } + divan::black_box(total_secrets) + }); + } +} diff --git a/libs/shared/src/cert_utils.rs b/libs/shared/src/cert_utils.rs index 71741894..f04a5f32 100644 --- a/libs/shared/src/cert_utils.rs +++ b/libs/shared/src/cert_utils.rs @@ -1,9 +1,13 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use rcgen::{ BasicConstraints, CertificateParams, DistinguishedName, DnType, IsCa, KeyUsagePurpose, SanType, }; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use rustls::{ClientConfig, RootCertStore, ServerConfig}; +use rustls_pemfile::{certs, pkcs8_private_keys}; +use std::fs::{self, File}; +use std::io::BufReader; +use std::path::{Path, PathBuf}; use std::sync::Arc; use time::OffsetDateTime; @@ -16,13 +20,82 @@ pub struct CertificateChain { impl std::fmt::Debug for CertificateChain { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("CertificateChain") - .field("ca_cert", &"") - .field("server_cert", &"") - .field("client_cert", &"") + .field("ca_cert", &"") + .field("server_cert", &"") + .field("client_cert", &"") .finish() } } +pub fn default_cert_dir() -> Result { + // Check for explicit CERTS_DIR override first + if let Ok(certs_dir) = std::env::var("CERTS_DIR") { + return Ok(PathBuf::from(certs_dir)); + } + + // Fall back to ~/.stakpak/certs + let home = std::env::var("HOME") + .or_else(|_| std::env::var("USERPROFILE")) + .context("Could not determine home directory")?; + Ok(PathBuf::from(home).join(".stakpak").join("certs")) +} + +/// Certificate management strategy for different use cases +#[derive(Debug, Clone)] +pub enum CertificateStrategy { + /// Generate in memory certificates + Ephemeral, + /// Load persistent certificates from disk + Persistent(PathBuf), +} + +impl CertificateStrategy { + pub fn load_server_config(&self) -> Result { + match self { + Self::Ephemeral => { + let chain = CertificateChain::generate()?; + chain.create_server_config() + } + Self::Persistent(path) => CertificateChain::load_server_config(path), + } + } + + pub fn load_client_config(&self) -> Result { + match self { + Self::Ephemeral => { + let chain = CertificateChain::generate()?; + chain.create_client_config() + } + Self::Persistent(path) => CertificateChain::load_client_config(path), + } + } + + /// Get the certificate chain (only works for Ephemeral strategy) + /// For Persistent strategy, returns an error as certificates are already on disk + pub fn get_certificate_chain(&self) -> Result { + match self { + Self::Ephemeral => CertificateChain::generate(), + Self::Persistent(path) => Err(anyhow::anyhow!( + "Cannot get certificate chain for Persistent strategy. Certificates are stored at: {}", + path.display() + )), + } + } + + /// Check if certificates exist + pub fn exists(&self) -> bool { + match self { + Self::Ephemeral => true, // Ephemeral certs are always available since they're generated on demand + Self::Persistent(path) => CertificateChain::exists_in_directory(path), + } + } + + /// Get default certifications directory + pub fn default_persistent() -> Result { + Ok(Self::Persistent(default_cert_dir()?)) + } +} + impl CertificateChain { pub fn generate() -> Result { // Generate CA certificate @@ -107,6 +180,62 @@ impl CertificateChain { }) } + /// Load server configuration from PEM files on disk + /// Use this for the 'start' command + pub fn load_server_config(dir: &Path) -> Result { + // Check if all files exist + if !Self::exists_in_directory(dir) { + return Err(anyhow::anyhow!( + "Certificates not found at {:?}\nRun 'stakpak mcp setup' to generate them", + dir + )); + } + + let ca_file = File::open(dir.join("ca.pem")).context("Failed to open CA certificate")?; + let ca_certs: Vec = certs(&mut BufReader::new(ca_file)) + .collect::, _>>() + .context("Failed to parse CA certificate")?; + + let server_cert_file = + File::open(dir.join("server-cert.pem")).context("Failed to open server certificate")?; + let server_cert_chain: Vec = certs(&mut BufReader::new(server_cert_file)) + .collect::, _>>() + .context("Failed to parse server certificate")?; + + let server_key_file = + File::open(dir.join("server-key.pem")).context("Failed to open server private key")?; + let mut server_keys = pkcs8_private_keys(&mut BufReader::new(server_key_file)) + .collect::, _>>() + .context("Failed to parse server private key")?; + + if server_keys.is_empty() { + return Err(anyhow::anyhow!("No private key found in server-key.pem")); + } + + let server_private_key = PrivateKeyDer::Pkcs8(server_keys.remove(0)); + + let mut root_cert_store = RootCertStore { + roots: Vec::with_capacity(ca_certs.len()), + }; + for cert in ca_certs { + root_cert_store.add(cert)?; + } + + let client_cert_verifier = + rustls::server::WebPkiClientVerifier::builder(Arc::new(root_cert_store)) + .build() + .map_err(|e| anyhow::anyhow!("Failed to build client cert verifier: {}", e))?; + + let config = ServerConfig::builder() + .with_client_cert_verifier(client_cert_verifier) + .with_single_cert(server_cert_chain, server_private_key)?; + + Ok(config) + } + + /// Create server configuration from in memory certificates + /// Used by `CertificateStrategy::Ephemeral` for testing and development. + /// For production use, prefer `load_server_config()` with `CertificateStrategy::Persistent` for better security and certificate persistence. pub fn create_server_config(&self) -> Result { // Sign server certificate with CA let server_cert_der = self.server_cert.serialize_der_with_signer(&self.ca_cert)?; @@ -134,6 +263,56 @@ impl CertificateChain { Ok(config) } + /// Load client configuration from PEM files on disk + /// Use this for clients to load existing certificates + pub fn load_client_config(dir: &Path) -> Result { + if !Self::exists_in_directory(dir) { + return Err(anyhow::anyhow!( + "Certificates not found at {:?}\nRun 'stakpak mcp setup' to generate them", + dir + )); + } + + let ca_file = File::open(dir.join("ca.pem")).context("Failed to open CA certificate")?; + let ca_certs: Vec = certs(&mut BufReader::new(ca_file)) + .collect::, _>>() + .context("Failed to parse CA certificate")?; + + let client_cert_file = + File::open(dir.join("client-cert.pem")).context("Failed to open client certificate")?; + let client_certs: Vec = certs(&mut BufReader::new(client_cert_file)) + .collect::, _>>() + .context("Failed to parse client certificate")?; + + let client_key_file = + File::open(dir.join("client-key.pem")).context("Failed to open client private key")?; + let mut client_keys = pkcs8_private_keys(&mut BufReader::new(client_key_file)) + .collect::, _>>() + .context("Failed to parse client private key")?; + + if client_keys.is_empty() { + return Err(anyhow::anyhow!("No private key found in client-key.pem")); + } + + let client_key = PrivateKeyDer::Pkcs8(client_keys.remove(0)); + + let mut root_cert_store = RootCertStore { + roots: Vec::with_capacity(ca_certs.len()), + }; + for cert in ca_certs { + root_cert_store.add(cert)?; + } + + let config = ClientConfig::builder() + .with_root_certificates(root_cert_store) + .with_client_auth_cert(client_certs, client_key)?; + + Ok(config) + } + + /// Create client configuration from in memory certificates + /// Used by `CertificateStrategy::Ephemeral` for testing and development. + /// For production use, prefer `load_client_config()` with `CertificateStrategy::Persistent` for certificate persistance pub fn create_client_config(&self) -> Result { // Sign client certificate with CA let client_cert_der = self.client_cert.serialize_der_with_signer(&self.ca_cert)?; @@ -174,6 +353,81 @@ impl CertificateChain { pub fn get_client_key_pem(&self) -> Result { Ok(self.client_cert.serialize_private_key_pem()) } + + /// Save certificates to a directory + pub fn save_to_directory(&self, dir: &Path) -> Result<()> { + fs::create_dir_all(dir) + .context(format!("Failed to create certificate directory: {:?}", dir))?; + + let ca_pem = self.get_ca_cert_pem()?; + fs::write(dir.join("ca.pem"), ca_pem).context("Failed to write CA certificate")?; + + let server_cert_pem = self.get_server_cert_pem()?; + fs::write(dir.join("server-cert.pem"), server_cert_pem) + .context("Failed to write server certificate")?; + + let server_key_pem = self.get_server_key_pem()?; + fs::write(dir.join("server-key.pem"), server_key_pem) + .context("Failed to write server key")?; + + let client_cert_pem = self.get_client_cert_pem()?; + fs::write(dir.join("client-cert.pem"), client_cert_pem) + .context("Failed to write client certificate")?; + + let client_key_pem = self.get_client_key_pem()?; + fs::write(dir.join("client-key.pem"), client_key_pem) + .context("Failed to write client key")?; + + // Set restrictive permissions on private keys (Unix only) + // Note: On Windows, file permissions are managed differently + // Windows users should ensure the certificate directory has appropriate access controls + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let key_perms = fs::Permissions::from_mode(0o600); + let _ = fs::set_permissions(dir.join("server-key.pem"), key_perms.clone()); + let _ = fs::set_permissions(dir.join("client-key.pem"), key_perms); + } + + Ok(()) + } + + /// Check if all required certificates exist in a directory + pub fn exists_in_directory(dir: &Path) -> bool { + dir.join("ca.pem").exists() + && dir.join("server-cert.pem").exists() + && dir.join("server-key.pem").exists() + && dir.join("client-cert.pem").exists() + && dir.join("client-key.pem").exists() + } + + /// Generate new certificates and save to a directory + /// Use this for the 'setup' command + pub fn generate_and_save(dir: Option<&Path>, force: bool) -> Result { + let cert_dir = match dir { + Some(d) => d.to_path_buf(), + None => default_cert_dir()?, + }; + + // Check if certificates already exist + if Self::exists_in_directory(&cert_dir) && !force { + return Err(anyhow::anyhow!( + "Certificates already exist at {:?}\nUse --force to overwrite, or delete the directory manually", + cert_dir + )); + } + + tracing::info!("Generating new certificate chain"); + let chain = Self::generate()?; + + chain + .save_to_directory(&cert_dir) + .context("Failed to save certificates")?; + + tracing::info!("Certificates saved to {:?}", cert_dir); + + Ok(chain) + } } #[cfg(test)] diff --git a/libs/shared/src/models/mod.rs b/libs/shared/src/models/mod.rs index 85683fff..a852b177 100644 --- a/libs/shared/src/models/mod.rs +++ b/libs/shared/src/models/mod.rs @@ -3,5 +3,6 @@ pub mod indexing; pub mod integrations; pub mod llm; pub mod model_pricing; +pub mod password; pub mod stakai_adapter; pub mod subagent; diff --git a/libs/shared/src/models/password.rs b/libs/shared/src/models/password.rs new file mode 100644 index 00000000..eb540898 --- /dev/null +++ b/libs/shared/src/models/password.rs @@ -0,0 +1,116 @@ +use std::string::FromUtf8Error; + +use schemars::JsonSchema; +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Deserializer}; + +/// This type wraps `SecretString` from the secrecy crate to provide automatic protection +/// against accidental password leakage in logs + +#[derive(Debug, Clone, JsonSchema)] +pub struct Password(#[schemars(with = "String", length(min = 8))] SecretString); + +// Custom deserializer to ensure validation happens during deserialization +impl<'de> Deserialize<'de> for Password { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Password::new(s).map_err(serde::de::Error::custom) + } +} + +// if `Password` needs to be serialized without redaction +// +// #[derive(Serialize, Clone)] +// pub struct Password(#[serde(serialize_with = "serialize_exposed_password")] SecretString); +// +// pub fn serialize_exposed_password( +// secret: &SecretString, +// serializer: S, +// ) -> Result +// where +// S: Serializer, +// { +// secret.expose_secret().serialize(serializer) +// } + +#[derive(thiserror::Error, Debug, PartialEq)] +pub enum PasswordGenerationError { + #[error("Failed to generate a unique password after multiple retries")] + Conflict, + #[error("Password must be at least 8 characters long")] + TooShort, + #[error("Password is not UTF8 String")] + NonUTF8(#[from] FromUtf8Error), +} + +impl Password { + pub fn new(password: impl Into) -> Result { + let password: String = password.into(); + + if password.len() < 8 { + tracing::error!( + "Password validation failed: must be at least 8 characters long, received {} characters", + password.len() + ); + return Err(PasswordGenerationError::TooShort); + } + + Ok(Self(SecretString::from(password))) + } + + pub fn expose_secret(&self) -> &str { + self.0.expose_secret() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_password_rejects_less_than_8_chars() { + let raw = "1234567"; + let password = Password::new(raw); + + assert!(password.is_err()); + } + + #[test] + fn test_initialization_and_exposure() { + let raw = "super_secret_123"; + let password = Password::new(raw); + + // Verify we can retrieve the secret explicitly + assert_eq!(password.unwrap().expose_secret(), raw); + } + + #[test] + fn test_json_deserialization_direct() { + let json = "\"test_pass\""; + let password: Password = serde_json::from_str(json).unwrap(); + + assert_eq!(password.expose_secret(), "test_pass"); + } + + #[test] + fn test_json_deserialization_rejects_short_password() { + let json = r#""short"""#; + let result: Result = serde_json::from_str(json); + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert_eq!(err_msg, PasswordGenerationError::TooShort.to_string()); + } + + #[test] + fn test_json_schema_generation() { + let schema = schemars::schema_for!(Password); + let schema_json = serde_json::to_value(&schema).unwrap(); + + assert_eq!(schema_json["type"], "string"); + assert_eq!(schema_json["minLength"], 8); + } +} diff --git a/libs/shared/src/remote_connection.rs b/libs/shared/src/remote_connection.rs index 5bc35c1d..8588d577 100644 --- a/libs/shared/src/remote_connection.rs +++ b/libs/shared/src/remote_connection.rs @@ -1,9 +1,10 @@ +use crate::models::password::Password; use crate::utils::{DirectoryEntry, FileSystemProvider}; use anyhow::{Result, anyhow}; use async_trait::async_trait; use russh::client::{self, Handler}; use russh_sftp::client::SftpSession; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use std::{ collections::HashMap, fmt::{self, Display}, @@ -30,10 +31,10 @@ pub struct CommandOptions { pub simple: bool, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Deserialize)] pub struct RemoteConnectionInfo { pub connection_string: String, // format: user@host:port - pub password: Option, + pub password: Option, pub private_key_path: Option, } @@ -123,7 +124,7 @@ impl RemoteConnection { if let Some(password) = &connection_info.password { debug!("Authenticating with password"); let auth_result = session - .authenticate_password(username, password) + .authenticate_password(username, password.expose_secret()) .await .map_err(|e| Self::map_ssh_error(e, "password authentication"))?; Self::map_auth_error(auth_result, "Password")?; diff --git a/libs/shared/src/secret_manager.rs b/libs/shared/src/secret_manager.rs index 6dc2addb..6070162d 100644 --- a/libs/shared/src/secret_manager.rs +++ b/libs/shared/src/secret_manager.rs @@ -1,26 +1,178 @@ use crate::local_store::LocalStore; +use crate::models::password::{Password, PasswordGenerationError}; use crate::secrets::{redact_password, redact_secrets, restore_secrets}; use serde_json; use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc::error::{SendError, TrySendError}; +use tokio::sync::{broadcast, mpsc, oneshot}; use tracing::{error, warn}; -/// Handles secret redaction and restoration across different tool types -#[derive(Clone)] -pub struct SecretManager { +const DEFAULT_CHANNEL_CAPACITY: usize = 100; + +const DEFAULT_OPERATION_TIMEOUT_SECS: u64 = 30; + +#[derive(Debug, thiserror::Error)] +pub enum SecretManagerError { + #[error("Secret manager actor channel closed")] + ChannelClosed, + + #[error("Secret manager actor channel full")] + ChannelFull, + + #[error("Secret manager actor dropped response channel")] + ActorDropped, + + #[error("Secret manager operation timed out after {0} seconds")] + Timeout(u64), + + #[error("I/O error: {0}")] + IoError(String), + + #[error("Password generation failed: {0}")] + PasswordGeneration(#[from] PasswordGenerationError), +} + +impl From> for SecretManagerError { + fn from(_: SendError) -> Self { + Self::ChannelClosed + } +} + +impl From> for SecretManagerError { + fn from(err: TrySendError) -> Self { + match err { + TrySendError::Full(_) => Self::ChannelFull, + TrySendError::Closed(_) => Self::ChannelClosed, + } + } +} + +enum SecretMessage { + RedactAndStore { + content: String, + path: Option, + resp: oneshot::Sender, + }, + RedactPassword { + password: String, + resp: oneshot::Sender, + }, + RestoreSecrets { + input: String, + resp: oneshot::Sender, + }, + GeneratePassword { + length: usize, + include_symbols: bool, + resp: oneshot::Sender>, + }, +} + +struct SecretManager { + redaction_map: HashMap, redact_secrets: bool, privacy_mode: bool, + rx: mpsc::Receiver, + shutdown_rx: broadcast::Receiver<()>, + /// Lazy-loaded gitleaks configuration. + /// Only loaded on first secret scan to save memory if scanning is never used. + gitleaks_config: Option, } impl SecretManager { - pub fn new(redact_secrets: bool, privacy_mode: bool) -> Self { + fn new( + redact_secrets: bool, + privacy_mode: bool, + rx: mpsc::Receiver, + shutdown_rx: broadcast::Receiver<()>, + ) -> Self { + let redaction_map = Self::load_session_redaction_map_sync(); + Self { + redaction_map, redact_secrets, privacy_mode, + rx, + shutdown_rx, + gitleaks_config: None, // Lazy-loaded on first scan + } + } + + pub async fn run(mut self) { + loop { + tokio::select! { + msg = self.rx.recv() => { + match msg { + Some(m) => self.handle_message(m).await, + None => { + // Main channel closed, all senders dropped - exit + warn!("SecretManager actor shutting down: channel closed"); + break; + } + } + } + result = self.shutdown_rx.recv() => { + match result { + Ok(_) => { + warn!("SecretManager actor shutting down: shutdown signal"); + break; + } + Err(_) => { + } + } + } + } + } + } + + async fn handle_message(&mut self, msg: SecretMessage) { + match msg { + SecretMessage::RedactAndStore { + content, + path, + resp, + } => { + let result = self + .redact_and_store_secrets_impl(&content, path.as_deref()) + .await; + let _ = resp.send(result); + } + SecretMessage::RedactPassword { password, resp } => { + let result = match Password::new(password) { + Ok(p) => self.redact_and_store_password_impl(p).await, + Err(_) => String::new(), // If password too short, return empty? Or error? + }; + let _ = resp.send(result); + } + SecretMessage::RestoreSecrets { input, resp } => { + let result = restore_secrets(&input, &self.redaction_map); + let _ = resp.send(result); + } + SecretMessage::GeneratePassword { + length, + include_symbols, + resp, + } => { + // Generate password using local map (actor state) + let result = match crate::utils::generate_password( + length, + include_symbols, + &self.redaction_map, + ) { + Ok(password) => { + // If successful, redact and store it immediately + Ok(self.redact_and_store_password_impl(password).await) + } + Err(e) => Err(e), + }; + let _ = resp.send(result); + } } } - /// Load the redaction map from the session file - pub fn load_session_redaction_map(&self) -> HashMap { + fn load_session_redaction_map_sync() -> HashMap { match LocalStore::read_session_data("secrets.json") { Ok(content) => { if content.trim().is_empty() { @@ -42,69 +194,276 @@ impl SecretManager { } } - /// Save the redaction map to the session file - pub fn save_session_redaction_map(&self, redaction_map: &HashMap) { - match serde_json::to_string_pretty(redaction_map) { - Ok(json_content) => { - if let Err(e) = LocalStore::write_session_data("secrets.json", &json_content) { - error!("Failed to save session redaction map: {}", e); - } - } + async fn save_session_redaction_map(&self) { + let json_content = match serde_json::to_string_pretty(&self.redaction_map) { + Ok(content) => content, Err(e) => { error!("Failed to serialize session redaction map to JSON: {}", e); + return; + } + }; + + // Use spawn_blocking to avoid blocking the async runtime during file I/O + let result = tokio::task::spawn_blocking(move || { + LocalStore::write_session_data("secrets.json", &json_content) + }) + .await; + + match result { + Ok(Ok(_)) => {} + Ok(Err(e)) => { + error!("Failed to save session redaction map: {}", e); + } + Err(e) => { + error!("spawn_blocking panicked while saving redaction map: {}", e); } } } - /// Add new redactions to the session map - pub fn add_to_session_redaction_map(&self, new_redactions: &HashMap) { - if new_redactions.is_empty() { - return; + async fn redact_and_store_secrets_impl(&mut self, content: &str, path: Option<&str>) -> String { + if !self.redact_secrets { + return content.to_string(); } - let mut existing_map = self.load_session_redaction_map(); - existing_map.extend(new_redactions.clone()); - self.save_session_redaction_map(&existing_map); - } + // Lazy-load gitleaks config on first use (saves memory if never needed) + if self.gitleaks_config.is_none() { + self.gitleaks_config = Some(crate::secrets::gitleaks::create_gitleaks_config( + self.privacy_mode, + )); + } - /// Restore secrets in a string using the session redaction map - pub fn restore_secrets_in_string(&self, input: &str) -> String { - let redaction_map = self.load_session_redaction_map(); - if redaction_map.is_empty() { - return input.to_string(); + let config = self.gitleaks_config.as_ref().unwrap(); + let redaction_result = redact_secrets(content, path, &self.redaction_map, config); + + let old_len = self.redaction_map.len(); + self.redaction_map.extend(redaction_result.redaction_map); + if self.redaction_map.len() > old_len { + self.save_session_redaction_map().await; } - restore_secrets(input, &redaction_map) + + redaction_result.redacted_string } - /// Redact secrets and add to session map - pub fn redact_and_store_secrets(&self, content: &str, path: Option<&str>) -> String { + async fn redact_and_store_password_impl(&mut self, password: Password) -> String { if !self.redact_secrets { - return content.to_string(); + return password.expose_secret().to_string(); } - // TODO: this is not thread safe, we need to use a mutex or an actor to protect the redaction map - let existing_redaction_map = self.load_session_redaction_map(); - let redaction_result = - redact_secrets(content, path, &existing_redaction_map, self.privacy_mode); + let redaction_result = redact_password(&password, &self.redaction_map); - // Add new redactions to session map - self.add_to_session_redaction_map(&redaction_result.redaction_map); + let old_len = self.redaction_map.len(); + self.redaction_map.extend(redaction_result.redaction_map); + if self.redaction_map.len() > old_len { + self.save_session_redaction_map().await; + } redaction_result.redacted_string } +} - pub fn redact_and_store_password(&self, content: &str, password: &str) -> String { +#[derive(Clone, Debug)] +pub struct SecretManagerHandle { + tx: mpsc::Sender, + shutdown_tx: broadcast::Sender<()>, + redact_secrets: bool, +} + +impl SecretManagerHandle { + pub fn shutdown(&self) -> Result<(), broadcast::error::SendError<()>> { + self.shutdown_tx.send(()).map(|_| ()) + } + + async fn await_response( + resp_rx: oneshot::Receiver, + ) -> Result { + let timeout_duration = Duration::from_secs(DEFAULT_OPERATION_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, resp_rx).await { + Ok(Ok(result)) => Ok(result), + Ok(Err(_)) => Err(SecretManagerError::ActorDropped), + Err(_) => Err(SecretManagerError::Timeout(DEFAULT_OPERATION_TIMEOUT_SECS)), + } + } + + pub async fn redact_and_store_secrets( + &self, + content: &str, + path: Option<&str>, + ) -> Result { + // Fast-path optimization: skip message passing if redaction is disabled if !self.redact_secrets { - return content.to_string(); + return Ok(content.to_string()); } - // TODO: this is not thread safe, we need to use a mutex or an actor to protect the redaction map - let existing_redaction_map = self.load_session_redaction_map(); - let redaction_result = redact_password(content, password, &existing_redaction_map); + let (resp_tx, resp_rx) = oneshot::channel(); + let msg = SecretMessage::RedactAndStore { + content: content.to_string(), + path: path.map(|s| s.to_string()), + resp: resp_tx, + }; - // Add new redactions to session map - self.add_to_session_redaction_map(&redaction_result.redaction_map); + self.tx.send(msg).await?; - redaction_result.redacted_string + Self::await_response(resp_rx).await + } + + /// returns immediately if the channel is full + pub async fn try_redact_and_store_secrets( + &self, + content: &str, + path: Option<&str>, + ) -> Result { + // Fast-path optimization: skip message passing if redaction is disabled + if !self.redact_secrets { + return Ok(content.to_string()); + } + + let (resp_tx, resp_rx) = oneshot::channel(); + let msg = SecretMessage::RedactAndStore { + content: content.to_string(), + path: path.map(|s| s.to_string()), + resp: resp_tx, + }; + + self.tx.try_send(msg)?; + + Self::await_response(resp_rx).await + } + + pub async fn redact_and_store_password( + &self, + password: &str, + ) -> Result { + // Fast-path optimization: skip message passing if redaction is disabled + if !self.redact_secrets { + return Ok(password.to_string()); + } + + let (resp_tx, resp_rx) = oneshot::channel(); + let msg = SecretMessage::RedactPassword { + password: password.to_string(), + resp: resp_tx, + }; + + self.tx.send(msg).await?; + + Self::await_response(resp_rx).await + } + + pub async fn restore_secrets_in_string( + &self, + input: &str, + ) -> Result { + let (resp_tx, resp_rx) = oneshot::channel(); + let msg = SecretMessage::RestoreSecrets { + input: input.to_string(), + resp: resp_tx, + }; + + self.tx.send(msg).await?; + + Self::await_response(resp_rx).await + } + + pub async fn generate_password( + &self, + length: usize, + include_symbols: bool, + ) -> Result { + let (resp_tx, resp_rx) = oneshot::channel(); + let msg = SecretMessage::GeneratePassword { + length, + include_symbols, + resp: resp_tx, + }; + + self.tx.send(msg).await?; + + // Logic for handling Result> across channel + let timeout_duration = Duration::from_secs(DEFAULT_OPERATION_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, resp_rx).await { + Ok(Ok(Ok(result))) => Ok(result), + Ok(Ok(Err(e))) => Err(SecretManagerError::PasswordGeneration(e)), + Ok(Err(_)) => Err(SecretManagerError::ActorDropped), + Err(_) => Err(SecretManagerError::Timeout(DEFAULT_OPERATION_TIMEOUT_SECS)), + } + } +} + +pub fn launch_secret_manager( + redact_secrets: bool, + privacy_mode: bool, + channel_capacity: Option, +) -> Arc { + let capacity = channel_capacity.unwrap_or(DEFAULT_CHANNEL_CAPACITY); + let (tx, rx) = mpsc::channel(capacity); + let (shutdown_tx, shutdown_rx) = broadcast::channel(1); + + let manager = SecretManager::new(redact_secrets, privacy_mode, rx, shutdown_rx); + + tokio::spawn(async move { + manager.run().await; + }); + + Arc::new(SecretManagerHandle { + tx, + shutdown_tx, + redact_secrets, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_concurrent_secret_operations() { + // Launch secret manager with redaction disabled for simpler testing + let handle = launch_secret_manager(false, false, None); + + let mut handles = Vec::new(); + for i in 0..50 { + let h = Arc::clone(&handle); + let task = tokio::spawn(async move { + let content = format!("API_KEY=secret_value_{}", i); + let result = h.redact_and_store_secrets(&content, None).await; + assert!(result.is_ok()); + + let restore_result = h.restore_secrets_in_string(&content).await; + assert!(restore_result.is_ok()); + }); + handles.push(task); + } + + for handle in handles { + handle.await.expect("Task panicked"); + } + } + + #[tokio::test] + async fn test_secret_manager_basic_operations() { + let handle = launch_secret_manager(true, false, None); + + let content = "export API_KEY=test_secret_12345"; + let result = handle.redact_and_store_secrets(content, None).await; + assert!(result.is_ok()); + + let restore_result = handle.restore_secrets_in_string(content).await; + assert!(restore_result.is_ok()); + } + + #[tokio::test] + async fn test_secret_manager_graceful_shutdown() { + // Use `redact_secrets=true` to test actual channel closure, not the fast-path optimization + let handle = launch_secret_manager(true, false, None); + + let result = handle.redact_and_store_secrets("test content", None).await; + assert!(result.is_ok()); + + let _ = handle.shutdown(); + + tokio::time::sleep(Duration::from_millis(100)).await; + + let result = handle.redact_and_store_secrets("more content", None).await; + assert!(matches!(result, Err(SecretManagerError::ChannelClosed))); } } diff --git a/libs/shared/src/secrets/gitleaks.rs b/libs/shared/src/secrets/gitleaks.rs index 7694efe3..122a573e 100644 --- a/libs/shared/src/secrets/gitleaks.rs +++ b/libs/shared/src/secrets/gitleaks.rs @@ -1,7 +1,6 @@ // Secret redaction implementation based on gitleaks (https://github.com/gitleaks/gitleaks) use regex::Regex; use serde::{Deserialize, Serialize}; -use std::sync::LazyLock; #[derive(Debug, Deserialize, Clone)] pub struct GitleaksConfig { @@ -222,16 +221,10 @@ impl RegexCompilable for GitleaksConfig { } } -/// Lazy-loaded gitleaks configuration -pub static GITLEAKS_CONFIG: LazyLock = - LazyLock::new(|| create_gitleaks_config(false)); - -/// Lazy-loaded gitleaks configuration with privacy rules -pub static GITLEAKS_CONFIG_WITH_PRIVACY: LazyLock = - LazyLock::new(|| create_gitleaks_config(true)); - /// Creates a gitleaks configuration with optional privacy rules -fn create_gitleaks_config(include_privacy_rules: bool) -> GitleaksConfig { +/// +/// Privacy rules detect and redact private data like IP addresses and AWS account IDs. +pub fn create_gitleaks_config(include_privacy_rules: bool) -> GitleaksConfig { // Load main gitleaks configuration let config_str = include_str!("gitleaks.toml"); let mut config: GitleaksConfig = @@ -355,14 +348,14 @@ pub fn calculate_entropy(text: &str) -> f64 { /// 3. Apply allowlists to exclude known false positives /// 4. Check keywords to ensure relevance /// -/// When privacy_mode is enabled, also detects private data like IP addresses and AWS account IDs -pub fn detect_secrets(input: &str, path: Option<&str>, privacy_mode: bool) -> Vec { +/// `config` The gitleaks configuration with compiled rules to use. +/// The config can include privacy rules for detecting private data like IP addresses and AWS account IDs. +pub fn detect_secrets( + input: &str, + path: Option<&str>, + config: &GitleaksConfig, +) -> Vec { let mut detected_secrets = Vec::new(); - let config = if privacy_mode { - &*GITLEAKS_CONFIG_WITH_PRIVACY - } else { - &*GITLEAKS_CONFIG - }; // Apply each compiled rule from the configuration for rule in &config.rules { @@ -592,27 +585,10 @@ pub fn contains_any_keyword(input: &str, keywords: &[String]) -> bool { .any(|keyword| input_lower.contains(&keyword.to_lowercase())) } -/// Forces initialization of the gitleaks configuration -/// -/// This function should be called during application startup to preload and compile -/// the gitleaks rules, avoiding delays on the first call to detect_secrets. -/// -/// When privacy_mode is enabled, also loads privacy rules for detecting IP addresses and AWS account IDs -/// -/// Returns the number of successfully compiled rules. -pub fn initialize_gitleaks_config(privacy_mode: bool) -> usize { - // Force evaluation of the lazy static - let config = if privacy_mode { - &*GITLEAKS_CONFIG_WITH_PRIVACY - } else { - &*GITLEAKS_CONFIG - }; - config.rules.len() -} - #[cfg(test)] mod tests { use super::*; + use crate::secrets::test_utils::{TEST_GITLEAKS_CONFIG, TEST_GITLEAKS_CONFIG_WITH_PRIVACY}; #[test] fn test_entropy_calculation() { @@ -635,7 +611,7 @@ mod tests { #[test] fn test_additional_rules_loaded() { - let config = &*GITLEAKS_CONFIG; + let config = &*TEST_GITLEAKS_CONFIG; // Check that the Anthropic API key rule from additional_rules.toml is loaded let anthropic_rule = config.rules.iter().find(|r| r.id == "anthropic-api-key"); @@ -657,10 +633,11 @@ mod tests { #[test] fn test_anthropic_api_key_detection() { + let config = &*TEST_GITLEAKS_CONFIG; // Use a more realistic API key that doesn't contain alphabet sequences let test_input = "ANTHROPIC_API_KEY=sk-ant-api03-Kx9mP2nQ8rT4vW7yZ3cF6hJ1lN5sA9bD2eG5kM8pR1tX4zB7"; - let secrets = detect_secrets(test_input, None, false); + let secrets = detect_secrets(test_input, None, &config); // Should detect the Anthropic API key let anthropic_secret = secrets.iter().find(|s| s.rule_id == "anthropic-api-key"); @@ -676,14 +653,16 @@ mod tests { #[test] fn test_privacy_mode_aws_account_id() { + let config = &*TEST_GITLEAKS_CONFIG; + let config_privacy = &*TEST_GITLEAKS_CONFIG_WITH_PRIVACY; let test_input = "AWS_ACCOUNT_ID=987654321098"; // Should not detect AWS account ID in regular mode - let secrets = detect_secrets(test_input, None, false); + let secrets = detect_secrets(test_input, None, &config); assert!(!secrets.iter().any(|s| s.rule_id == "aws-account-id")); // Should detect AWS account ID in privacy mode - let secrets_privacy = detect_secrets(test_input, None, true); + let secrets_privacy = detect_secrets(test_input, None, &config_privacy); let aws_secret = secrets_privacy .iter() .find(|s| s.rule_id == "aws-account-id"); @@ -699,14 +678,16 @@ mod tests { #[test] fn test_privacy_mode_public_ip() { + let config = &*TEST_GITLEAKS_CONFIG; + let config_privacy = &*TEST_GITLEAKS_CONFIG_WITH_PRIVACY; let test_input = "SERVER_IP=203.0.113.195"; // Should not detect public IP in regular mode - let secrets = detect_secrets(test_input, None, false); + let secrets = detect_secrets(test_input, None, &config); assert!(!secrets.iter().any(|s| s.rule_id == "public-ipv4")); // Should detect public IP in privacy mode - let secrets_privacy = detect_secrets(test_input, None, true); + let secrets_privacy = detect_secrets(test_input, None, &config_privacy); let ip_secret = secrets_privacy.iter().find(|s| s.rule_id == "public-ipv4"); assert!( ip_secret.is_some(), @@ -720,23 +701,26 @@ mod tests { #[test] fn test_privacy_mode_private_ip_excluded() { + let config_privacy = &*TEST_GITLEAKS_CONFIG_WITH_PRIVACY; let test_input = "LOCAL_IP=192.168.1.1"; // Should not detect private IP even in privacy mode - let secrets_privacy = detect_secrets(test_input, None, true); + let secrets_privacy = detect_secrets(test_input, None, &config_privacy); assert!(!secrets_privacy.iter().any(|s| s.rule_id == "public-ipv4")); } #[test] fn test_privacy_mode_aws_arn() { + let config = &*TEST_GITLEAKS_CONFIG; + let config_privacy = &*TEST_GITLEAKS_CONFIG_WITH_PRIVACY; let test_input = "ARN=arn:aws:s3:::my-bucket/object"; // Should not detect AWS account ID in regular mode - let secrets = detect_secrets(test_input, None, false); + let secrets = detect_secrets(test_input, None, &config); assert!(!secrets.iter().any(|s| s.rule_id == "aws-account-id")); // Should detect AWS account ID in ARN in privacy mode - let secrets_privacy = detect_secrets(test_input, None, true); + let secrets_privacy = detect_secrets(test_input, None, &config_privacy); // This specific ARN doesn't contain an account ID, so it shouldn't be detected assert!( !secrets_privacy @@ -746,7 +730,7 @@ mod tests { // Test with an ARN that contains an account ID let test_input_with_account = "ARN=arn:aws:iam::987654321098:role/MyRole"; - let secrets_with_account = detect_secrets(test_input_with_account, None, true); + let secrets_with_account = detect_secrets(test_input_with_account, None, &config_privacy); let aws_secret = secrets_with_account .iter() .find(|s| s.rule_id == "aws-account-id"); @@ -762,13 +746,13 @@ mod tests { #[test] fn test_privacy_mode_initialization() { - // Test that privacy mode initialization works - let regular_count = initialize_gitleaks_config(false); - let privacy_count = initialize_gitleaks_config(true); + // Test that privacy mode creates configs with different rule counts + let regular_config = &*TEST_GITLEAKS_CONFIG; + let privacy_config = &*TEST_GITLEAKS_CONFIG_WITH_PRIVACY; // Privacy mode should have more rules assert!( - privacy_count > regular_count, + privacy_config.rules.len() > regular_config.rules.len(), "Privacy mode should have more rules than regular mode" ); } @@ -778,7 +762,8 @@ mod tests { let test_input = "AWS_ACCOUNT_ID=987654321098"; // Different from allowlist // Test with privacy mode - let secrets_privacy = detect_secrets(test_input, None, true); + let config = &*TEST_GITLEAKS_CONFIG_WITH_PRIVACY; + let secrets_privacy = detect_secrets(test_input, None, &config); println!("Privacy mode detected {} secrets", secrets_privacy.len()); for secret in &secrets_privacy { println!( @@ -788,7 +773,8 @@ mod tests { } // Test without privacy mode - let secrets_regular = detect_secrets(test_input, None, false); + let config_regular = &*TEST_GITLEAKS_CONFIG; + let secrets_regular = detect_secrets(test_input, None, &config_regular); println!("Regular mode detected {} secrets", secrets_regular.len()); for secret in &secrets_regular { println!( @@ -798,7 +784,7 @@ mod tests { } // Check if privacy config loaded properly - let config_with_privacy = &*GITLEAKS_CONFIG_WITH_PRIVACY; + let config_with_privacy = &*TEST_GITLEAKS_CONFIG_WITH_PRIVACY; let aws_rule = config_with_privacy .rules .iter() @@ -855,7 +841,8 @@ mod tests { let test_input = "SERVER_IP=8.8.8.8"; // Test with privacy mode - let secrets_privacy = detect_secrets(test_input, None, true); + let config_with_privacy = &*TEST_GITLEAKS_CONFIG_WITH_PRIVACY; + let secrets_privacy = detect_secrets(test_input, None, &config_with_privacy); println!("Privacy mode detected {} secrets", secrets_privacy.len()); for secret in &secrets_privacy { println!( @@ -865,7 +852,6 @@ mod tests { } // Check if privacy config loaded properly - let config_with_privacy = &*GITLEAKS_CONFIG_WITH_PRIVACY; let ip_rule = config_with_privacy .rules .iter() @@ -906,6 +892,8 @@ mod tests { fn test_comprehensive_ip_detection() { println!("=== COMPREHENSIVE IP DETECTION TEST ==="); + let config_privacy = &*TEST_GITLEAKS_CONFIG_WITH_PRIVACY; + let test_cases = vec![ // Public IPs that should be detected ("16.170.172.114", true), @@ -924,7 +912,7 @@ mod tests { ]; for (ip, should_detect) in test_cases { - let secrets = detect_secrets(ip, None, true); + let secrets = detect_secrets(ip, None, &config_privacy); let detected = secrets.iter().any(|s| s.rule_id == "public-ipv4"); println!( @@ -949,7 +937,7 @@ mod tests { ]; for context in context_tests { - let secrets = detect_secrets(context, None, true); + let secrets = detect_secrets(context, None, &config_privacy); let detected = secrets.iter().any(|s| s.rule_id == "public-ipv4"); println!("Context: '{}' | Detected: {}", context, detected); assert!(detected, "Should detect IP in context: {}", context); @@ -960,9 +948,11 @@ mod tests { fn test_standalone_ip_detection() { println!("=== TESTING STANDALONE IP DETECTION ==="); + let config = &*TEST_GITLEAKS_CONFIG_WITH_PRIVACY; + // Test standalone IP that should be detected let standalone_ip = "16.170.172.114"; - let secrets = detect_secrets(standalone_ip, None, true); + let secrets = detect_secrets(standalone_ip, None, &config); println!( "Standalone IP '{}' detected {} secrets", @@ -975,7 +965,7 @@ mod tests { // Test IP with context that should be detected let ip_with_context = "SERVER_IP=16.170.172.114"; - let secrets_with_context = detect_secrets(ip_with_context, None, true); + let secrets_with_context = detect_secrets(ip_with_context, None, &config); println!( "IP with context '{}' detected {} secrets", @@ -987,7 +977,6 @@ mod tests { } // Test keyword filtering - let config = &*GITLEAKS_CONFIG_WITH_PRIVACY; let ip_rule = config.rules.iter().find(|r| r.id == "public-ipv4"); if let Some(rule) = ip_rule { println!("IP rule keywords: {:?}", rule.keywords); @@ -1006,13 +995,15 @@ mod tests { fn test_user_provided_json_snippet() { println!("=== TESTING USER PROVIDED JSON SNIPPET ==="); + let config_privacy = &*TEST_GITLEAKS_CONFIG_WITH_PRIVACY; + let json_snippet = r#"{ "UserId": "AIDAX5UI4H55WM6GS6NIJ", "Account": "544388841223", "Arn": "arn:aws:iam::544388841223:user/terraform-mac" }"#; - let secrets = detect_secrets(json_snippet, None, true); + let secrets = detect_secrets(json_snippet, None, &config_privacy); let aws_secrets: Vec<_> = secrets .iter() .filter(|s| s.rule_id == "aws-account-id") @@ -1044,6 +1035,8 @@ mod tests { fn test_aws_account_id_json_field() { println!("=== TESTING AWS ACCOUNT ID JSON FIELD DETECTION ==="); + let config_privacy = &*TEST_GITLEAKS_CONFIG_WITH_PRIVACY; + let test_cases = vec![ // JSON field patterns that should be detected r#""Account": "544388841223""#, @@ -1059,7 +1052,7 @@ mod tests { ]; for test_case in test_cases { - let secrets = detect_secrets(test_case, None, true); + let secrets = detect_secrets(test_case, None, &config_privacy); let detected = secrets.iter().any(|s| s.rule_id == "aws-account-id"); println!("Test case: '{}' | Detected: {}", test_case, detected); diff --git a/libs/shared/src/secrets/mod.rs b/libs/shared/src/secrets/mod.rs index a76e3024..8e387228 100644 --- a/libs/shared/src/secrets/mod.rs +++ b/libs/shared/src/secrets/mod.rs @@ -1,8 +1,13 @@ pub mod gitleaks; +#[cfg(test)] +pub mod test_utils; + use crate::helper::generate_simple_id; +use crate::models::password::Password; /// Re-export the gitleaks initialization function for external access -pub use gitleaks::initialize_gitleaks_config; +pub use gitleaks::create_gitleaks_config; use gitleaks::{DetectedSecret, detect_secrets}; +use std::borrow::Cow; use std::collections::HashMap; use std::fmt; @@ -31,26 +36,27 @@ impl fmt::Display for RedactionResult { } /// Redacts secrets from the input string and returns both the redacted string and redaction mapping +/// Note: Returns only NEW mappings; caller already has the old redaction map and uses `.extend()` to merge /// -/// When privacy_mode is enabled, also detects and redacts private data like IP addresses and AWS account IDs +/// `config` The gitleaks configuration to use for detection. +/// The config can include privacy rules for detecting and redacting private data like IP addresses and AWS account IDs. pub fn redact_secrets( content: &str, path: Option<&str>, old_redaction_map: &HashMap, - privacy_mode: bool, + config: &gitleaks::GitleaksConfig, ) -> RedactionResult { + let mut secrets = detect_secrets(content, path, config); // Skip redaction if content already contains redacted secrets (avoid double redaction) if content.contains("[REDACTED_SECRET:") { return RedactionResult::new(content.to_string(), HashMap::new()); } - let mut secrets = detect_secrets(content, path, privacy_mode); - - let mut redaction_map = old_redaction_map.clone(); + // Track only NEW mappings to return (not the full old map) + let mut new_redaction_map: HashMap = HashMap::new(); let mut reverse_redaction_map: HashMap = old_redaction_map - .clone() - .into_iter() - .map(|(k, v)| (v, k)) + .iter() + .map(|(k, v)| (v.clone(), k.clone())) .collect(); for (original_secret, redaction_key) in &reverse_redaction_map { @@ -132,8 +138,8 @@ pub fn redact_secrets( existing_key.clone() } else { let key = generate_redaction_key(&secret.rule_id); - // Store the mapping (only once per unique secret value) - redaction_map.insert(key.clone(), secret.value.clone()); + // Store the NEW mapping + new_redaction_map.insert(key.clone(), secret.value.clone()); reverse_redaction_map.insert(secret.value, key.clone()); key }; @@ -142,7 +148,7 @@ pub fn redact_secrets( redacted_string.replace_range(secret.start_pos..secret.end_pos, &redaction_key); } - RedactionResult::new(redacted_string, redaction_map) + RedactionResult::new(redacted_string, new_redaction_map) } /// Restores secrets in a redacted string using the provided redaction map @@ -156,44 +162,32 @@ pub fn restore_secrets(redacted_string: &str, redaction_map: &HashMap, ) -> RedactionResult { - if password.is_empty() { - return RedactionResult::new(content.to_string(), HashMap::new()); - } - - // Skip redaction if content already contains redacted secrets (avoid double redaction) - if content.contains("[REDACTED_SECRET:") { - return RedactionResult::new(content.to_string(), HashMap::new()); - } - - let mut redacted_string = content.to_string(); - let mut redaction_map = old_redaction_map.clone(); + // Track only NEW mappings to return (not the full old map) + let mut new_redaction_map: HashMap = HashMap::new(); let mut reverse_redaction_map: HashMap = old_redaction_map - .clone() - .into_iter() - .map(|(k, v)| (v, k)) + .iter() + .map(|(k, v)| (v.clone(), k.clone())) .collect(); // Check if we already have a redaction key for this password - let redaction_key = if let Some(existing_key) = reverse_redaction_map.get(password) { - existing_key.clone() - } else { - let key = generate_redaction_key("password"); - // Store the mapping - redaction_map.insert(key.clone(), password.to_string()); - reverse_redaction_map.insert(password.to_string(), key.clone()); - key - }; - - // Replace all occurrences of the password - redacted_string = redacted_string.replace(password, &redaction_key); + let redaction_key: Cow = + if let Some(existing_key) = reverse_redaction_map.get(password.expose_secret()) { + Cow::Borrowed(existing_key) + } else { + let key = generate_redaction_key("password"); + // Store the NEW mapping + new_redaction_map.insert(key.clone(), password.expose_secret().to_string()); + reverse_redaction_map.insert(password.expose_secret().to_string(), key.clone()); + Cow::Owned(key) + }; - RedactionResult::new(redacted_string, redaction_map) + RedactionResult::new(redaction_key.into_owned(), new_redaction_map) } /// Generates a random redaction key @@ -207,9 +201,10 @@ mod tests { use regex::Regex; use crate::secrets::gitleaks::{ - GITLEAKS_CONFIG, calculate_entropy, contains_any_keyword, create_simple_api_key_regex, + calculate_entropy, contains_any_keyword, create_simple_api_key_regex, is_allowed_by_rule_allowlist, should_allow_match, }; + use crate::secrets::test_utils::TEST_GITLEAKS_CONFIG; use super::*; @@ -230,11 +225,23 @@ mod tests { #[test] fn test_empty_input() { - let result = redact_secrets("", None, &HashMap::new(), false); + let config = &*TEST_GITLEAKS_CONFIG; + let result = redact_secrets("", None, &HashMap::new(), &config); assert_eq!(result.redacted_string, ""); assert!(result.redaction_map.is_empty()); } + #[test] + fn test_redaction_map_preserved_with_empty_content() { + let existing_map = + HashMap::from([("[REDACTED_abc123]".to_string(), "secret123".to_string())]); + + let result = redact_secrets("", None, &existing_map, &*TEST_GITLEAKS_CONFIG); + assert_eq!(result.redacted_string, ""); + // Early return now returns empty map since we only return NEW mappings + assert!(result.redaction_map.is_empty()); + } + #[test] fn test_restore_secrets() { let mut redaction_map = HashMap::new(); @@ -258,9 +265,10 @@ mod tests { #[test] fn test_redact_secrets_with_api_key() { + let config = &*TEST_GITLEAKS_CONFIG; // Use a pattern that matches the generic-api-key rule let input = "export API_KEY=abc123def456ghi789jkl012mno345pqr678"; - let result = redact_secrets(input, None, &HashMap::new(), false); + let result = redact_secrets(input, None, &HashMap::new(), &config); // Should detect the API key and redact it assert!(!result.redaction_map.is_empty()); @@ -272,8 +280,9 @@ mod tests { #[test] fn test_redact_secrets_with_aws_key() { + let config = &*TEST_GITLEAKS_CONFIG; let input = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EX23PLE"; - let result = redact_secrets(input, None, &HashMap::new(), false); + let result = redact_secrets(input, None, &HashMap::new(), &config); // Should detect the AWS access key assert!(!result.redaction_map.is_empty()); @@ -284,33 +293,37 @@ mod tests { #[test] fn test_redaction_identical_secrets() { + let config = &*TEST_GITLEAKS_CONFIG; let input = r#" export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EX23PLE export AWS_ACCESS_KEY_ID_2=AKIAIOSFODNN7EX23PLE "#; - let result = redact_secrets(input, None, &HashMap::new(), false); + let result = redact_secrets(input, None, &HashMap::new(), &config); assert_eq!(result.redaction_map.len(), 1); } #[test] fn test_redaction_identical_secrets_different_contexts() { + let config = &*TEST_GITLEAKS_CONFIG; let input_1 = r#" export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EX23PLE "#; let input_2 = r#" export SOME_OTHER_SECRET=AKIAIOSFODNN7EX23PLE "#; - let result_1 = redact_secrets(input_1, None, &HashMap::new(), false); - let result_2 = redact_secrets(input_2, None, &result_1.redaction_map, false); + let result_1 = redact_secrets(input_1, None, &HashMap::new(), &config); + let result_2 = redact_secrets(input_2, None, &result_1.redaction_map, &config); - assert_eq!(result_1.redaction_map, result_2.redaction_map); + // Second call should return empty map (reuses existing secret mapping) + assert_eq!(result_2.redaction_map.len(), 0); } #[test] fn test_redact_secrets_with_github_token() { + let config = &*TEST_GITLEAKS_CONFIG; let input = "GITHUB_TOKEN=ghp_1234567890abcdef1234567890abcdef12345678"; - let result = redact_secrets(input, None, &HashMap::new(), false); + let result = redact_secrets(input, None, &HashMap::new(), &config); // Should detect the GitHub PAT assert!(!result.redaction_map.is_empty()); @@ -322,16 +335,28 @@ mod tests { #[test] fn test_no_secrets() { let input = "This is just a normal string with no secrets"; - let result = redact_secrets(input, None, &HashMap::new(), false); + let config = &*TEST_GITLEAKS_CONFIG; + let result = redact_secrets(input, None, &HashMap::new(), &config); // Should not detect any secrets assert_eq!(result.redaction_map.len(), 0); assert_eq!(result.redacted_string, input); + + // Test that existing redaction_map is preserved when no secrets in content + let existing_map = HashMap::from([( + "[REDACTED_SECRET:api:abc]".to_string(), + "api_key_value".to_string(), + )]); + let result = redact_secrets(input, None, &existing_map, &*TEST_GITLEAKS_CONFIG); + + // Early return now returns empty map since we only return NEW mappings + assert!(result.redaction_map.is_empty()); + assert_eq!(result.redacted_string, input); } #[test] fn test_debug_generic_api_key() { - let config = &*GITLEAKS_CONFIG; + let config = &*TEST_GITLEAKS_CONFIG; // Find the generic-api-key rule let generic_rule = config.rules.iter().find(|r| r.id == "generic-api-key"); @@ -381,7 +406,8 @@ mod tests { for input in test_inputs { println!("\nTesting input: {}", input); - let result = redact_secrets(input, None, &HashMap::new(), false); + let config = &*TEST_GITLEAKS_CONFIG; + let result = redact_secrets(input, None, &HashMap::new(), &config); println!(" Detected secrets: {}", result.redaction_map.len()); if !result.redaction_map.is_empty() { println!(" Redacted: {}", result.redacted_string); @@ -398,7 +424,7 @@ mod tests { let input = "key=abcdefghijklmnop"; println!("Testing simple input: {}", input); - let config = &*GITLEAKS_CONFIG; + let config = &*TEST_GITLEAKS_CONFIG; let generic_rule = config .rules .iter() @@ -437,7 +463,8 @@ mod tests { } // Also test the full redact_secrets function - let result = redact_secrets(input, None, &HashMap::new(), false); + let config = &*TEST_GITLEAKS_CONFIG; + let result = redact_secrets(input, None, &HashMap::new(), &config); println!( "Full function result: {} secrets detected", result.redaction_map.len() @@ -446,7 +473,7 @@ mod tests { #[test] fn test_regex_breakdown() { - let config = &*GITLEAKS_CONFIG; + let config = &*TEST_GITLEAKS_CONFIG; let generic_rule = config .rules .iter() @@ -531,7 +558,7 @@ mod tests { #[test] fn test_working_api_key_patterns() { - let config = &*GITLEAKS_CONFIG; + let config = &*TEST_GITLEAKS_CONFIG; let generic_rule = config .rules .iter() @@ -589,7 +616,8 @@ mod tests { } // Test the full redact_secrets function - let result = redact_secrets(input, None, &HashMap::new(), false); + let config = &*TEST_GITLEAKS_CONFIG; + let result = redact_secrets(input, None, &HashMap::new(), &config); println!( " Full function detected: {} secrets", result.redaction_map.len() @@ -645,7 +673,7 @@ mod tests { } // Test if there's an issue with the actual gitleaks regex compilation - let config = &*GITLEAKS_CONFIG; + let config = &*TEST_GITLEAKS_CONFIG; let generic_rule = config .rules .iter() @@ -688,7 +716,8 @@ export PORT=3000 println!("Original input:\n{}", input); - let result = redact_secrets(input, None, &HashMap::new(), false); + let config = &*TEST_GITLEAKS_CONFIG; + let result = redact_secrets(input, None, &HashMap::new(), &config); println!("Redacted output:\n{}", result.redacted_string); println!("\nDetected {} secrets:", result.redaction_map.len()); @@ -719,7 +748,7 @@ export PORT=3000 // Helper function for keyword validation tests fn count_rules_that_would_process(input: &str) -> Vec { - let config = &*GITLEAKS_CONFIG; + let config = &*TEST_GITLEAKS_CONFIG; let mut rules = Vec::new(); for rule in &config.rules { @@ -735,7 +764,7 @@ export PORT=3000 fn test_keyword_filtering() { println!("=== TESTING KEYWORD FILTERING ==="); - let config = &*GITLEAKS_CONFIG; + let config = &*TEST_GITLEAKS_CONFIG; // Find a rule that has keywords (like generic-api-key) let generic_rule = config @@ -747,7 +776,8 @@ export PORT=3000 // Test 1: Input with keywords should be processed let input_with_keywords = "export API_KEY=abc123def456ghi789jklmnop"; - let result1 = redact_secrets(input_with_keywords, None, &HashMap::new(), false); + let config = &*TEST_GITLEAKS_CONFIG; + let result1 = redact_secrets(input_with_keywords, None, &HashMap::new(), &config); println!("\nTest 1 - Input WITH keywords:"); println!(" Input: {}", input_with_keywords); println!( @@ -758,7 +788,7 @@ export PORT=3000 // Test 2: Input without any keywords should NOT be processed for that rule let input_without_keywords = "export DATABASE_URL=postgresql://user:pass@localhost/db"; - let result2 = redact_secrets(input_without_keywords, None, &HashMap::new(), false); + let result2 = redact_secrets(input_without_keywords, None, &HashMap::new(), &config); println!("\nTest 2 - Input WITHOUT generic-api-key keywords:"); println!(" Input: {}", input_without_keywords); println!( @@ -774,7 +804,7 @@ export PORT=3000 .find(|r| r.id == "aws-access-token") .unwrap(); let aws_input = "AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE"; - let result3 = redact_secrets(aws_input, None, &HashMap::new(), false); + let result3 = redact_secrets(aws_input, None, &HashMap::new(), &config); println!("\nTest 3 - AWS input:"); println!(" Input: {}", aws_input); println!(" AWS rule keywords: {:?}", aws_rule.keywords); @@ -803,7 +833,7 @@ export PORT=3000 fn test_keyword_optimization_performance() { println!("=== TESTING KEYWORD OPTIMIZATION PERFORMANCE ==="); - let config = &*GITLEAKS_CONFIG; + let config = &*TEST_GITLEAKS_CONFIG; // Test case 1: Input with NO keywords for any rule should be very fast let no_keywords_input = "export DATABASE_CONNECTION=some_long_connection_string_that_has_no_common_secret_keywords"; @@ -823,7 +853,8 @@ export PORT=3000 config.rules.len() ); - let result = redact_secrets(no_keywords_input, None, &HashMap::new(), false); + let config = &*TEST_GITLEAKS_CONFIG; + let result = redact_secrets(no_keywords_input, None, &HashMap::new(), &config); println!(" Secrets detected: {}", result.redaction_map.len()); // Test case 2: Input with specific keywords should only process relevant rules @@ -839,7 +870,7 @@ export PORT=3000 } println!(" Rules that would be processed: {:?}", matching_rules); - let result = redact_secrets(specific_keywords_input, None, &HashMap::new(), false); + let result = redact_secrets(specific_keywords_input, None, &HashMap::new(), &config); println!(" Secrets detected: {}", result.redaction_map.len()); // Test case 3: Verify that rules without keywords are always processed @@ -878,7 +909,7 @@ export PORT=3000 fn test_keyword_filtering_efficiency() { println!("=== KEYWORD FILTERING EFFICIENCY TEST ==="); - let config = &*GITLEAKS_CONFIG; + let config = &*TEST_GITLEAKS_CONFIG; println!("Total rules in config: {}", config.rules.len()); // Test with input that has NO matching keywords @@ -907,7 +938,8 @@ export PORT=3000 ); // Verify no secrets are detected - let result = redact_secrets(non_secret_input, None, &HashMap::new(), false); + let config = &*TEST_GITLEAKS_CONFIG; + let result = redact_secrets(non_secret_input, None, &HashMap::new(), &config); println!(" Secrets detected: {}", result.redaction_map.len()); // Now test with input that has relevant keywords @@ -925,7 +957,8 @@ export PORT=3000 println!(" Rules that match keywords: {}", rules_with_keywords); - let result = redact_secrets(secret_input, None, &HashMap::new(), false); + let config = &*TEST_GITLEAKS_CONFIG; + let result = redact_secrets(secret_input, None, &HashMap::new(), &config); println!(" Secrets detected: {}", result.redaction_map.len()); // Assertions @@ -947,7 +980,7 @@ export PORT=3000 fn test_keyword_validation_summary() { println!("=== KEYWORD VALIDATION SUMMARY ==="); - let config = &*GITLEAKS_CONFIG; + let config = &*TEST_GITLEAKS_CONFIG; let total_rules = config.rules.len(); println!("Total rules in gitleaks config: {}", total_rules); @@ -964,7 +997,8 @@ export PORT=3000 ); println!(" Rules: {:?}", no_keyword_rules); - let no_keyword_secrets = detect_secrets(no_keyword_input, None, false); + let config = &*TEST_GITLEAKS_CONFIG; + let no_keyword_secrets = detect_secrets(no_keyword_input, None, &config); println!( "Secrets detected: {} (expected: 0)", no_keyword_secrets.len() @@ -985,7 +1019,7 @@ export PORT=3000 ); println!(" Rules: {:?}", api_rules); - let api_secrets = detect_secrets(api_input, None, false); + let api_secrets = detect_secrets(api_input, None, &config); println!("Secrets detected: {} (expected: 1)", api_secrets.len()); assert!(!api_secrets.is_empty(), "Should detect at least 1 secrets"); println!("✅ Test passed"); @@ -1004,7 +1038,7 @@ export PORT=3000 ); println!(" Rules: {:?}", aws_rules); - let aws_secrets = detect_secrets(aws_input, None, false); + let aws_secrets = detect_secrets(aws_input, None, &config); println!("Secrets detected: {} (expected: 1)", aws_secrets.len()); // Should detect AWS key @@ -1055,7 +1089,7 @@ export PORT=3000 } // Test allowlist checking - let config = &*GITLEAKS_CONFIG; + let config = &*TEST_GITLEAKS_CONFIG; let generic_rule = config .rules .iter() @@ -1083,7 +1117,8 @@ export PORT=3000 } // Test full detection - let result = redact_secrets(input, None, &HashMap::new(), false); + let config = &*TEST_GITLEAKS_CONFIG; + let result = redact_secrets(input, None, &HashMap::new(), &config); println!( " Full detection result: {} secrets", result.redaction_map.len() @@ -1100,7 +1135,7 @@ export PORT=3000 "PASSWORD=supersecretpassword123456", ]; - let config = &*GITLEAKS_CONFIG; + let config = &*TEST_GITLEAKS_CONFIG; let generic_rule = config .rules .iter() @@ -1186,7 +1221,7 @@ export PORT=3000 "API_KEY=example_key", // Should be filtered ]; - let config = &*GITLEAKS_CONFIG; + let config = &*TEST_GITLEAKS_CONFIG; let generic_rule = config .rules .iter() @@ -1253,75 +1288,37 @@ export PORT=3000 #[test] fn test_redact_password_basic() { - let content = "User password is supersecret123 and should be hidden"; - let password = "supersecret123"; - let result = redact_password(content, password, &HashMap::new()); + let password = Password::new("supersecret123").unwrap(); + let result = redact_password(&password, &HashMap::new()); - // Should redact the password - assert!(!result.redacted_string.contains(password)); + // Should return a redaction key assert!( result .redacted_string - .contains("[REDACTED_SECRET:password:") + .starts_with("[REDACTED_SECRET:password:") ); assert_eq!(result.redaction_map.len(), 1); // The redaction map should contain our password let redacted_password = result.redaction_map.values().next().unwrap(); - assert_eq!(redacted_password, password); - } - - #[test] - fn test_redact_password_empty() { - let content = "Some content without password"; - let password = ""; - let result = redact_password(content, password, &HashMap::new()); - - // Should not change anything - assert_eq!(result.redacted_string, content); - assert!(result.redaction_map.is_empty()); - } - - #[test] - fn test_redact_password_multiple_occurrences() { - let content = "Password is mypass123 and again mypass123 appears here"; - let password = "mypass123"; - let result = redact_password(content, password, &HashMap::new()); - - // Should redact both occurrences with the same key - assert!(!result.redacted_string.contains(password)); - assert_eq!(result.redaction_map.len(), 1); - - // Count redaction keys in the result - let redaction_key = result.redaction_map.keys().next().unwrap(); - let count = result.redacted_string.matches(redaction_key).count(); - assert_eq!(count, 2); + assert_eq!(redacted_password, "supersecret123"); } #[test] fn test_redact_password_reuse_existing_key() { - // Start with an existing redaction map - let mut existing_map = HashMap::new(); - existing_map.insert( - "[REDACTED_SECRET:password:abc123]".to_string(), + let existing_map = HashMap::from([( + "[REDACTED_SECRET:password:abc123456]".to_string(), "mypassword".to_string(), - ); + )]); - let content = "The password mypassword should use existing key"; - let password = "mypassword"; - let result = redact_password(content, password, &existing_map); + let password = Password::new("mypassword").unwrap(); + let result = redact_password(&password, &existing_map); - // Should reuse the existing key - assert_eq!(result.redaction_map.len(), 1); - assert!( - result - .redaction_map - .contains_key("[REDACTED_SECRET:password:abc123]") - ); - assert!( - result - .redacted_string - .contains("[REDACTED_SECRET:password:abc123]") + // Should reuse the existing key, so no NEW mappings returned + assert_eq!(result.redaction_map.len(), 0); + assert_eq!( + result.redacted_string, + "[REDACTED_SECRET:password:abc123456]" ); } @@ -1334,64 +1331,36 @@ export PORT=3000 "some_api_key".to_string(), ); - let content = "API key is some_api_key and password is newpassword123"; - let password = "newpassword123"; - let result = redact_password(content, password, &existing_map); + let password = Password::new("newpassword123").unwrap(); + let result = redact_password(&password, &existing_map); - // Should preserve existing mapping and add new one - assert_eq!(result.redaction_map.len(), 2); + // Should return only the NEW password mapping, not the existing api-key + assert_eq!(result.redaction_map.len(), 1); assert!( - result + !result .redaction_map .contains_key("[REDACTED_SECRET:api-key:xyz789]") ); - assert!( - result - .redaction_map - .get("[REDACTED_SECRET:api-key:xyz789]") - .unwrap() - == "some_api_key" - ); - - // Should add new password mapping - let new_keys: Vec<_> = result - .redaction_map - .keys() - .filter(|k| k.contains("password")) - .collect(); - assert_eq!(new_keys.len(), 1); - let password_key = new_keys[0]; - assert_eq!( - result.redaction_map.get(password_key).unwrap(), - "newpassword123" - ); - } - - #[test] - fn test_redact_password_no_match() { - let content = "This content has no matching password"; - let password = "notfound"; - let result = redact_password(content, password, &HashMap::new()); - - // Should still create a redaction key but content unchanged - assert_eq!(result.redacted_string, content); - assert_eq!(result.redaction_map.len(), 1); - assert_eq!(result.redaction_map.values().next().unwrap(), "notfound"); + // The new password mapping should be present + assert!(result.redaction_map.values().any(|v| v == "newpassword123")); } #[test] fn test_redact_password_integration_with_restore() { - let content = "Login with username admin and password secret456"; - let password = "secret456"; - let result = redact_password(content, password, &HashMap::new()); + let password = Password::new("secret456").unwrap(); + let result = redact_password(&password, &HashMap::new()); // Redact the password - assert!(!result.redacted_string.contains(password)); - assert!(result.redacted_string.contains("username admin")); + assert!( + result + .redacted_string + .starts_with("[REDACTED_SECRET:password:") + ); + assert!(!result.redacted_string.contains("secret456")); // Restore should bring back the original let restored = restore_secrets(&result.redacted_string, &result.redaction_map); - assert_eq!(restored, content); + assert_eq!(restored, "secret456"); } #[test] @@ -1400,7 +1369,8 @@ export PORT=3000 let content = "The secret value is mysecretvalue123 and another is anothersecret456"; // First, test with empty map to prove the secret wouldn't normally be redacted - let result_empty = redact_secrets(content, None, &HashMap::new(), false); + let config = &*TEST_GITLEAKS_CONFIG; + let result_empty = redact_secrets(content, None, &HashMap::new(), &config); // Verify that mysecretvalue123 is NOT redacted when using empty map assert!(result_empty.redacted_string.contains("mysecretvalue123")); @@ -1411,7 +1381,7 @@ export PORT=3000 "mysecretvalue123".to_string(), ); - let result = redact_secrets(content, None, &existing_redaction_map, false); + let result = redact_secrets(content, None, &existing_redaction_map, &config); // The secret from the existing map should be redacted assert!( @@ -1421,26 +1391,16 @@ export PORT=3000 ); assert!(!result.redacted_string.contains("mysecretvalue123")); - // The redaction map should contain the existing mapping - assert!( - result - .redaction_map - .contains_key("[REDACTED_SECRET:manual:abc123]") - ); - assert_eq!( - result - .redaction_map - .get("[REDACTED_SECRET:manual:abc123]") - .unwrap(), - "mysecretvalue123" - ); + // The redaction map should be EMPTY (reusing existing mapping, no new secrets) + assert_eq!(result.redaction_map.len(), 0); } #[test] fn test_redact_secrets_skip_already_redacted() { // Content that already contains redacted secrets should not be double-redacted let content = "The password is [REDACTED_SECRET:password:abc123] and API key is [REDACTED_SECRET:api-key:xyz789]"; - let result = redact_secrets(content, None, &HashMap::new(), false); + let config = &*TEST_GITLEAKS_CONFIG; + let result = redact_secrets(content, None, &HashMap::new(), config); // Should return content unchanged assert_eq!(result.redacted_string, content); @@ -1449,25 +1409,29 @@ export PORT=3000 } #[test] - fn test_redact_password_skip_already_redacted() { - // Content that already contains redacted secrets should not be double-redacted - let content = "[REDACTED_SECRET:password:existing123]"; - let password = "newpassword"; - let result = redact_password(content, password, &HashMap::new()); + fn test_redact_password_idempotency() { + let password = Password::new("newpassword".to_string()).unwrap(); - // Should return content unchanged - assert_eq!(result.redacted_string, content); - // Should not add any new redactions - assert!(result.redaction_map.is_empty()); + // First redaction + let first_result = redact_password(&password, &HashMap::new()); + assert!( + first_result + .redacted_string + .starts_with("[REDACTED_SECRET:password:") + ); + + // Second redaction with the same password should reuse the key + let second_result = redact_password(&password, &first_result.redaction_map); + assert_eq!(second_result.redacted_string, first_result.redacted_string); } #[test] fn test_redact_secrets_skip_nested_redaction() { // Simulate what happens when local_tools redacts and proxy tries to redact again - let original_password = "MySecureP@ssw0rd!"; + let original_password = Password::new("MySecureP@ssw0rd!".to_string()).unwrap(); // First redaction (simulating local_tools) - let first_result = redact_password(original_password, original_password, &HashMap::new()); + let first_result = redact_password(&original_password, &HashMap::new()); assert!( first_result .redacted_string @@ -1475,11 +1439,26 @@ export PORT=3000 ); // Second redaction attempt (simulating proxy) - should be skipped + let config = &*TEST_GITLEAKS_CONFIG; let second_result = - redact_secrets(&first_result.redacted_string, None, &HashMap::new(), false); + redact_secrets(&first_result.redacted_string, None, &HashMap::new(), config); // Should return the already-redacted content unchanged assert_eq!(second_result.redacted_string, first_result.redacted_string); assert!(second_result.redaction_map.is_empty()); } + + #[test] + fn test_redact_secrets_preserves_map_on_early_return() { + let mut old_map = HashMap::new(); + old_map.insert("key".to_string(), "value".to_string()); + + // Input containing REDACTED_SECRET should trigger early return + let content = "Some content with [REDACTED_SECRET:test:123]"; + let result = redact_secrets(content, None, &old_map, &*TEST_GITLEAKS_CONFIG); + + assert_eq!(result.redacted_string, content); + // Early return now returns empty map since we only return NEW mappings + assert!(result.redaction_map.is_empty()); + } } diff --git a/libs/shared/src/secrets/test_utils.rs b/libs/shared/src/secrets/test_utils.rs new file mode 100644 index 00000000..9660c169 --- /dev/null +++ b/libs/shared/src/secrets/test_utils.rs @@ -0,0 +1,15 @@ +//! Test utilities for secret detection tests. +//! +//! This module provides shared test configurations to avoid recompiling +//! regex patterns for every test, which would be slow and memory-intensive. + +use super::gitleaks::{GitleaksConfig, create_gitleaks_config}; +use std::sync::LazyLock; + +/// Lazy-loaded gitleaks configuration +pub static TEST_GITLEAKS_CONFIG: LazyLock = + LazyLock::new(|| create_gitleaks_config(false)); + +/// Lazy-loaded gitleaks configuration with privacy rules +pub static TEST_GITLEAKS_CONFIG_WITH_PRIVACY: LazyLock = + LazyLock::new(|| create_gitleaks_config(true)); diff --git a/libs/shared/src/utils.rs b/libs/shared/src/utils.rs index 9362fae9..16948251 100644 --- a/libs/shared/src/utils.rs +++ b/libs/shared/src/utils.rs @@ -1,10 +1,14 @@ use crate::local_store::LocalStore; use async_trait::async_trait; use rand::Rng; +use rand::seq::{IndexedRandom, SliceRandom}; +use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use walkdir::DirEntry; +use crate::models::password::{Password, PasswordGenerationError}; + /// Read .gitignore patterns from the specified base directory pub fn read_gitignore_patterns(base_dir: &str) -> Vec { let mut patterns = vec![".git".to_string()]; // Always ignore .git directory @@ -285,81 +289,116 @@ temp* } } +const MAX_RETRIES: usize = 10; + /// Generate a secure password with alphanumeric characters and optional symbols -pub fn generate_password(length: usize, no_symbols: bool) -> String { +pub fn generate_password( + length: usize, + include_symbols: bool, + redaction_map: &HashMap, +) -> Result { + // Validate length early to prevent potential underflow + if length < 8 { + tracing::error!( + "Password generation failed: length must be at least 8 characters, received {}", + length + ); + return Err(PasswordGenerationError::TooShort); + } + let mut rng = rand::rng(); // Define character sets - let lowercase = "abcdefghijklmnopqrstuvwxyz"; - let uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; - let digits = "0123456789"; - let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?"; + let lowercase = b"abcdefghijklmnopqrstuvwxyz"; + let uppercase = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + let digits = b"0123456789"; + let symbols = b"!@#$%^&*()_+-=[]{}|;:,.<>?"; // Build the character set based on options - let mut charset = String::new(); - charset.push_str(lowercase); - charset.push_str(uppercase); - charset.push_str(digits); + let mut charset = Vec::new(); + charset.extend_from_slice(lowercase); + charset.extend_from_slice(uppercase); + charset.extend_from_slice(digits); - if !no_symbols { - charset.push_str(symbols); + if include_symbols { + charset.extend_from_slice(symbols); } - let charset_chars: Vec = charset.chars().collect(); - // Generate password ensuring at least one character from each required category - let mut password = String::new(); - - // Ensure at least one character from each category - password.push( - lowercase - .chars() - .nth(rng.random_range(0..lowercase.len())) - .unwrap(), - ); - password.push( - uppercase - .chars() - .nth(rng.random_range(0..uppercase.len())) - .unwrap(), - ); - password.push( - digits - .chars() - .nth(rng.random_range(0..digits.len())) - .unwrap(), + let mut password = Vec::with_capacity(length); + + populate_password_with_random_chars( + length, + include_symbols, + &mut rng, + lowercase, + uppercase, + digits, + symbols, + &charset, + &mut password, ); - if !no_symbols { - password.push( - symbols - .chars() - .nth(rng.random_range(0..symbols.len())) - .unwrap(), - ); - } + for attempt in 0..MAX_RETRIES { + if redaction_map.values().any(|v| v.as_bytes() == password) { + tracing::warn!( + "Password collision detected, regenerating (attempt {}/{})", + attempt + 1, + MAX_RETRIES + ); - // Fill the rest with random characters from the full charset - let remaining_length = if length > password.len() { - length - password.len() - } else { - 0 - }; + password.clear(); + populate_password_with_random_chars( + length, + include_symbols, + &mut rng, + lowercase, + uppercase, + digits, + symbols, + &charset, + &mut password, + ); - for _ in 0..remaining_length { - let random_char = charset_chars[rng.random_range(0..charset_chars.len())]; - password.push(random_char); + continue; + } + + return Ok(Password::new(String::from_utf8(password)?)?); } - // Shuffle the password to randomize the order - let mut password_chars: Vec = password.chars().collect(); - for i in 0..password_chars.len() { - let j = rng.random_range(0..password_chars.len()); - password_chars.swap(i, j); + tracing::error!( + "Could not create a non conflicting password after {} retries", + MAX_RETRIES + ); + + Err(PasswordGenerationError::Conflict) +} + +fn populate_password_with_random_chars( + length: usize, + include_symbols: bool, + rng: &mut rand::prelude::ThreadRng, + lowercase: &[u8; 26], + uppercase: &[u8; 26], + digits: &[u8; 10], + symbols: &[u8; 26], + charset: &Vec, + password: &mut Vec, +) { + // Ensure at least one character from each category + password.push(*lowercase.choose(rng).unwrap()); + password.push(*uppercase.choose(rng).unwrap()); + password.push(*digits.choose(rng).unwrap()); + + if include_symbols { + password.push(*symbols.choose(rng).unwrap()); } - // Take only the requested length - password_chars.into_iter().take(length).collect() + // Fill the rest with random characters from the full charset + password.extend((0..(length - password.len())).map(|_| charset.choose(rng).unwrap())); + + // Shuffle the password to randomize the order + password.shuffle(rng); } /// Sanitize text output by removing control characters while preserving essential whitespace @@ -433,23 +472,36 @@ pub fn handle_large_output( mod password_tests { use super::*; + #[test] + fn test_generate_password_length_too_short() { + let redaction_map = HashMap::new(); + let result = generate_password(7, true, &redaction_map); + + match result { + Ok(_) => panic!("Expected an error, but got a valid password"), + Err(e) => assert_eq!(e, PasswordGenerationError::TooShort), + } + } + #[test] fn test_generate_password_length() { - let password = generate_password(10, false); - assert_eq!(password.len(), 10); + let redaction_map = HashMap::new(); + let password = generate_password(10, true, &redaction_map).unwrap(); + assert_eq!(password.expose_secret().len(), 10); - let password = generate_password(20, true); - assert_eq!(password.len(), 20); + let password = generate_password(20, false, &redaction_map).unwrap(); + assert_eq!(password.expose_secret().len(), 20); } #[test] fn test_generate_password_no_symbols() { - let password = generate_password(50, true); + let redaction_map = HashMap::new(); + let password = generate_password(50, false, &redaction_map).unwrap(); let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?"; for symbol in symbols.chars() { assert!( - !password.contains(symbol), + !password.expose_secret().contains(symbol), "Password should not contain symbol: {}", symbol ); @@ -458,21 +510,32 @@ mod password_tests { #[test] fn test_generate_password_with_symbols() { - let password = generate_password(50, false); + let redaction_map = HashMap::new(); + let password = generate_password(50, true, &redaction_map).unwrap(); let symbols = "!@#$%^&*()_+-=[]{}|;:,.<>?"; // At least one symbol should be present (due to our algorithm) - let has_symbol = password.chars().any(|c| symbols.contains(c)); + let has_symbol = password + .expose_secret() + .chars() + .any(|c| symbols.contains(c)); assert!(has_symbol, "Password should contain at least one symbol"); } #[test] fn test_generate_password_contains_required_chars() { - let password = generate_password(50, false); + let redaction_map = HashMap::new(); + let password = generate_password(50, true, &redaction_map).unwrap(); - let has_lowercase = password.chars().any(|c| c.is_ascii_lowercase()); - let has_uppercase = password.chars().any(|c| c.is_ascii_uppercase()); - let has_digit = password.chars().any(|c| c.is_ascii_digit()); + let has_lowercase = password + .expose_secret() + .chars() + .any(|c| c.is_ascii_lowercase()); + let has_uppercase = password + .expose_secret() + .chars() + .any(|c| c.is_ascii_uppercase()); + let has_digit = password.expose_secret().chars().any(|c| c.is_ascii_digit()); assert!(has_lowercase, "Password should contain lowercase letters"); assert!(has_uppercase, "Password should contain uppercase letters"); @@ -481,11 +544,12 @@ mod password_tests { #[test] fn test_generate_password_uniqueness() { - let password1 = generate_password(20, false); - let password2 = generate_password(20, false); + let redaction_map = HashMap::new(); + let password1 = generate_password(20, true, &redaction_map).unwrap(); + let password2 = generate_password(20, true, &redaction_map).unwrap(); // Very unlikely to generate the same password twice - assert_ne!(password1, password2); + assert_ne!(password1.expose_secret(), password2.expose_secret()); } } diff --git a/tui/src/app.rs b/tui/src/app.rs index 2274d5fc..e356fd7f 100644 --- a/tui/src/app.rs +++ b/tui/src/app.rs @@ -22,8 +22,9 @@ use ratatui::layout::Size; use ratatui::text::Line; use stakpak_api::models::ListRuleBook; use stakpak_shared::models::integrations::openai::{AgentModel, ToolCall, ToolCallResult}; -use stakpak_shared::secret_manager::SecretManager; +use stakpak_shared::secret_manager::{SecretManagerHandle, launch_secret_manager}; use std::collections::HashMap; +use std::sync::Arc; use tokio::sync::mpsc; use uuid::Uuid; @@ -168,7 +169,7 @@ pub struct AppState { pub context_usage_percent: u64, // ========== Configuration State ========== - pub secret_manager: SecretManager, + pub secret_manager: Arc, pub latest_version: Option, pub is_git_repo: bool, pub auto_approve_manager: AutoApproveManager, @@ -298,7 +299,7 @@ impl AppState { pending_path_start: None, dialog_message_id: None, file_search: FileSearch::default(), - secret_manager: SecretManager::new(redact_secrets, privacy_mode), + secret_manager: launch_secret_manager(redact_secrets, privacy_mode, None), latest_version: latest_version.clone(), ctrl_c_pressed_once: false, ctrl_c_timer: None, diff --git a/tui/src/services/shell_popup.rs b/tui/src/services/shell_popup.rs index 9c2f9a54..50e1d983 100644 --- a/tui/src/services/shell_popup.rs +++ b/tui/src/services/shell_popup.rs @@ -214,7 +214,7 @@ pub fn update_cursor_blink(state: &mut AppState) { state.shell_cursor_blink_timer = state.shell_cursor_blink_timer.wrapping_add(1); // Toggle every 5 frames (~500ms at 10fps / 100ms interval) - if state.shell_cursor_blink_timer % 5 == 0 { + if state.shell_cursor_blink_timer.is_multiple_of(5) { state.shell_cursor_visible = !state.shell_cursor_visible; } }