diff --git a/.gitignore b/.gitignore index 183907c3..c2e48faa 100755 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ docs/* .claude/ .cursor/ .gemini/ +kubeconfig \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 382fed4b..2772bd47 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,9 +23,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -38,18 +38,18 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -62,39 +62,45 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.10" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "arbitrary" version = "1.4.2" @@ -116,7 +122,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.17", + "thiserror", "time", ] @@ -128,7 +134,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -140,7 +146,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -157,9 +163,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.37" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d10e4f991a553474232bc0a31799f6d24b034a84c0971d80d2e2f78b2e576e40" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -186,18 +192,18 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -208,15 +214,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "zeroize", @@ -224,14 +230,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.41.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -263,7 +270,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", @@ -298,7 +305,7 @@ checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -320,9 +327,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "block-buffer" @@ -333,23 +340,32 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" -version = "1.10.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.2.62" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" dependencies = [ "find-msvc-tools", "jobserver", @@ -365,29 +381,40 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.1", + "windows-link", ] [[package]] name = "clap" -version = "4.5.54" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -395,9 +422,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.54" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -407,21 +434,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" @@ -434,15 +461,15 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compression-codecs" -version = "0.4.36" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" dependencies = [ "compression-core", "flate2", @@ -451,32 +478,30 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.31" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" [[package]] -name = "concurrent-queue" -version = "2.5.0" +name = "const-oid" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] name = "const-str" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93e19f68b180ebff43d6d42005c4b5f046c65fcac28369ba8b3beaad633f9ec0" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" [[package]] name = "const_format" -version = "0.2.35" +version = "0.2.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" dependencies = [ "const_format_proc_macros", + "konst", ] [[package]] @@ -515,31 +540,49 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "darling" version = "0.21.3" @@ -561,7 +604,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.119", ] [[package]] @@ -572,7 +615,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -597,12 +640,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" -dependencies = [ - "powerfmt", -] +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" [[package]] name = "derive_arbitrary" @@ -612,27 +652,28 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ "proc-macro2", "quote", - "syn", + "rustc_version", + "syn 2.0.119", ] [[package]] @@ -641,20 +682,31 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -678,33 +730,33 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "either" -version = "1.15.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "enum-ordinalize" -version = "4.3.2" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" dependencies = [ "enum-ordinalize-derive", ] [[package]] name = "enum-ordinalize-derive" -version = "4.3.2" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -713,13 +765,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -736,21 +797,27 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.8" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -785,9 +852,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -800,9 +867,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -810,15 +877,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -827,38 +894,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -868,15 +935,14 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] [[package]] name = "generic-array" -version = "0.14.9" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", @@ -884,9 +950,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -902,18 +968,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] [[package]] name = "git2" -version = "0.20.2" +version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2deb07a133b1520dc1a5690e9bd08950108873d7ed5de38dcc74d3b5ebffa110" +checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" dependencies = [ "bitflags", "libc", @@ -936,9 +1014,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" dependencies = [ "atomic-waker", "bytes", @@ -946,13 +1024,19 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.15.5" @@ -966,9 +1050,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -988,7 +1072,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1002,31 +1086,30 @@ dependencies = [ [[package]] name = "hostname" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" dependencies = [ "cfg-if", "libc", - "windows-link 0.1.3", + "windows-link", ] [[package]] name = "http" -version = "1.3.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", - "fnv", "itoa", ] [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1034,9 +1117,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1063,11 +1146,20 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" -version = "1.7.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -1080,7 +1172,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -1088,9 +1179,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", @@ -1098,7 +1189,6 @@ dependencies = [ "log", "rustls", "rustls-native-certs", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1120,14 +1210,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.17" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -1136,7 +1225,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.5", "tokio", "tower-service", "tracing", @@ -1144,9 +1233,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -1168,12 +1257,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1181,9 +1271,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1194,9 +1284,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1208,16 +1298,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1228,15 +1319,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1266,9 +1357,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1276,31 +1367,31 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.0" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + +[[package]] +name = "indexmap" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.17.1", "serde", "serde_core", ] [[package]] name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.12" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is_debug" @@ -1314,42 +1405,52 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] [[package]] name = "json-patch" -version = "4.1.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f300e415e2134745ef75f04562dd0145405c2f7fd92065db029ac4b16b57fe90" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" dependencies = [ "jsonptr", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror", ] [[package]] @@ -1362,7 +1463,7 @@ dependencies = [ "pest_derive", "regex", "serde_json", - "thiserror 2.0.17", + "thiserror", ] [[package]] @@ -1388,6 +1489,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "konst" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb" +dependencies = [ + "konst_macro_rules", +] + +[[package]] +name = "konst_macro_rules" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" + [[package]] name = "kube" version = "2.0.1" @@ -1429,10 +1545,10 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "thiserror 2.0.17", + "thiserror", "tokio", "tokio-util", - "tower", + "tower 0.5.3", "tower-http", "tracing", ] @@ -1453,7 +1569,7 @@ dependencies = [ "serde", "serde-value", "serde_json", - "thiserror 2.0.17", + "thiserror", ] [[package]] @@ -1467,7 +1583,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn", + "syn 2.0.119", ] [[package]] @@ -1480,7 +1596,7 @@ dependencies = [ "futures", "k8s-openapi", "kube", - "rand", + "rand 0.9.5", "snafu", "tokio", "tokio-util", @@ -1508,7 +1624,7 @@ dependencies = [ "pin-project", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror", "tokio", "tokio-util", "tracing", @@ -1522,15 +1638,15 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libgit2-sys" -version = "0.18.2+1.9.1" +version = "0.18.8+1.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c42fe03df2bd3c53a3a9c7317ad91d80c81cd1fb0caec8d7cc4cd2bfa10c222" +checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50" dependencies = [ "cc", "libc", @@ -1540,9 +1656,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.22" +version = "1.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" dependencies = [ "cc", "libc", @@ -1550,11 +1666,17 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -1567,9 +1689,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.28" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru-slab" @@ -1594,9 +1716,9 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -1632,15 +1754,21 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", "windows-sys 0.61.2", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + [[package]] name = "nom" version = "7.1.3" @@ -1672,15 +1800,15 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -1714,9 +1842,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -1726,9 +1854,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "operator" @@ -1754,6 +1882,7 @@ dependencies = [ "rcgen", "reqwest", "ring", + "rustfs-admin", "rustls", "rustls-pemfile", "rustls-webpki", @@ -1762,7 +1891,7 @@ dependencies = [ "serde_ignored", "serde_json", "serde_yaml_ng", - "sha2", + "sha2 0.10.9", "shadow-rs", "snafu", "strum", @@ -1771,7 +1900,7 @@ dependencies = [ "tokio-rustls", "tokio-stream", "tokio-util", - "tower", + "tower 0.5.3", "tower-http", "tracing", "tracing-subscriber", @@ -1816,7 +1945,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1837,9 +1966,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.3" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" dependencies = [ "memchr", "ucd-trie", @@ -1847,9 +1976,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.3" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" dependencies = [ "pest", "pest_generator", @@ -1857,70 +1986,73 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.3" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "pest_meta" -version = "2.8.3" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" dependencies = [ "pest", - "sha2", +] + +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset", + "indexmap 2.14.0", ] [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -1941,102 +2073,276 @@ dependencies = [ ] [[package]] -name = "proc-macro2" -version = "1.0.103" +name = "prettyplease" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ - "unicode-ident", + "proc-macro2", + "syn 2.0.119", ] [[package]] -name = "quinn" -version = "0.11.9" +name = "proc-macro2" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.17", - "tokio", - "tracing", - "web-time", + "unicode-ident", ] [[package]] -name = "quinn-proto" -version = "0.11.14" +name = "prost" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" dependencies = [ "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.17", - "tinyvec", - "tracing", - "web-time", + "prost-derive", ] [[package]] -name = "quinn-udp" -version = "0.5.14" +name = "prost-build" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ - "cfg_aliases", - "libc", + "heck", + "itertools", + "log", + "multimap", "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "regex", + "syn 2.0.119", + "tempfile", ] [[package]] -name = "quote" -version = "1.0.41" +name = "prost-derive" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" dependencies = [ + "anyhow", + "itertools", "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "prost-types" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost", +] [[package]] -name = "rand" -version = "0.9.4" +name = "protoc-bin-vendored" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" dependencies = [ - "rand_chacha", - "rand_core", + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", ] [[package]] -name = "rand_chacha" +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.5", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.5", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -2048,6 +2354,21 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rcgen" version = "0.13.2" @@ -2072,29 +2393,29 @@ dependencies = [ [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -2104,9 +2425,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -2115,15 +2436,15 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", @@ -2147,7 +2468,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -2165,7 +2486,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -2173,9 +2494,9 @@ dependencies = [ [[package]] name = "rust-embed" -version = "8.11.0" +version = "8.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -2184,32 +2505,80 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.11.0" +version = "8.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" dependencies = [ + "mime_guess", "proc-macro2", "quote", "rust-embed-utils", - "syn", + "syn 2.0.119", "walkdir", ] [[package]] name = "rust-embed-utils" -version = "8.11.0" +version = "8.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" dependencies = [ - "sha2", + "sha2 0.11.0", "walkdir", ] [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustfs-admin" +version = "0.1.0" +dependencies = [ + "chrono", + "hex", + "hmac", + "reqwest", + "serde", + "serde_json", + "sha2 0.10.9", + "url", +] + +[[package]] +name = "rustfs-cosi-driver" +version = "0.1.0" +dependencies = [ + "hex", + "k8s-openapi", + "kube", + "prost", + "protoc-bin-vendored", + "rand 0.8.8", + "rustfs-admin", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "snafu", + "tokio", + "tokio-stream", + "tonic", + "tonic-build", + "tracing", + "tracing-subscriber", +] [[package]] name = "rusticata-macros" @@ -2220,11 +2589,24 @@ dependencies = [ "nom", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "log", @@ -2238,9 +2620,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -2259,9 +2641,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -2269,9 +2651,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -2281,15 +2663,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -2302,18 +2684,18 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "schemars" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54e910108742c57a770f492731f99be216a52fadd361b06c8fb59d74ccc267d2" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -2324,14 +2706,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4908ad288c5035a8eb12cfdf0d49270def0a268ee162b75eeee0f85d155a7c45" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 3.0.4", ] [[package]] @@ -2351,9 +2733,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", "core-foundation", @@ -2364,19 +2746,25 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -2394,33 +2782,33 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] name = "serde_derive_internals" -version = "0.29.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -2435,9 +2823,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.148" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -2475,7 +2863,7 @@ version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -2488,7 +2876,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" dependencies = [ - "indexmap", + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -2502,15 +2890,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] name = "shadow-rs" -version = "1.5.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff351910f271e7065781b6b4f0f43cb515d474d812f31176a0246d9058e47d5d" +checksum = "3c798acfc78a69c7b038adde44084d8df875555b091da42c90ae46257cdcc41a" dependencies = [ "const_format", "git2", @@ -2530,36 +2929,37 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "snafu" @@ -2581,17 +2981,27 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "socket2" -version = "0.6.1" +version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", ] [[package]] @@ -2624,7 +3034,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2635,9 +3045,20 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.109" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -2661,86 +3082,78 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] -name = "thiserror" -version = "1.0.69" +name = "tempfile" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "thiserror-impl 1.0.69", + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", ] [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.69" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "syn 3.0.4", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.44" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", "libc", "num-conv", "num_threads", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -2748,9 +3161,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -2758,9 +3171,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -2773,29 +3186,29 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", "mio", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -2810,9 +3223,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -2822,25 +3235,90 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-io", "futures-sink", "futures-util", + "libc", "pin-project-lite", "slab", "tokio", ] +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9557ce109ea773b399c9b9e5dca39294110b74f1f342cb347a80d1fce8c26a11" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.119", +] + [[package]] name = "tower" -version = "0.5.2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.8", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", @@ -2855,9 +3333,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", "base64", @@ -2870,17 +3348,17 @@ dependencies = [ "http-body-util", "http-range-header", "httpdate", - "iri-string", "mime", "mime_guess", "percent-encoding", "pin-project-lite", "tokio", "tokio-util", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "tracing", + "url", ] [[package]] @@ -2915,7 +3393,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2941,9 +3419,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -2965,21 +3443,21 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "tz-rs" -version = "0.7.1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14eff19b8dc1ace5bf7e4d920b2628ae3837f422ff42210cb1567cbf68b5accf" +checksum = "4fc6c929ffa10fb34f4a3c7e9a73620a83ef2e85e47f9ec3381b8289e6762f42" [[package]] name = "tzdb" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0be2ea5956f295449f47c0b825c5e109022ff1a6a53bb4f77682a87c2341fbf5" +checksum = "56d4e985b6dda743ae7fd4140c28105316ffd75bc58258ee6cc12934e3eb7a0c" dependencies = [ "iana-time-zone", "tz-rs", @@ -2988,9 +3466,9 @@ dependencies = [ [[package]] name = "tzdb_data" -version = "0.2.2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c4c81d75033770e40fbd3643ce7472a1a9fd301f90b7139038228daf8af03ec" +checksum = "febaa995f6852367564e16c3369988b99d471d43ed12b1255b8d294661797f1c" dependencies = [ "tz-rs", ] @@ -3009,9 +3487,9 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-xid" @@ -3033,9 +3511,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", @@ -3057,11 +3535,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utoipa" -version = "5.4.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_json", "utoipa-gen", @@ -3069,13 +3547,13 @@ dependencies = [ [[package]] name = "utoipa-gen" -version = "5.4.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3148,18 +3626,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -3170,22 +3648,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.55" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ - "cfg-if", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3193,31 +3668,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.82" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -3235,9 +3710,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -3259,7 +3734,7 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", + "windows-link", "windows-result", "windows-strings", ] @@ -3272,7 +3747,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3283,15 +3758,9 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" @@ -3304,7 +3773,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3313,7 +3782,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3322,16 +3791,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -3340,7 +3800,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3349,31 +3809,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link 0.2.1", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -3382,107 +3825,59 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "x509-parser" @@ -3497,7 +3892,7 @@ dependencies = [ "nom", "oid-registry", "rusticata-macros", - "thiserror 2.0.17", + "thiserror", "time", ] @@ -3512,9 +3907,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -3523,68 +3918,68 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -3593,9 +3988,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -3604,13 +3999,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -3624,17 +4019,17 @@ dependencies = [ "crossbeam-utils", "displaydoc", "flate2", - "indexmap", + "indexmap 2.14.0", "memchr", - "thiserror 2.0.17", + "thiserror", "zopfli", ] [[package]] name = "zmij" -version = "1.0.10" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30e0d8dffbae3d840f64bda38e28391faef673a7b5a6017840f2a106c8145868" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" diff --git a/Cargo.toml b/Cargo.toml index 30b461c9..a1dba84b 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ url = "2.5" shadow-rs = "1.5.0" snafu = { version = "0.8.9", features = ["futures"] } kube-leader-election = { path = "crates/leader-election" } +rustfs-admin = { path = "crates/rustfs-admin" } hostname = "0.4" # Console dependencies @@ -65,7 +66,7 @@ shadow-rs = { version = "1.5.0", features = ["build"] } unused_variables = "allow" [workspace] -members = ["crates/leader-election"] +members = ["crates/leader-election", "crates/cosi-driver", "crates/rustfs-admin"] [lints.clippy] unwrap_used = "deny" diff --git a/Dockerfile b/Dockerfile index 1bef2d8f..fad04fd4 100755 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,9 @@ ARG PNPM_VERSION=10.28.1 # Shared Cargo settings for slow / flaky networks (applies to all Rust stages) FROM ${RUST_BUILD_IMAGE} AS rust-base +RUN apt-get update \ + && apt-get install -y --no-install-recommends protobuf-compiler \ + && rm -rf /var/lib/apt/lists/* RUN mkdir -p /usr/local/cargo && \ printf '%s\n' \ '[http]' \ @@ -48,9 +51,9 @@ FROM rust-base AS cacher COPY --from=cargo-chef-installer /usr/local/cargo/bin/cargo-chef /usr/local/cargo/bin/cargo-chef WORKDIR /app COPY --from=planner /app/recipe.json recipe.json -RUN cargo chef cook --release --recipe-path recipe.json +RUN cargo chef cook --release --workspace --recipe-path recipe.json -# Stage 3: Build the binary +# Stage 3: Build the binaries (operator + COSI driver) FROM rust-base AS builder ARG VERSION ENV RUSTFS_OPERATOR_VERSION=${VERSION} @@ -58,7 +61,7 @@ WORKDIR /app COPY . . COPY --from=cacher /app/target target COPY --from=cacher /usr/local/cargo /usr/local/cargo -RUN cargo build --release +RUN cargo build --release -p operator -p rustfs-cosi-driver # Stage 4: Build the static Console frontend FROM ${NODE_BUILD_IMAGE} AS console-web-builder @@ -77,5 +80,6 @@ FROM ${BASE_IMAGE} WORKDIR /app COPY --from=builder /app/target/release/operator . +COPY --from=builder /app/target/release/rustfs-cosi-driver . COPY --from=console-web-builder /app/console-web/out ./console-web ENTRYPOINT ["./operator"] diff --git a/crates/cosi-driver/Cargo.toml b/crates/cosi-driver/Cargo.toml new file mode 100644 index 00000000..78346ae1 --- /dev/null +++ b/crates/cosi-driver/Cargo.toml @@ -0,0 +1,46 @@ +# Copyright 2025 RustFS Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[package] +name = "rustfs-cosi-driver" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +[[bin]] +name = "rustfs-cosi-driver" +path = "src/main.rs" + +[dependencies] +rustfs-admin = { path = "../rustfs-admin" } +tokio = { version = "1.49.0", features = ["rt", "rt-multi-thread", "macros", "fs", "signal", "net"] } +tokio-stream = { version = "0.1", features = ["net"] } +tonic = "0.12" +prost = "0.13" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +kube = { version = "2.0.1", features = ["client", "rustls-tls"] } +k8s-openapi = { version = "0.26.1", features = ["v1_30"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.10" +hex = "0.4" +rand = "0.8" +snafu = { version = "0.8.9", features = ["futures"] } +rustls = { version = "0.23", default-features = false, features = ["ring"] } + +[build-dependencies] +tonic-build = "0.12" +protoc-bin-vendored = "3" diff --git a/crates/cosi-driver/build.rs b/crates/cosi-driver/build.rs new file mode 100644 index 00000000..3d6cb892 --- /dev/null +++ b/crates/cosi-driver/build.rs @@ -0,0 +1,30 @@ +// Copyright 2025 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +fn main() -> Result<(), Box> { + // Prefer PATH `protoc` (Dockerfile installs protobuf-compiler); fall back to vendored. + if std::env::var_os("PROTOC").is_none() + && let Ok(protoc) = protoc_bin_vendored::protoc_bin_path() + { + // SAFETY: build script is single-threaded before codegen. + unsafe { + std::env::set_var("PROTOC", protoc); + } + } + tonic_build::configure() + .build_server(true) + .build_client(false) + .compile_protos(&["proto/cosi.proto"], &["proto"])?; + Ok(()) +} diff --git a/crates/cosi-driver/proto/cosi.proto b/crates/cosi-driver/proto/cosi.proto new file mode 100644 index 00000000..b1b05aae --- /dev/null +++ b/crates/cosi-driver/proto/cosi.proto @@ -0,0 +1,111 @@ +// Copyright 2025 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +syntax = "proto3"; +package cosi.v1alpha1; + +option go_package = "sigs.k8s.io/container-object-storage-interface/proto;cosi"; + +service Identity { + rpc DriverGetInfo (DriverGetInfoRequest) returns (DriverGetInfoResponse) {} +} + +service Provisioner { + rpc DriverCreateBucket (DriverCreateBucketRequest) returns (DriverCreateBucketResponse) {} + rpc DriverDeleteBucket (DriverDeleteBucketRequest) returns (DriverDeleteBucketResponse) {} + rpc DriverGrantBucketAccess (DriverGrantBucketAccessRequest) returns (DriverGrantBucketAccessResponse); + rpc DriverRevokeBucketAccess (DriverRevokeBucketAccessRequest) returns (DriverRevokeBucketAccessResponse); +} + +enum S3SignatureVersion { + UnknownSignature = 0; + S3V2 = 1; + S3V4 = 2; +} + +enum AuthenticationType { + UnknownAuthenticationType = 0; + Key = 1; + IAM = 2; +} + +message S3 { + string region = 1; + S3SignatureVersion signature_version = 2; +} + +message AzureBlob { + string storage_account = 1; +} + +message GCS { + string private_key_name = 1; + string project_id = 2; + string service_account = 3; +} + +message Protocol { + oneof type { + S3 s3 = 1; + AzureBlob azureBlob = 2; + GCS gcs = 3; + } +} + +message CredentialDetails { + map secrets = 1; +} + +message DriverGetInfoRequest {} + +message DriverGetInfoResponse { + string name = 1; +} + +message DriverCreateBucketRequest { + string name = 1; + map parameters = 2; +} + +message DriverCreateBucketResponse { + string bucket_id = 1; + Protocol bucket_info = 2; +} + +message DriverDeleteBucketRequest { + string bucket_id = 1; + map delete_context = 2; +} + +message DriverDeleteBucketResponse {} + +message DriverGrantBucketAccessRequest { + string bucket_id = 1; + string name = 2; + AuthenticationType authentication_type = 3; + map parameters = 4; +} + +message DriverGrantBucketAccessResponse { + string account_id = 1; + map credentials = 2; +} + +message DriverRevokeBucketAccessRequest { + string bucket_id = 1; + string account_id = 2; + map revoke_access_context = 3; +} + +message DriverRevokeBucketAccessResponse {} diff --git a/crates/cosi-driver/src/backend.rs b/crates/cosi-driver/src/backend.rs new file mode 100644 index 00000000..7532a2f0 --- /dev/null +++ b/crates/cosi-driver/src/backend.rs @@ -0,0 +1,160 @@ +// Copyright 2025 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Admin credential lookup + RustFS admin client construction. + +use k8s_openapi::api::core::v1::{ConfigMap, Secret}; +use kube::{Api, Client}; +use rustfs_admin::RustfsAdminClient; +use snafu::Snafu; +use tracing::info; + +use crate::parameters::BackendParameters; + +#[derive(Debug, Snafu)] +pub enum BackendError { + #[snafu(display("failed to read Secret {namespace}/{name}: {source}"))] + SecretLookup { + namespace: String, + name: String, + source: kube::Error, + }, + #[snafu(display("secret key missing: {key}"))] + MissingSecretKey { key: &'static str }, + #[snafu(display("secret key empty: {key}"))] + EmptySecretKey { key: &'static str }, + #[snafu(display("secret key is not valid utf8: {key}"))] + InvalidSecretKey { key: &'static str }, + #[snafu(display("failed to read ConfigMap {namespace}/{name}: {source}"))] + ConfigMapLookup { + namespace: String, + name: String, + source: kube::Error, + }, + #[snafu(display("configmap key missing: {key}"))] + MissingCaData { key: &'static str }, + #[snafu(transparent)] + ClientBuild { + source: rustfs_admin::RustfsClientError, + }, +} + +#[allow(clippy::result_large_err)] +fn secret_value<'a>(secret: &'a Secret, keys: &[&'static str]) -> Result<&'a str, BackendError> { + let data = secret + .data + .as_ref() + .ok_or(BackendError::MissingSecretKey { key: keys[0] })?; + for key in keys { + if let Some(bytes) = data.get(*key) { + let value = std::str::from_utf8(&bytes.0) + .map_err(|_| BackendError::InvalidSecretKey { key })?; + if value.is_empty() { + return Err(BackendError::EmptySecretKey { key }); + } + return Ok(value); + } + } + Err(BackendError::MissingSecretKey { key: keys[0] }) +} + +pub async fn admin_client_from_params( + kube: &Client, + params: &BackendParameters, +) -> Result { + let secrets: Api = + Api::namespaced(kube.clone(), ¶ms.object_store_user_secret_namespace); + let secret = secrets + .get(¶ms.object_store_user_secret_name) + .await + .map_err(|source| BackendError::SecretLookup { + namespace: params.object_store_user_secret_namespace.clone(), + name: params.object_store_user_secret_name.clone(), + source, + })?; + + let access_key = secret_value( + &secret, + &[ + "accesskey", + "accessKey", + "ACCESSKEY", + "AWS_ACCESS_KEY_ID", + "access_key", + "access-key", + "access_key_id", + "access-key-id", + "RUSTFS_ACCESS_KEY", + ], + )?; + let secret_key = secret_value( + &secret, + &[ + "secretkey", + "secretKey", + "SECRETKEY", + "AWS_SECRET_ACCESS_KEY", + "secret_key", + "secret-key", + "secret_access_key", + "secret-access-key", + "RUSTFS_SECRET_KEY", + ], + )?; + + info!( + endpoint = %params.endpoint, + secret = %params.object_store_user_secret_name, + "building RustFS admin client" + ); + + if let (Some(cm_name), Some(cm_ns)) = ( + params.tls_ca_configmap_name.as_ref(), + params + .tls_ca_configmap_namespace + .as_ref() + .or(Some(¶ms.object_store_user_secret_namespace)), + ) { + let cms: Api = Api::namespaced(kube.clone(), cm_ns); + let cm = cms + .get(cm_name) + .await + .map_err(|source| BackendError::ConfigMapLookup { + namespace: cm_ns.clone(), + name: cm_name.clone(), + source, + })?; + let ca = cm + .data + .as_ref() + .and_then(|d| { + d.get("ca.crt") + .or_else(|| d.get("tls.crt")) + .or_else(|| d.get("ca-bundle.crt")) + }) + .ok_or(BackendError::MissingCaData { key: "ca.crt" })?; + return Ok(RustfsAdminClient::new_with_base_url_and_ca_pem( + params.endpoint.clone(), + access_key, + secret_key, + ca.as_bytes(), + )?); + } + + Ok(RustfsAdminClient::new_with_base_url( + params.endpoint.clone(), + access_key, + secret_key, + )) +} diff --git a/crates/cosi-driver/src/bucket.rs b/crates/cosi-driver/src/bucket.rs new file mode 100644 index 00000000..52fdf159 --- /dev/null +++ b/crates/cosi-driver/src/bucket.rs @@ -0,0 +1,462 @@ +// Copyright 2025 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Bucket create/delete with atomic ownership proof and safe static defaults. + +use k8s_openapi::api::core::v1::ConfigMap; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::Client; +use kube::api::{Api, PostParams}; +use rustfs_admin::RustfsAdminClient; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use snafu::{ResultExt, Snafu}; +use tracing::info; + +use crate::parameters::{self, BackendParameters}; + +const PROOF_KEY: &str = "proof"; +const CAS_RETRIES: usize = 8; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum BucketProofState { + PendingCreate, + Ready, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BucketProof { + pub backend_id: String, + pub cosi_name: String, + pub bucket_id: String, + pub static_override: bool, + pub state: BucketProofState, +} + +#[derive(Debug, Snafu)] +pub enum BucketError { + #[snafu(display("kubernetes error: {source}"))] + Kube { source: kube::Error }, + #[snafu(display("invalid bucket ownership proof for `{bucket_id}`: {detail}"))] + InvalidProof { bucket_id: String, detail: String }, + #[snafu(display("rustfs admin error: {msg}"))] + Admin { msg: String }, + #[snafu(display( + "refusing to delete bucket `{bucket_id}`: BucketClass uses static \ + bucketName/buckets override (adoption preview); delete is skipped \ + without ownership proof" + ))] + StaticBucketDeleteRefused { bucket_id: String }, + #[snafu(display("no buckets to create (buckets/bucketName empty or only *)"))] + NothingToCreate, + #[snafu(display( + "bucket `{bucket}` is already owned by a different account; refusing to adopt it" + ))] + NameConflict { bucket: String }, + #[snafu(display( + "refusing to delete bucket `{bucket_id}`: ownership proof is still PendingCreate \ + (creation may not have completed)" + ))] + DeleteRefusedNotReady { bucket_id: String }, + #[snafu(display("refusing to delete bucket `{bucket_id}`: no ownership proof found"))] + DeleteRefusedNoProof { bucket_id: String }, +} + +fn bucket_claim_name(backend_id: &str, bucket_id: &str) -> String { + let digest = hex::encode(Sha256::digest(bucket_id.as_bytes())); + format!("cosi-bkt-{backend_id}-{}", &digest[..24]) +} + +#[derive(Clone)] +struct BucketOwnershipStore { + api: Api, +} + +impl BucketOwnershipStore { + fn new(client: Client, namespace: String) -> Self { + Self { + api: Api::namespaced(client, &namespace), + } + } + + async fn get(&self, name: &str) -> Result, BucketError> { + let cm = match self.api.get(name).await { + Ok(cm) => cm, + Err(kube::Error::Api(err)) if err.code == 404 => return Ok(None), + Err(source) => return Err(source).context(KubeSnafu), + }; + let raw = cm + .data + .as_ref() + .and_then(|data| data.get(PROOF_KEY)) + .ok_or_else(|| BucketError::InvalidProof { + bucket_id: name.to_string(), + detail: "missing proof data".into(), + })?; + let proof: BucketProof = + serde_json::from_str(raw).map_err(|err| BucketError::InvalidProof { + bucket_id: name.to_string(), + detail: err.to_string(), + })?; + Ok(Some(proof)) + } + + fn build_cm(name: &str, proof: &BucketProof) -> Result { + let raw = serde_json::to_string(proof).map_err(|err| BucketError::InvalidProof { + bucket_id: name.to_string(), + detail: err.to_string(), + })?; + Ok(ConfigMap { + metadata: ObjectMeta { + name: Some(name.to_string()), + labels: Some( + [( + "app.kubernetes.io/name".to_string(), + "rustfs-cosi-driver".to_string(), + )] + .into_iter() + .collect(), + ), + ..Default::default() + }, + data: Some([(PROOF_KEY.to_string(), raw)].into_iter().collect()), + ..Default::default() + }) + } + + /// Create `PendingCreate` for `bucket_id` before mutating RustFS, or + /// resume an existing proof for the same `cosi_name`. Returns `Err` if a + /// different `cosi_name` already owns this bucket_id. + async fn begin_or_resume( + &self, + backend_id: &str, + cosi_name: &str, + bucket_id: &str, + static_override: bool, + ) -> Result { + let name = bucket_claim_name(backend_id, bucket_id); + let fresh = BucketProof { + backend_id: backend_id.to_string(), + cosi_name: cosi_name.to_string(), + bucket_id: bucket_id.to_string(), + static_override, + state: BucketProofState::PendingCreate, + }; + let cm = Self::build_cm(&name, &fresh)?; + match self.api.create(&PostParams::default(), &cm).await { + Ok(_) => Ok(fresh), + Err(kube::Error::Api(err)) if err.code == 409 => { + let existing = self + .get(&name) + .await? + .ok_or_else(|| BucketError::InvalidProof { + bucket_id: bucket_id.to_string(), + detail: "bucket proof create conflicted but object is now missing".into(), + })?; + Ok(existing) + } + Err(source) => Err(source).context(KubeSnafu), + } + } + + async fn mark_ready(&self, proof: &BucketProof) -> Result<(), BucketError> { + if proof.state == BucketProofState::Ready { + return Ok(()); + } + let name = bucket_claim_name(&proof.backend_id, &proof.bucket_id); + let mut ready = proof.clone(); + ready.state = BucketProofState::Ready; + self.replace(&name, &ready).await + } + + async fn remove(&self, proof: &BucketProof) -> Result<(), BucketError> { + let name = bucket_claim_name(&proof.backend_id, &proof.bucket_id); + match self.api.delete(&name, &Default::default()).await { + Ok(_) => Ok(()), + Err(kube::Error::Api(err)) if err.code == 404 => Ok(()), + Err(source) => Err(source).context(KubeSnafu), + } + } + + async fn replace(&self, name: &str, proof: &BucketProof) -> Result<(), BucketError> { + for _ in 0..CAS_RETRIES { + let mut cm = self.api.get(name).await.context(KubeSnafu)?; + let raw = serde_json::to_string(proof).map_err(|err| BucketError::InvalidProof { + bucket_id: proof.bucket_id.clone(), + detail: err.to_string(), + })?; + let mut data = cm.data.take().unwrap_or_default(); + data.insert(PROOF_KEY.to_string(), raw); + cm.data = Some(data); + match self.api.replace(name, &PostParams::default(), &cm).await { + Ok(_) => return Ok(()), + Err(kube::Error::Api(err)) if err.code == 409 => continue, + Err(source) => return Err(source).context(KubeSnafu), + } + } + Err(BucketError::InvalidProof { + bucket_id: proof.bucket_id.clone(), + detail: "CAS conflict updating bucket ownership proof (retry)".into(), + }) + } +} + +#[derive(Debug, Clone)] +pub struct CreateBucketOutcome { + pub bucket_id: String, + pub region: String, + /// True when BAC/BC supplied bucketName/buckets (static adoption preview). + #[allow(dead_code)] + pub static_override: bool, +} + +/// Dynamic path: create the unique COSI request name, recording ownership +/// before mutating RustFS so a bucket adopted via `AlreadyExists` (owned by +/// someone else) can never later pass the delete-ownership gate. +/// Static override preview: create configured bucket names; do not treat as +/// fully owned for delete (see [`delete_bucket`]). +pub async fn create_bucket( + kube: &Client, + client: &RustfsAdminClient, + params: &BackendParameters, + cosi_name: &str, +) -> Result { + let static_override = params.bucket_name.is_some() || params.buckets.is_some(); + let targets = if static_override { + let list = params.buckets_to_create(cosi_name); + if list.is_empty() { + return Err(BucketError::NothingToCreate); + } + list + } else { + vec![cosi_name.to_string()] + }; + let bucket_id = if static_override { + params.primary_bucket_id(cosi_name) + } else { + cosi_name.to_string() + }; + + let backend_id = parameters::backend_id(params); + let namespace = crate::grant::state_namespace(kube); + let store = BucketOwnershipStore::new(kube.clone(), namespace); + let proof = store + .begin_or_resume(&backend_id, cosi_name, &bucket_id, static_override) + .await?; + + for bucket in &targets { + info!( + bucket = %bucket, + cosi_name = %cosi_name, + static_override, + "creating bucket" + ); + let rustfs_result = client + .create_bucket(bucket, Some(params.region.as_str()), false) + .await + .map_err(|err| BucketError::Admin { + msg: err.to_string(), + })?; + if !logic::is_successful_create(rustfs_result) { + // Never owned it — drop the PendingCreate proof we just + // created rather than leaving a dangling claim. + if proof.state == BucketProofState::PendingCreate { + store.remove(&proof).await?; + } + return Err(BucketError::NameConflict { + bucket: bucket.clone(), + }); + } + } + + store.mark_ready(&proof).await?; + + Ok(CreateBucketOutcome { + bucket_id, + region: params.region.clone(), + static_override, + }) +} + +/// Delete only dynamically owned buckets that reached a `Ready` ownership +/// proof — an adopted-but-conflicting bucket never reaches `Ready`, so it +/// can never be deleted through this path. +/// +/// When `bucketName`/`buckets` is set on the class, refuse delete (FailedPrecondition +/// semantics at the gRPC layer) so shared/static buckets are not destroyed. +pub async fn delete_bucket( + kube: &Client, + client: &RustfsAdminClient, + params: &BackendParameters, + bucket_id: &str, +) -> Result<(), BucketError> { + if params.bucket_name.is_some() || params.buckets.is_some() { + return Err(BucketError::StaticBucketDeleteRefused { + bucket_id: bucket_id.to_string(), + }); + } + + let backend_id = parameters::backend_id(params); + let namespace = crate::grant::state_namespace(kube); + let store = BucketOwnershipStore::new(kube.clone(), namespace); + let name = bucket_claim_name(&backend_id, bucket_id); + let proof = store.get(&name).await?; + + logic::authorize_delete(false, proof.as_ref().map(|p| p.state), bucket_id)?; + + info!(bucket = %bucket_id, "deleting dynamically owned bucket"); + client + .delete_bucket(bucket_id) + .await + .map_err(|err| BucketError::Admin { + msg: err.to_string(), + })?; + store.remove(&proof.expect("checked above")).await?; + Ok(()) +} + +/// Pure decision logic, used by the real code paths above and exhaustively +/// table-tested below without needing kube / RustFS clients. +pub mod logic { + use super::{BucketError, BucketProofState}; + use rustfs_admin::CreateBucketResult; + + /// Whether a `create_bucket` RustFS response should be treated as a + /// successful (idempotent) create, given whatever ownership proof state + /// existed for this bucket_id going in. `existing_proof_state` never + /// changes the verdict — tests exercise every value to prove that + /// `AlreadyExists` is never a successful retry, regardless of it. + pub fn is_successful_create(rustfs_result: CreateBucketResult) -> bool { + !matches!(rustfs_result, CreateBucketResult::AlreadyExists) + } + + #[cfg_attr(not(test), allow(dead_code))] + pub fn resolve_create_result( + existing_proof_state: Option, + rustfs_result: CreateBucketResult, + ) -> Result<(), BucketError> { + let _ = existing_proof_state; + if is_successful_create(rustfs_result) { + Ok(()) + } else { + Err(BucketError::NameConflict { + bucket: "".to_string(), + }) + } + } + + /// Whether a delete is authorized: never for a static override, and + /// only for a bucket_id with a `Ready` ownership proof. + pub fn authorize_delete( + static_override: bool, + proof_state: Option, + bucket_id: &str, + ) -> Result<(), BucketError> { + if static_override { + return Err(BucketError::StaticBucketDeleteRefused { + bucket_id: bucket_id.to_string(), + }); + } + match proof_state { + Some(BucketProofState::Ready) => Ok(()), + Some(_) => Err(BucketError::DeleteRefusedNotReady { + bucket_id: bucket_id.to_string(), + }), + None => Err(BucketError::DeleteRefusedNoProof { + bucket_id: bucket_id.to_string(), + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::logic::{authorize_delete, resolve_create_result}; + use super::*; + use rustfs_admin::CreateBucketResult; + use std::collections::HashMap; + + fn params(extra: &[(&str, &str)]) -> BackendParameters { + let mut map = HashMap::new(); + map.insert("endpoint".into(), "http://rustfs".into()); + map.insert("objectStoreUserSecretName".into(), "s".into()); + map.insert("objectStoreUserSecretNamespace".into(), "ns".into()); + for (k, v) in extra { + map.insert((*k).into(), (*v).into()); + } + BackendParameters::from_map(&map).unwrap() + } + + #[test] + fn dynamic_create_targets_cosi_name_only() { + let p = params(&[]); + assert!(p.bucket_name.is_none()); + assert!(p.buckets.is_none()); + let targets = if p.bucket_name.is_some() || p.buckets.is_some() { + p.buckets_to_create("bc-1") + } else { + vec!["bc-1".to_string()] + }; + assert_eq!(targets, vec!["bc-1".to_string()]); + } + + #[test] + fn static_override_refuses_delete_without_admin() { + let err = authorize_delete(true, None, "shared-mlflow").unwrap_err(); + assert!(matches!(err, BucketError::StaticBucketDeleteRefused { .. })); + } + + /// Regression test: existing-compatible vs incompatible bucket collision. + /// `AlreadyExists` (owned by someone else) must never be treated as a + /// successful retry, no matter what our own proof state was. + #[test] + fn already_exists_is_always_a_name_conflict() { + for state in [ + None, + Some(BucketProofState::PendingCreate), + Some(BucketProofState::Ready), + ] { + let err = resolve_create_result(state, CreateBucketResult::AlreadyExists).unwrap_err(); + assert!(matches!(err, BucketError::NameConflict { .. })); + } + } + + #[test] + fn created_and_already_owned_by_you_succeed() { + for state in [ + None, + Some(BucketProofState::PendingCreate), + Some(BucketProofState::Ready), + ] { + assert!(resolve_create_result(state, CreateBucketResult::Created).is_ok()); + assert!(resolve_create_result(state, CreateBucketResult::AlreadyOwnedByYou).is_ok()); + } + } + + /// Regression test: delete refusal without a matching ownership proof. + #[test] + fn delete_refused_without_matching_proof() { + assert!(matches!( + authorize_delete(false, None, "b").unwrap_err(), + BucketError::DeleteRefusedNoProof { .. } + )); + assert!(matches!( + authorize_delete(false, Some(BucketProofState::PendingCreate), "b").unwrap_err(), + BucketError::DeleteRefusedNotReady { .. } + )); + assert!(authorize_delete(false, Some(BucketProofState::Ready), "b").is_ok()); + } +} diff --git a/crates/cosi-driver/src/credentials.rs b/crates/cosi-driver/src/credentials.rs new file mode 100644 index 00000000..b58c1a6a --- /dev/null +++ b/crates/cosi-driver/src/credentials.rs @@ -0,0 +1,273 @@ +// Copyright 2025 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Durable random S3 credentials stored in Kubernetes Secrets, created once. + +use k8s_openapi::api::core::v1::Secret; +use k8s_openapi::{ByteString, apimachinery::pkg::apis::meta::v1::ObjectMeta}; +use kube::api::PostParams; +use kube::{Api, Client, Error as KubeError}; +use rand::{Rng, distributions::Alphanumeric}; +use sha2::{Digest, Sha256}; +use snafu::Snafu; + +use crate::parameters::sanitize_policy_fragment; + +#[derive(Debug, Snafu)] +pub enum CredentialStoreError { + #[snafu(display("failed to read credential Secret {namespace}/{name}: {source}"))] + Lookup { + namespace: String, + name: String, + source: Box, + }, + #[snafu(display("failed to persist credential Secret {namespace}/{name}: {source}"))] + Persist { + namespace: String, + name: String, + source: Box, + }, + #[snafu(display("credential Secret {namespace}/{name} missing key `{key}`"))] + MissingKey { + namespace: String, + name: String, + key: &'static str, + }, + #[snafu(display( + "credential store inconsistent: Secret {namespace}/{name} holds access key `{found}` \ + but this grant is bound to `{expected}`" + ))] + AccessKeyMismatch { + namespace: String, + name: String, + expected: String, + found: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoredCredentials { + pub access_key: String, + pub secret_key: String, + pub secret_name: String, + /// True only when *this* call created the Secret; false for a normal + /// reload and for the loser of a concurrent create race. Consumed by + /// `grant::resolve_password_rotation` to decide whether the RustFS + /// password must be (re)issued. + pub freshly_created: bool, +} + +pub fn credential_secret_name(grant_name: &str) -> String { + format!("cosi-cred-{}", sanitize_policy_fragment(grant_name)) +} + +pub fn access_key_hash(access_key: &str) -> String { + hex::encode(Sha256::digest(access_key.as_bytes())) +} + +pub fn random_secret_key(len: usize) -> String { + rand::thread_rng() + .sample_iter(&Alphanumeric) + .take(len) + .map(char::from) + .collect() +} + +fn decode_secret_key(secret: &Secret, key: &'static str) -> Result { + let namespace = secret.metadata.namespace.clone().unwrap_or_default(); + let name = secret.metadata.name.clone().unwrap_or_default(); + let value = secret + .data + .as_ref() + .and_then(|data| data.get(key)) + .ok_or_else(|| CredentialStoreError::MissingKey { + namespace: namespace.clone(), + name: name.clone(), + key, + })?; + String::from_utf8(value.0.clone()).map_err(|_| CredentialStoreError::MissingKey { + namespace, + name, + key, + }) +} + +fn read_stored(secret: &Secret) -> Result<(String, String), CredentialStoreError> { + let access = decode_secret_key(secret, "accessKeyID") + .or_else(|_| decode_secret_key(secret, "AWS_ACCESS_KEY_ID"))?; + let secret_key = decode_secret_key(secret, "accessSecretKey") + .or_else(|_| decode_secret_key(secret, "AWS_SECRET_ACCESS_KEY"))?; + Ok((access, secret_key)) +} + +/// Load existing credentials for a grant, or atomically create a new random +/// Secret. Two concurrent creators for the same grant converge on the +/// winner's material: the loser reloads instead of overwriting. +pub async fn load_or_create_credentials( + kube: &Client, + namespace: &str, + grant_name: &str, + access_key: &str, +) -> Result { + let secret_name = credential_secret_name(grant_name); + let api: Api = Api::namespaced(kube.clone(), namespace); + + match api.get(&secret_name).await { + Ok(existing) => { + let (stored_access, secret_key) = read_stored(&existing)?; + if stored_access != access_key { + return Err(CredentialStoreError::AccessKeyMismatch { + namespace: namespace.to_string(), + name: secret_name, + expected: access_key.to_string(), + found: stored_access, + }); + } + Ok(StoredCredentials { + access_key: stored_access, + secret_key, + secret_name, + freshly_created: false, + }) + } + Err(KubeError::Api(err)) if err.code == 404 => { + let secret_key = random_secret_key(40); + match create_credentials(kube, namespace, &secret_name, access_key, &secret_key).await { + Ok(()) => Ok(StoredCredentials { + access_key: access_key.to_string(), + secret_key, + secret_name, + freshly_created: true, + }), + Err(CredentialStoreError::Persist { source, .. }) if matches!(source.as_ref(), KubeError::Api(err) if err.code == 409) => + { + // Lost the create race: reload and return the winner's + // material rather than our own locally-generated one. + let winner = api.get(&secret_name).await.map_err(|source| { + CredentialStoreError::Lookup { + namespace: namespace.to_string(), + name: secret_name.clone(), + source: Box::new(source), + } + })?; + let (stored_access, secret_key) = read_stored(&winner)?; + if stored_access != access_key { + return Err(CredentialStoreError::AccessKeyMismatch { + namespace: namespace.to_string(), + name: secret_name, + expected: access_key.to_string(), + found: stored_access, + }); + } + Ok(StoredCredentials { + access_key: stored_access, + secret_key, + secret_name, + freshly_created: false, + }) + } + Err(err) => Err(err), + } + } + Err(source) => Err(CredentialStoreError::Lookup { + namespace: namespace.to_string(), + name: secret_name, + source: Box::new(source), + }), + } +} + +async fn create_credentials( + kube: &Client, + namespace: &str, + secret_name: &str, + access_key: &str, + secret_key: &str, +) -> Result<(), CredentialStoreError> { + let api: Api = Api::namespaced(kube.clone(), namespace); + let mut data = std::collections::BTreeMap::new(); + data.insert( + "accessKeyID".to_string(), + ByteString(access_key.as_bytes().to_vec()), + ); + data.insert( + "accessSecretKey".to_string(), + ByteString(secret_key.as_bytes().to_vec()), + ); + data.insert( + "AWS_ACCESS_KEY_ID".to_string(), + ByteString(access_key.as_bytes().to_vec()), + ); + data.insert( + "AWS_SECRET_ACCESS_KEY".to_string(), + ByteString(secret_key.as_bytes().to_vec()), + ); + + let secret = Secret { + metadata: ObjectMeta { + name: Some(secret_name.to_string()), + namespace: Some(namespace.to_string()), + labels: Some( + [ + ( + "app.kubernetes.io/name".to_string(), + "rustfs-cosi-driver".to_string(), + ), + ( + "rustfs.objectstorage.k8s.io/grant".to_string(), + sanitize_policy_fragment( + secret_name + .strip_prefix("cosi-cred-") + .unwrap_or(secret_name), + ), + ), + ] + .into_iter() + .collect(), + ), + ..ObjectMeta::default() + }, + type_: Some("Opaque".to_string()), + data: Some(data), + ..Secret::default() + }; + + api.create(&PostParams::default(), &secret) + .await + .map_err(|source| CredentialStoreError::Persist { + namespace: namespace.to_string(), + name: secret_name.to_string(), + source: Box::new(source), + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{access_key_hash, credential_secret_name, random_secret_key}; + + #[test] + fn secret_names_are_stable() { + assert_eq!(credential_secret_name("ba-abc.def"), "cosi-cred-ba-abc-def"); + } + + #[test] + fn random_secrets_are_not_derived_from_access_key() { + let a = random_secret_key(40); + let b = random_secret_key(40); + assert_ne!(a, b); + assert_ne!(a, access_key_hash("mlflow")); + assert!(a.len() >= 40); + } +} diff --git a/crates/cosi-driver/src/driver.rs b/crates/cosi-driver/src/driver.rs new file mode 100644 index 00000000..a0f43172 --- /dev/null +++ b/crates/cosi-driver/src/driver.rs @@ -0,0 +1,224 @@ +// Copyright 2025 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! COSI Identity + Provisioner gRPC adapters (thin; logic lives in grant/bucket). + +use std::collections::HashMap; + +use kube::Client; +use tonic::{Request, Response, Status}; +use tracing::error; + +use crate::backend::{BackendError, admin_client_from_params}; +use crate::bucket::{self, BucketError}; +use crate::grant::{self, GrantError}; +use crate::parameters::{BackendParameters, DRIVER_NAME}; +use crate::proto::cosi::v1alpha1::{ + AuthenticationType, CredentialDetails, DriverCreateBucketRequest, DriverCreateBucketResponse, + DriverDeleteBucketRequest, DriverDeleteBucketResponse, DriverGetInfoRequest, + DriverGetInfoResponse, DriverGrantBucketAccessRequest, DriverGrantBucketAccessResponse, + DriverRevokeBucketAccessRequest, DriverRevokeBucketAccessResponse, Protocol, S3, + S3SignatureVersion, identity_server::Identity, provisioner_server::Provisioner, +}; + +#[derive(Clone)] +pub struct Driver { + kube: Client, +} + +impl Driver { + pub fn new(kube: Client) -> Self { + Self { kube } + } +} + +fn map_backend(err: BackendError) -> Status { + error!(error = %err, "backend error"); + Status::internal(err.to_string()) +} + +fn map_params(err: crate::parameters::ParameterError) -> Status { + Status::invalid_argument(err.to_string()) +} + +fn map_bucket(err: BucketError) -> Status { + match err { + BucketError::StaticBucketDeleteRefused { .. } + | BucketError::DeleteRefusedNotReady { .. } + | BucketError::DeleteRefusedNoProof { .. } => Status::failed_precondition(err.to_string()), + BucketError::NothingToCreate => Status::invalid_argument(err.to_string()), + BucketError::NameConflict { .. } => Status::already_exists(err.to_string()), + BucketError::Admin { ref msg } => { + error!(error = %msg, "rustfs admin error"); + Status::internal(err.to_string()) + } + BucketError::Kube { .. } | BucketError::InvalidProof { .. } => { + error!(error = %err, "bucket ownership store error"); + Status::internal(err.to_string()) + } + } +} + +fn map_grant(err: GrantError) -> Status { + if err.is_access_key_immutable() { + return Status::failed_precondition(err.to_string()); + } + if err.is_conflict() { + return Status::already_exists(err.to_string()); + } + match err { + GrantError::MissingExternalPolicy { .. } => Status::failed_precondition(err.to_string()), + GrantError::Credentials { ref source } => { + error!(error = %source, "credential store error"); + Status::internal(err.to_string()) + } + GrantError::Ownership { ref source } => { + error!(error = %source, "ownership store error"); + Status::internal(err.to_string()) + } + GrantError::Admin { ref msg } => { + error!(error = %msg, "rustfs admin error"); + Status::internal(err.to_string()) + } + other => Status::internal(other.to_string()), + } +} + +#[tonic::async_trait] +impl Identity for Driver { + async fn driver_get_info( + &self, + _request: Request, + ) -> Result, Status> { + Ok(Response::new(DriverGetInfoResponse { + name: DRIVER_NAME.to_string(), + })) + } +} + +#[tonic::async_trait] +impl Provisioner for Driver { + async fn driver_create_bucket( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + if req.name.trim().is_empty() { + return Err(Status::invalid_argument("bucket name is required")); + } + let params = BackendParameters::from_map(&req.parameters).map_err(map_params)?; + let client = admin_client_from_params(&self.kube, ¶ms) + .await + .map_err(map_backend)?; + + let outcome = bucket::create_bucket(&self.kube, &client, ¶ms, &req.name) + .await + .map_err(map_bucket)?; + + Ok(Response::new(DriverCreateBucketResponse { + bucket_id: outcome.bucket_id, + bucket_info: Some(Protocol { + r#type: Some(crate::proto::cosi::v1alpha1::protocol::Type::S3(S3 { + region: outcome.region, + signature_version: S3SignatureVersion::S3v4 as i32, + })), + }), + })) + } + + async fn driver_delete_bucket( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + if req.bucket_id.trim().is_empty() { + return Err(Status::invalid_argument("bucket_id is required")); + } + let params = BackendParameters::from_map(&req.delete_context).map_err(map_params)?; + let client = admin_client_from_params(&self.kube, ¶ms) + .await + .map_err(map_backend)?; + + bucket::delete_bucket(&self.kube, &client, ¶ms, &req.bucket_id) + .await + .map_err(map_bucket)?; + Ok(Response::new(DriverDeleteBucketResponse {})) + } + + async fn driver_grant_bucket_access( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + if req.bucket_id.trim().is_empty() { + return Err(Status::invalid_argument("bucket_id is required")); + } + if req.name.trim().is_empty() { + return Err(Status::invalid_argument("account name is required")); + } + if req.authentication_type != AuthenticationType::Key as i32 + && req.authentication_type != AuthenticationType::UnknownAuthenticationType as i32 + { + return Err(Status::invalid_argument( + "only KEY authentication is supported", + )); + } + + let params = BackendParameters::from_map(&req.parameters).map_err(map_params)?; + let client = admin_client_from_params(&self.kube, ¶ms) + .await + .map_err(map_backend)?; + + let result = + grant::grant_bucket_access(&self.kube, &client, ¶ms, &req.name, &req.bucket_id) + .await + .map_err(map_grant)?; + + let mut credentials = HashMap::new(); + credentials.insert( + "s3".to_string(), + CredentialDetails { + secrets: result.secrets, + }, + ); + + Ok(Response::new(DriverGrantBucketAccessResponse { + account_id: result.account_id, + credentials, + })) + } + + async fn driver_revoke_bucket_access( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + if req.bucket_id.trim().is_empty() { + return Err(Status::invalid_argument("bucket_id is required")); + } + if req.account_id.trim().is_empty() { + return Err(Status::invalid_argument("account_id is required")); + } + + let params = BackendParameters::from_map(&req.revoke_access_context).map_err(map_params)?; + let client = admin_client_from_params(&self.kube, ¶ms) + .await + .map_err(map_backend)?; + + grant::revoke_bucket_access(&self.kube, &client, ¶ms, &req.account_id) + .await + .map_err(map_grant)?; + Ok(Response::new(DriverRevokeBucketAccessResponse {})) + } +} diff --git a/crates/cosi-driver/src/grant.rs b/crates/cosi-driver/src/grant.rs new file mode 100644 index 00000000..fede4d3d --- /dev/null +++ b/crates/cosi-driver/src/grant.rs @@ -0,0 +1,392 @@ +// Copyright 2025 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Grant provisioning state machine (durable secrets + atomic CAS ownership). + +use std::collections::HashMap; + +use kube::Client; +use rustfs_admin::RustfsAdminClient; +use snafu::Snafu; +use tracing::info; + +use crate::credentials::{ + CredentialStoreError, access_key_hash, credential_secret_name, load_or_create_credentials, +}; +use crate::ownership::{OwnershipError, OwnershipStore}; +use crate::parameters::{self, BackendParameters, bucket_policy_document_for, grant_policy_name}; + +#[derive(Debug, Snafu)] +pub enum GrantError { + #[snafu(transparent)] + Ownership { source: OwnershipError }, + #[snafu(transparent)] + Credentials { source: CredentialStoreError }, + #[snafu(display("rustfs admin error: {msg}"))] + Admin { msg: String }, + #[snafu(display( + "access key `{account_id}` exists in RustFS without a matching ownership proof for \ + grant `{grant_name}`; refusing to adopt a user this driver did not create" + ))] + OrphanUserConflict { + account_id: String, + grant_name: String, + }, + #[snafu(display("external policy `{policy}` does not exist"))] + MissingExternalPolicy { policy: String }, +} + +impl GrantError { + /// Conflicts that a COSI retry should see as `ALREADY_EXISTS`. + pub fn is_conflict(&self) -> bool { + matches!( + self, + Self::OrphanUserConflict { .. } + | Self::Ownership { + source: OwnershipError::AccountConflict { .. } + } + | Self::Ownership { + source: OwnershipError::AccountRetired { .. } + } + ) + } + + /// A grant retried with a different `preferredAccessKey` than its first + /// successful claim — a client bug, not a transient race. + pub fn is_access_key_immutable(&self) -> bool { + matches!( + self, + Self::Ownership { + source: OwnershipError::AccessKeyImmutable { .. } + } + ) + } +} + +fn admin_err(err: rustfs_admin::RustfsClientError) -> GrantError { + GrantError::Admin { + msg: err.to_string(), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RotationAction { + /// No RustFS user exists yet under this access key. + AddUser, + /// A RustFS user exists but the credential Secret was just (re)created — + /// the driver no longer knows the password RustFS has, so it must be + /// reset atomically (RustFS's `add-user` is an upsert). + RotatePassword, + /// A RustFS user exists and the credential Secret's password is the one + /// already known to be in effect — only (re)attach policies. + AttachPoliciesOnly, +} + +/// Decide whether `add_user` must be (re)issued. Pure so it can be +/// exhaustively table-tested without a live RustFS/kube client. +pub fn resolve_password_rotation( + user_exists: bool, + freshly_created_secret: bool, +) -> RotationAction { + match (user_exists, freshly_created_secret) { + (false, _) => RotationAction::AddUser, + (true, true) => RotationAction::RotatePassword, + (true, false) => RotationAction::AttachPoliciesOnly, + } +} + +#[derive(Debug, Clone)] +pub struct GrantResult { + pub account_id: String, + #[allow(dead_code)] + pub secret_key: String, + pub secrets: HashMap, +} + +pub fn state_namespace(kube: &Client) -> String { + std::env::var("POD_NAMESPACE") + .or_else(|_| std::env::var("COSI_STATE_NAMESPACE")) + .unwrap_or_else(|_| kube.default_namespace().to_string()) +} + +fn credential_map( + access_key: &str, + secret_key: &str, + params: &BackendParameters, + policy_buckets: &[String], +) -> HashMap { + let mut secrets = HashMap::new(); + secrets.insert("accessKeyID".to_string(), access_key.to_string()); + secrets.insert("accessSecretKey".to_string(), secret_key.to_string()); + secrets.insert("AWS_ACCESS_KEY_ID".to_string(), access_key.to_string()); + secrets.insert("AWS_SECRET_ACCESS_KEY".to_string(), secret_key.to_string()); + secrets.insert("accesskey".to_string(), access_key.to_string()); + secrets.insert("secretkey".to_string(), secret_key.to_string()); + secrets.insert("endpoint".to_string(), params.endpoint.clone()); + secrets.insert("region".to_string(), params.region.clone()); + secrets.insert( + "BUCKETS".to_string(), + params + .buckets + .clone() + .unwrap_or_else(|| policy_buckets.join(",")), + ); + secrets +} + +/// Resolve the policy name attached to this grant. +/// +/// - Generated policies are unique per grant (`cosi-pol-{grant}`). +/// - External BAC `policy` names are referenced only (never replaced). +pub fn resolve_policy_name(params: &BackendParameters, grant_name: &str) -> (String, bool) { + match params.policy.as_deref() { + Some(external) => (external.to_string(), true), + None => (grant_policy_name(grant_name), false), + } +} + +/// Whether the driver should call `add_canned_policy` for this grant. +/// +/// External BAC policy names are validate-only — never overwritten. +pub fn should_write_canned_policy(external_policy: bool) -> bool { + !external_policy +} + +async fn attach_policies( + client: &RustfsAdminClient, + access_key: &str, + grant_name: &str, + params: &BackendParameters, + policy_buckets: &[String], +) -> Result<(), GrantError> { + let (policy_name, external) = resolve_policy_name(params, grant_name); + if external { + // Validate-only: never add_canned_policy / replace. + client + .get_canned_policy(&policy_name) + .await + .map_err(|err| { + if err.is_not_found() { + GrantError::MissingExternalPolicy { + policy: policy_name.clone(), + } + } else { + admin_err(err) + } + })?; + } else { + debug_assert!(should_write_canned_policy(false)); + let doc = bucket_policy_document_for(policy_buckets); + client + .add_canned_policy(&policy_name, &doc) + .await + .map_err(admin_err)?; + } + + client + .set_user_policy(access_key, &[policy_name]) + .await + .map_err(admin_err)?; + Ok(()) +} + +/// Provision or resume a grant with durable credentials and atomic CAS +/// ownership. +/// +/// Flow: claim `{backend_id, account_id}` (atomic, race-free even under +/// retries) → durable Secret (create-once) → add_user/rotate + policies → +/// Ready. Orphan RustFS users (no matching claim) are not adopted. +pub async fn grant_bucket_access( + kube: &Client, + client: &RustfsAdminClient, + params: &BackendParameters, + grant_name: &str, + bucket_id: &str, +) -> Result { + let namespace = state_namespace(kube); + let backend_id = parameters::backend_id(params); + let access_key = params + .preferred_access_key + .clone() + .unwrap_or_else(|| grant_name.to_string()); + let policy_buckets = params.buckets_for_policy(bucket_id); + let ak_hash = access_key_hash(&access_key); + let cred_name = credential_secret_name(grant_name); + let store = OwnershipStore::new(kube.clone(), namespace.clone()); + + let (claim, freshly_claimed) = store + .claim_account(&backend_id, grant_name, &access_key, &ak_hash, &cred_name) + .await?; + + let user_info = client.get_user_info(&access_key).await.map_err(admin_err)?; + if freshly_claimed && user_info.is_some() { + // Nobody (including this driver, on a previous attempt) had ever + // claimed this account before now, yet RustFS already has a user + // under this access key — it was created outside this driver's + // tracking. Refuse to silently take it over. + return Err(GrantError::OrphanUserConflict { + account_id: access_key, + grant_name: grant_name.to_string(), + }); + } + + let creds = load_or_create_credentials(kube, &namespace, grant_name, &claim.account_id).await?; + + info!( + grant = %grant_name, + account = %creds.access_key, + state = ?claim.state, + buckets = %policy_buckets.join(","), + "granting bucket access" + ); + + match resolve_password_rotation(user_info.is_some(), creds.freshly_created) { + RotationAction::AddUser | RotationAction::RotatePassword => { + client + .add_user(&creds.access_key, &creds.secret_key) + .await + .map_err(admin_err)?; + } + RotationAction::AttachPoliciesOnly => {} + } + attach_policies( + client, + &creds.access_key, + grant_name, + params, + &policy_buckets, + ) + .await?; + + store + .mark_ready(&backend_id, grant_name, &claim.account_id) + .await?; + + Ok(GrantResult { + account_id: creds.access_key.clone(), + secret_key: creds.secret_key.clone(), + secrets: credential_map( + &creds.access_key, + &creds.secret_key, + params, + &policy_buckets, + ), + }) +} + +pub async fn revoke_bucket_access( + kube: &Client, + client: &RustfsAdminClient, + params: &BackendParameters, + account_id: &str, +) -> Result<(), GrantError> { + let namespace = state_namespace(kube); + let backend_id = parameters::backend_id(params); + let store = OwnershipStore::new(kube.clone(), namespace); + + store.revoke(&backend_id, account_id).await?; + + client.remove_user(account_id).await.map_err(admin_err)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + RotationAction, resolve_password_rotation, resolve_policy_name, should_write_canned_policy, + }; + use crate::parameters::BackendParameters; + use std::collections::HashMap; + + fn params_with_policy(policy: Option<&str>) -> BackendParameters { + let mut map = HashMap::new(); + map.insert("endpoint".into(), "http://rustfs".into()); + map.insert("objectStoreUserSecretName".into(), "s".into()); + map.insert("objectStoreUserSecretNamespace".into(), "ns".into()); + if let Some(p) = policy { + map.insert("policy".into(), p.into()); + } + BackendParameters::from_map(&map).unwrap() + } + + #[test] + fn generated_policy_is_unique_per_grant() { + let params = params_with_policy(None); + let (name, external) = resolve_policy_name(¶ms, "ba-1"); + assert!(!external); + assert_eq!(name, "cosi-pol-ba-1"); + let (other, _) = resolve_policy_name(¶ms, "ba-2"); + assert_ne!(name, other); + } + + #[test] + fn external_policy_is_reference_only() { + let params = params_with_policy(Some("shared-readonly")); + let (name, external) = resolve_policy_name(¶ms, "ba-1"); + assert!(external); + assert_eq!(name, "shared-readonly"); + } + + #[test] + fn external_policy_is_never_written() { + let params = params_with_policy(Some("shared-readonly")); + let (_, external) = resolve_policy_name(¶ms, "ba-1"); + assert!(!should_write_canned_policy(external)); + assert!(should_write_canned_policy(false)); + } + + #[test] + fn retry_reuses_stable_credential_secret_name() { + use crate::credentials::credential_secret_name; + assert_eq!( + credential_secret_name("ba-1"), + credential_secret_name("ba-1") + ); + assert_ne!( + credential_secret_name("ba-1"), + credential_secret_name("ba-2") + ); + } + + /// Regression test: retry when the internal Secret is missing but the + /// RustFS user still exists must rotate the password, never silently + /// return credentials RustFS never learned. + #[test] + fn missing_secret_with_existing_user_forces_password_rotation() { + assert_eq!( + resolve_password_rotation(true, true), + RotationAction::RotatePassword + ); + } + + #[test] + fn password_rotation_matrix() { + assert_eq!( + resolve_password_rotation(false, false), + RotationAction::AddUser + ); + assert_eq!( + resolve_password_rotation(false, true), + RotationAction::AddUser + ); + assert_eq!( + resolve_password_rotation(true, false), + RotationAction::AttachPoliciesOnly + ); + assert_eq!( + resolve_password_rotation(true, true), + RotationAction::RotatePassword + ); + } +} diff --git a/crates/cosi-driver/src/main.rs b/crates/cosi-driver/src/main.rs new file mode 100644 index 00000000..bfe6bf4d --- /dev/null +++ b/crates/cosi-driver/src/main.rs @@ -0,0 +1,116 @@ +// Copyright 2025 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! RustFS COSI driver — serves Identity + Provisioner on a Unix socket. + +mod backend; +mod bucket; +mod credentials; +mod driver; +mod grant; +mod ownership; +mod parameters; + +pub mod proto { + pub mod cosi { + pub mod v1alpha1 { + tonic::include_proto!("cosi.v1alpha1"); + } + } +} + +use std::path::PathBuf; + +use kube::Client; +use tokio::net::UnixListener; +use tokio_stream::wrappers::UnixListenerStream; +use tracing::{error, info}; + +use crate::driver::Driver; +use crate::parameters::DRIVER_NAME; +use crate::proto::cosi::v1alpha1::{ + identity_server::IdentityServer, provisioner_server::ProvisionerServer, +}; + +fn parse_unix_endpoint(raw: &str) -> Result { + let trimmed = raw.trim(); + let path = trimmed + .strip_prefix("unix://") + .ok_or_else(|| format!("unsupported COSI_ENDPOINT `{trimmed}`"))?; + if path.is_empty() { + return Err("COSI_ENDPOINT unix path is empty".to_string()); + } + Ok(PathBuf::from(path)) +} + +#[tokio::main] +async fn main() { + // Required for rustls 0.23 when multiple crypto backends may be linked via deps. + let _ = rustls::crypto::ring::default_provider().install_default(); + + let _ = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .with_target(true) + .try_init(); + + let endpoint = std::env::var("COSI_ENDPOINT") + .unwrap_or_else(|_| "unix:///var/lib/cosi/cosi.sock".to_string()); + let sock_path = match parse_unix_endpoint(&endpoint) { + Ok(path) => path, + Err(err) => { + error!(error = %err, "invalid COSI_ENDPOINT"); + std::process::exit(2); + } + }; + + if let Some(parent) = sock_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::remove_file(&sock_path); + + let kube = match Client::try_default().await { + Ok(client) => client, + Err(err) => { + error!(error = %err, "failed to create Kubernetes client"); + std::process::exit(1); + } + }; + + let listener = match UnixListener::bind(&sock_path) { + Ok(listener) => listener, + Err(err) => { + error!(error = %err, path = %sock_path.display(), "failed to bind COSI socket"); + std::process::exit(1); + } + }; + let incoming = UnixListenerStream::new(listener); + let driver = Driver::new(kube); + + info!( + driver = DRIVER_NAME, + endpoint = %endpoint, + "starting RustFS COSI driver" + ); + + let result = tonic::transport::Server::builder() + .add_service(IdentityServer::new(driver.clone())) + .add_service(ProvisionerServer::new(driver)) + .serve_with_incoming(incoming) + .await; + + if let Err(err) = result { + error!(error = %err, "RustFS COSI driver stopped"); + std::process::exit(1); + } +} diff --git a/crates/cosi-driver/src/ownership.rs b/crates/cosi-driver/src/ownership.rs new file mode 100644 index 00000000..21be09d7 --- /dev/null +++ b/crates/cosi-driver/src/ownership.rs @@ -0,0 +1,551 @@ +// Copyright 2025 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Crash-safe, atomic grant ownership (PendingCreate → Ready → Revoked). +//! +//! Every `{backend_id, account_id}` claim is its own Kubernetes ConfigMap, +//! created with `Api::create()` — a single atomic operation on the object's +//! name, not a read-then-write race on a shared blob. Existence of the +//! object under its deterministic name *is* the uniqueness proof, so the +//! invariant is checked on every retry (including CAS 409s), not just once +//! up front. A companion per-grant "grant record" ConfigMap lets +//! `grant_bucket_access` resume the same COSI request idempotently without +//! needing `account_id` as an input. +//! +//! Revoked claims are tombstoned, never deleted: once `{backend_id, +//! account_id}` has been revoked it can never be claimed again. This makes a +//! delayed/retried revoke for an old grant provably unable to affect a newer +//! grant that reused the same preferred access key, because the newer grant +//! could never have acquired the key in the first place. + +use k8s_openapi::api::core::v1::ConfigMap; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; +use kube::Client; +use kube::api::{Api, PostParams}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use snafu::{ResultExt, Snafu}; +use tracing::info; + +const PROOF_KEY: &str = "proof"; +const CAS_RETRIES: usize = 8; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ClaimState { + PendingCreate, + Ready, + Revoked, +} + +/// Atomic `{backend_id, account_id}` uniqueness claim. Its existence under +/// [`account_claim_name`] *is* the ownership proof. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AccountClaim { + pub grant_name: String, + pub backend_id: String, + pub account_id: String, + pub access_key_hash: String, + pub cred_secret_name: String, + pub state: ClaimState, +} + +/// `grant_name -> account_id` pointer, enabling idempotent resume of a COSI +/// `DriverGrantBucketAccess` retry without trusting the caller-supplied +/// `preferredAccessKey` parameter on that retry. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GrantRecord { + pub grant_name: String, + pub backend_id: String, + pub account_id: String, + pub cred_secret_name: String, + pub state: ClaimState, +} + +#[derive(Debug, Snafu)] +pub enum OwnershipError { + #[snafu(display("kubernetes error: {source}"))] + Kube { source: kube::Error }, + #[snafu(display("invalid ownership proof for grant `{grant}`: {detail}"))] + InvalidProof { grant: String, detail: String }, + #[snafu(display( + "ownership conflict: access key `{account_id}` is claimed by grant `{owner}`, \ + not `{requester}`" + ))] + AccountConflict { + account_id: String, + owner: String, + requester: String, + }, + #[snafu(display( + "access key `{account_id}` was previously revoked on backend `{backend_id}` and can \ + never be reused; choose a different preferredAccessKey" + ))] + AccountRetired { + account_id: String, + backend_id: String, + }, + #[snafu(display( + "grant `{grant_name}` is already bound to access key `{existing}`; access keys are \ + immutable once claimed (requested `{requested}`)" + ))] + AccessKeyImmutable { + grant_name: String, + existing: String, + requested: String, + }, + #[snafu(display("CAS conflict writing ownership object `{name}` (retry)"))] + CasConflict { name: String }, +} + +fn account_claim_name(backend_id: &str, account_id: &str) -> String { + let digest = hex::encode(Sha256::digest(account_id.as_bytes())); + format!("cosi-acct-{backend_id}-{}", &digest[..24]) +} + +fn grant_record_name(grant_name: &str) -> String { + format!( + "cosi-grant-{}", + crate::parameters::sanitize_policy_fragment(grant_name) + ) +} + +#[derive(Clone)] +pub struct OwnershipStore { + api: Api, +} + +impl OwnershipStore { + pub fn new(client: Client, namespace: String) -> Self { + Self { + api: Api::namespaced(client, &namespace), + } + } + + async fn get_object( + &self, + name: &str, + ) -> Result, OwnershipError> { + let cm = match self.api.get(name).await { + Ok(cm) => cm, + Err(kube::Error::Api(err)) if err.code == 404 => return Ok(None), + Err(source) => return Err(source).context(KubeSnafu), + }; + let raw = cm + .data + .as_ref() + .and_then(|data| data.get(PROOF_KEY)) + .ok_or_else(|| OwnershipError::InvalidProof { + grant: name.to_string(), + detail: "missing proof data".into(), + })?; + let value: T = serde_json::from_str(raw).map_err(|err| OwnershipError::InvalidProof { + grant: name.to_string(), + detail: err.to_string(), + })?; + Ok(Some(value)) + } + + fn build_cm(name: &str, value: &T) -> Result { + let raw = serde_json::to_string(value).map_err(|err| OwnershipError::InvalidProof { + grant: name.to_string(), + detail: err.to_string(), + })?; + Ok(ConfigMap { + metadata: ObjectMeta { + name: Some(name.to_string()), + labels: Some( + [( + "app.kubernetes.io/name".to_string(), + "rustfs-cosi-driver".to_string(), + )] + .into_iter() + .collect(), + ), + ..Default::default() + }, + data: Some([(PROOF_KEY.to_string(), raw)].into_iter().collect()), + ..Default::default() + }) + } + + /// Atomically create `name` holding `value`. Returns `Ok(true)` if this + /// call won the create race, `Ok(false)` if the object already existed + /// (caller must `get_object` to see who won). + async fn try_create( + &self, + name: &str, + value: &T, + ) -> Result { + let cm = Self::build_cm(name, value)?; + match self.api.create(&PostParams::default(), &cm).await { + Ok(_) => Ok(true), + Err(kube::Error::Api(err)) if err.code == 409 => Ok(false), + Err(source) => Err(source).context(KubeSnafu), + } + } + + /// Replace `name`'s contents with `value`, retrying on resourceVersion + /// conflicts. `name` must already exist. + async fn replace_object( + &self, + name: &str, + value: &T, + ) -> Result<(), OwnershipError> { + let raw = serde_json::to_string(value).map_err(|err| OwnershipError::InvalidProof { + grant: name.to_string(), + detail: err.to_string(), + })?; + + for _ in 0..CAS_RETRIES { + let mut cm = self.api.get(name).await.context(KubeSnafu)?; + let mut data = cm.data.take().unwrap_or_default(); + data.insert(PROOF_KEY.to_string(), raw.clone()); + cm.data = Some(data); + match self.api.replace(name, &PostParams::default(), &cm).await { + Ok(_) => return Ok(()), + Err(kube::Error::Api(err)) if err.code == 409 => continue, + Err(source) => return Err(source).context(KubeSnafu), + } + } + Err(OwnershipError::CasConflict { + name: name.to_string(), + }) + } + + pub async fn get_grant_record( + &self, + grant_name: &str, + ) -> Result, OwnershipError> { + self.get_object(&grant_record_name(grant_name)).await + } + + async fn get_account_claim( + &self, + backend_id: &str, + account_id: &str, + ) -> Result, OwnershipError> { + self.get_object(&account_claim_name(backend_id, account_id)) + .await + } + + /// Atomically claim `{backend_id, account_id}` for `grant_name`, or + /// resume an in-flight/completed claim for the same grant. Conflicts + /// (a different grant owns the key, or the key was revoked) are + /// evaluated on every call, including CAS retries, since the claim + /// object's existence under a deterministic name is itself the + /// uniqueness check. + /// + /// `freshly_claimed` is true only when this call is the very first ever + /// to claim `{backend_id, account_id}` for `grant_name` — i.e. neither a + /// resumed grant record nor an adopted claim from a prior crashed + /// attempt of this same grant. Callers use this to detect a RustFS user + /// that already exists under the access key without this driver ever + /// having claimed it (an orphan/foreign user). + pub async fn claim_account( + &self, + backend_id: &str, + grant_name: &str, + account_id: &str, + access_key_hash: &str, + cred_secret_name: &str, + ) -> Result<(AccountClaim, bool), OwnershipError> { + if let Some(record) = self.get_grant_record(grant_name).await? { + logic::check_access_key_immutable(grant_name, account_id, &record)?; + let claim = self + .get_account_claim(backend_id, account_id) + .await? + .ok_or_else(|| OwnershipError::InvalidProof { + grant: grant_name.to_string(), + detail: "grant record exists but account claim is missing".into(), + })?; + logic::check_resumable(account_id, backend_id, &claim)?; + return Ok((claim, false)); + } + + let claim_name = account_claim_name(backend_id, account_id); + let fresh = AccountClaim { + grant_name: grant_name.to_string(), + backend_id: backend_id.to_string(), + account_id: account_id.to_string(), + access_key_hash: access_key_hash.to_string(), + cred_secret_name: cred_secret_name.to_string(), + state: ClaimState::PendingCreate, + }; + + let (claim, freshly_claimed) = if self.try_create(&claim_name, &fresh).await? { + info!(grant = %grant_name, account = %account_id, "claimed account ownership"); + (fresh, true) + } else { + let existing = self + .get_account_claim(backend_id, account_id) + .await? + .ok_or_else(|| OwnershipError::InvalidProof { + grant: grant_name.to_string(), + detail: "account claim create conflicted but object is now missing".into(), + })?; + // Crash resume: we created this claim on a previous attempt but + // died before the grant record was written below. + logic::resolve_claim_conflict(grant_name, account_id, backend_id, &existing)?; + (existing, false) + }; + + let record = GrantRecord { + grant_name: grant_name.to_string(), + backend_id: backend_id.to_string(), + account_id: account_id.to_string(), + cred_secret_name: cred_secret_name.to_string(), + state: claim.state, + }; + if !self + .try_create(&grant_record_name(grant_name), &record) + .await? + { + let existing = self.get_grant_record(grant_name).await?.ok_or_else(|| { + OwnershipError::InvalidProof { + grant: grant_name.to_string(), + detail: "grant record create conflicted but object is now missing".into(), + } + })?; + if existing.account_id != account_id { + return Err(OwnershipError::InvalidProof { + grant: grant_name.to_string(), + detail: "grant record race resolved to a different account_id".into(), + }); + } + } + + Ok((claim, freshly_claimed)) + } + + pub async fn mark_ready( + &self, + backend_id: &str, + grant_name: &str, + account_id: &str, + ) -> Result<(), OwnershipError> { + let claim_name = account_claim_name(backend_id, account_id); + if let Some(mut claim) = self.get_account_claim(backend_id, account_id).await? { + if claim.state != ClaimState::Ready { + claim.state = ClaimState::Ready; + self.replace_object(&claim_name, &claim).await?; + } + } else { + return Err(OwnershipError::InvalidProof { + grant: grant_name.to_string(), + detail: "missing account claim when promoting to Ready".into(), + }); + } + + let record_name = grant_record_name(grant_name); + if let Some(mut record) = self.get_grant_record(grant_name).await? + && record.state != ClaimState::Ready + { + record.state = ClaimState::Ready; + self.replace_object(&record_name, &record).await?; + } + info!(grant = %grant_name, account = %account_id, "promoted grant ownership to Ready"); + Ok(()) + } + + /// Tombstone the claim for `{backend_id, account_id}` if present. A + /// claim already in `Revoked` state is left untouched (idempotent no-op + /// for a delayed/duplicate revoke). Returns the grant_name that owned + /// the claim, if any. + pub async fn revoke( + &self, + backend_id: &str, + account_id: &str, + ) -> Result, OwnershipError> { + let claim_name = account_claim_name(backend_id, account_id); + let Some(mut claim) = self.get_account_claim(backend_id, account_id).await? else { + return Ok(None); + }; + if claim.state == ClaimState::Revoked { + return Ok(Some(claim.grant_name)); + } + + let grant_name = claim.grant_name.clone(); + claim.state = ClaimState::Revoked; + self.replace_object(&claim_name, &claim).await?; + info!(grant = %grant_name, account = %account_id, "revoked (tombstoned) account claim"); + Ok(Some(grant_name)) + } +} + +/// Pure decision logic for unit tests (no kube client). +pub mod logic { + use super::{AccountClaim, ClaimState, GrantRecord, OwnershipError}; + + /// A grant's access key is fixed at first successful claim; a retry + /// carrying a different `preferredAccessKey` is a hard conflict rather + /// than a silent rebind. + pub fn check_access_key_immutable( + grant_name: &str, + requested_account_id: &str, + record: &GrantRecord, + ) -> Result<(), OwnershipError> { + if record.account_id != requested_account_id { + return Err(OwnershipError::AccessKeyImmutable { + grant_name: grant_name.to_string(), + existing: record.account_id.clone(), + requested: requested_account_id.to_string(), + }); + } + Ok(()) + } + + /// A grant resuming its own claim must not resurrect one that was + /// already revoked (e.g. a stale retry arriving after `revoke_bucket_access` + /// tore this grant's access down). + pub fn check_resumable( + account_id: &str, + backend_id: &str, + claim: &AccountClaim, + ) -> Result<(), OwnershipError> { + if claim.state == ClaimState::Revoked { + return Err(OwnershipError::AccountRetired { + account_id: account_id.to_string(), + backend_id: backend_id.to_string(), + }); + } + Ok(()) + } + + /// Decide whether the caller may adopt/resume an `AccountClaim` object + /// found after losing an atomic-create race for the same name. This is + /// evaluated on *every* claim attempt (including CAS retries), which is + /// what makes the uniqueness invariant race-free: two concurrent claims + /// for the same `account_id` can never both pass. + pub fn resolve_claim_conflict( + grant_name: &str, + account_id: &str, + backend_id: &str, + existing: &AccountClaim, + ) -> Result<(), OwnershipError> { + if existing.grant_name != grant_name { + return Err(OwnershipError::AccountConflict { + account_id: account_id.to_string(), + owner: existing.grant_name.clone(), + requester: grant_name.to_string(), + }); + } + if existing.state == ClaimState::Revoked { + return Err(OwnershipError::AccountRetired { + account_id: account_id.to_string(), + backend_id: backend_id.to_string(), + }); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn claim(grant_name: &str, state: ClaimState) -> AccountClaim { + AccountClaim { + grant_name: grant_name.into(), + backend_id: "be-abc".into(), + account_id: "mlflow".into(), + access_key_hash: "h".into(), + cred_secret_name: "cosi-cred-ba-1".into(), + state, + } + } + + #[test] + fn two_grants_racing_for_the_same_account_id_conflict() { + // ba-1 wins the create race; ba-2's retry must see the conflict, + // regardless of who asked first. + let winner = claim("ba-1", ClaimState::PendingCreate); + let err = logic::resolve_claim_conflict("ba-2", "mlflow", "be-abc", &winner).unwrap_err(); + assert!(matches!(err, OwnershipError::AccountConflict { .. })); + } + + #[test] + fn same_grant_may_adopt_its_own_pending_claim() { + let mine = claim("ba-1", ClaimState::PendingCreate); + assert!(logic::resolve_claim_conflict("ba-1", "mlflow", "be-abc", &mine).is_ok()); + } + + #[test] + fn revoked_account_id_can_never_be_reclaimed() { + // Simulates: A claimed+revoked "mlflow"; B now races to claim the + // same key and loses to A's tombstone — reported as an ownership + // conflict (a different grant's name is on the tombstoned claim), + // which is just as final as `AccountRetired`: B can never win it. + let tombstone = claim("ba-1", ClaimState::Revoked); + let err = + logic::resolve_claim_conflict("ba-2", "mlflow", "be-abc", &tombstone).unwrap_err(); + assert!(matches!(err, OwnershipError::AccountConflict { .. })); + } + + #[test] + fn resuming_a_revoked_grant_is_rejected() { + // A delayed retry of a grant request must not resurrect access that + // was already revoked by a prior DriverRevokeBucketAccess call. + let tombstone = claim("ba-1", ClaimState::Revoked); + let err = logic::check_resumable("mlflow", "be-abc", &tombstone).unwrap_err(); + assert!(matches!(err, OwnershipError::AccountRetired { .. })); + } + + #[test] + fn changing_preferred_access_key_on_retry_is_rejected() { + let record = GrantRecord { + grant_name: "ba-1".into(), + backend_id: "be-abc".into(), + account_id: "mlflow".into(), + cred_secret_name: "cosi-cred-ba-1".into(), + state: ClaimState::Ready, + }; + assert!(logic::check_access_key_immutable("ba-1", "mlflow", &record).is_ok()); + let err = logic::check_access_key_immutable("ba-1", "other", &record).unwrap_err(); + assert!(matches!(err, OwnershipError::AccessKeyImmutable { .. })); + } + + #[test] + fn claim_roundtrip() { + let claim = AccountClaim { + grant_name: "ba-1".into(), + backend_id: "be-abc".into(), + account_id: "mlflow".into(), + access_key_hash: "abc".into(), + cred_secret_name: "cosi-cred-ba-1".into(), + state: ClaimState::PendingCreate, + }; + let raw = serde_json::to_string(&claim).unwrap(); + let back: AccountClaim = serde_json::from_str(&raw).unwrap(); + assert_eq!(claim, back); + } + + #[test] + fn claim_names_are_deterministic_and_backend_scoped() { + let a = account_claim_name("be-1", "mlflow"); + let b = account_claim_name("be-1", "mlflow"); + let c = account_claim_name("be-2", "mlflow"); + assert_eq!(a, b); + assert_ne!(a, c); + } + + #[test] + fn grant_record_names_are_stable() { + assert_eq!(grant_record_name("ba-1"), grant_record_name("ba-1")); + assert_ne!(grant_record_name("ba-1"), grant_record_name("ba-2")); + } +} diff --git a/crates/cosi-driver/src/parameters.rs b/crates/cosi-driver/src/parameters.rs new file mode 100644 index 00000000..17bd4f5a --- /dev/null +++ b/crates/cosi-driver/src/parameters.rs @@ -0,0 +1,231 @@ +// Copyright 2025 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! RustFS COSI driver parameters (BucketClass / BucketAccessClass). + +use std::collections::HashMap; + +use sha2::{Digest, Sha256}; +use snafu::Snafu; + +pub const DRIVER_NAME: &str = "rustfs.objectstorage.k8s.io"; + +#[derive(Debug, Clone)] +pub struct BackendParameters { + pub endpoint: String, + pub object_store_user_secret_name: String, + pub object_store_user_secret_namespace: String, + pub region: String, + /// Optional existing canned policy name. Referenced only — never replaced. + pub policy: Option, + pub tls_ca_configmap_name: Option, + pub tls_ca_configmap_namespace: Option, + /// Static/adoption preview: preferred S3 bucket name (overrides COSI name). + /// + /// Buckets created via this override are not deleted by DriverDeleteBucket + /// without ownership proof (PR A safe default). + pub bucket_name: Option, + /// Static/adoption preview: comma-separated bucket list (`*` = full access). + pub buckets: Option, + /// Preferred S3 access-key / account name for GrantBucketAccess. + /// + /// Must be unique per BucketAccess. Reusing the same value across claims is + /// rejected (ownership conflict). Prefer omitting this so the COSI grant + /// name (`ba-`) is used as the account id. + pub preferred_access_key: Option, +} + +#[derive(Debug, Snafu)] +pub enum ParameterError { + #[snafu(display("missing required parameter `{key}`"))] + MissingRequired { key: &'static str }, + #[snafu(display("parameter `{key}` is empty"))] + Empty { key: &'static str }, + #[snafu(display( + "conflicting parameters: `bucketName` and `buckets` may not both be set; \ + use `buckets` alone (comma-separated) for multiple names" + ))] + ConflictingBucketParams, +} + +fn required(map: &HashMap, key: &'static str) -> Result { + let value = map + .get(key) + .cloned() + .ok_or(ParameterError::MissingRequired { key })?; + if value.trim().is_empty() { + return Err(ParameterError::Empty { key }); + } + Ok(value) +} + +fn optional(map: &HashMap, key: &str) -> Option { + map.get(key) + .cloned() + .filter(|value| !value.trim().is_empty()) +} + +impl BackendParameters { + pub fn from_map(map: &HashMap) -> Result { + let bucket_name = optional(map, "bucketName"); + let buckets = optional(map, "buckets"); + if bucket_name.is_some() && buckets.is_some() { + return Err(ParameterError::ConflictingBucketParams); + } + + Ok(Self { + endpoint: required(map, "endpoint")?, + object_store_user_secret_name: required(map, "objectStoreUserSecretName")?, + object_store_user_secret_namespace: required(map, "objectStoreUserSecretNamespace")?, + region: optional(map, "region").unwrap_or_else(|| "us-east-1".to_string()), + policy: optional(map, "policy"), + tls_ca_configmap_name: optional(map, "tlsCAConfigMapName"), + tls_ca_configmap_namespace: optional(map, "tlsCAConfigMapNamespace"), + bucket_name, + buckets, + preferred_access_key: optional(map, "preferredAccessKey") + .or_else(|| optional(map, "accessKey")), + }) + } + + /// Buckets to create (excludes `*`). Primary bucket_id is the first entry. + pub fn buckets_to_create(&self, fallback_name: &str) -> Vec { + let raw = self + .buckets + .as_deref() + .or(self.bucket_name.as_deref()) + .unwrap_or(fallback_name); + raw.split(',') + .map(str::trim) + .filter(|b| !b.is_empty() && *b != "*") + .map(ToOwned::to_owned) + .collect() + } + + /// Full bucket list for IAM policy (may include `*`). + pub fn buckets_for_policy(&self, fallback_name: &str) -> Vec { + let raw = self + .buckets + .as_deref() + .or(self.bucket_name.as_deref()) + .unwrap_or(fallback_name); + let list: Vec = raw + .split(',') + .map(str::trim) + .filter(|b| !b.is_empty()) + .map(ToOwned::to_owned) + .collect(); + if list.is_empty() { + vec![fallback_name.to_string()] + } else { + list + } + } + + pub fn primary_bucket_id(&self, cosi_name: &str) -> String { + self.bucket_name + .clone() + .or_else(|| self.buckets_to_create(cosi_name).into_iter().next()) + .unwrap_or_else(|| cosi_name.to_string()) + } +} + +/// Stable, DNS-1123-safe identifier for the RustFS endpoint a claim/proof +/// belongs to. Two BucketClasses/BucketAccessClasses pointing at different +/// endpoints must never collide on the same account_id/bucket_id. +pub fn backend_id(params: &BackendParameters) -> String { + let normalized = params + .endpoint + .trim() + .trim_end_matches('/') + .to_ascii_lowercase(); + let hash = Sha256::digest(normalized.as_bytes()); + format!("be-{}", hex::encode(&hash[..8])) +} + +pub fn bucket_policy_document_for(buckets: &[String]) -> String { + let has_wildcard = buckets.iter().any(|b| b == "*"); + let resources: Vec = if has_wildcard { + vec!["arn:aws:s3:::*".to_string(), "arn:aws:s3:::*/*".to_string()] + } else { + buckets + .iter() + .flat_map(|b| [format!("arn:aws:s3:::{b}"), format!("arn:aws:s3:::{b}/*")]) + .collect() + }; + serde_json::json!({ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Action": ["s3:*"], + "Resource": resources + }] + }) + .to_string() +} + +pub fn sanitize_policy_fragment(value: &str) -> String { + value + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect() +} + +/// Unique generated policy name per COSI grant (never shared across grants). +pub fn grant_policy_name(grant_name: &str) -> String { + format!("cosi-pol-{}", sanitize_policy_fragment(grant_name)) +} + +#[cfg(test)] +mod tests { + use super::{BackendParameters, ParameterError, backend_id, grant_policy_name}; + use std::collections::HashMap; + + #[test] + fn grant_policy_name_sanitizes() { + assert_eq!( + grant_policy_name("ba-81733d1a-ac7a-4759-96f3-fbcc07c0cee9"), + "cosi-pol-ba-81733d1a-ac7a-4759-96f3-fbcc07c0cee9" + ); + assert_eq!(grant_policy_name("ba/weird.name"), "cosi-pol-ba-weird-name"); + } + + fn base_map() -> HashMap { + let mut map = HashMap::new(); + map.insert("endpoint".into(), "http://rustfs".into()); + map.insert("objectStoreUserSecretName".into(), "s".into()); + map.insert("objectStoreUserSecretNamespace".into(), "ns".into()); + map + } + + #[test] + fn conflicting_bucket_params_are_rejected() { + let mut map = base_map(); + map.insert("bucketName".into(), "one".into()); + map.insert("buckets".into(), "two,three".into()); + let err = BackendParameters::from_map(&map).unwrap_err(); + assert!(matches!(err, ParameterError::ConflictingBucketParams)); + } + + #[test] + fn backend_id_is_stable_and_endpoint_specific() { + let a = BackendParameters::from_map(&base_map()).unwrap(); + let mut other_map = base_map(); + other_map.insert("endpoint".into(), "http://other-rustfs".into()); + let b = BackendParameters::from_map(&other_map).unwrap(); + + assert_eq!(backend_id(&a), backend_id(&a)); + assert_ne!(backend_id(&a), backend_id(&b)); + } +} diff --git a/crates/rustfs-admin/Cargo.toml b/crates/rustfs-admin/Cargo.toml new file mode 100644 index 00000000..7b276690 --- /dev/null +++ b/crates/rustfs-admin/Cargo.toml @@ -0,0 +1,30 @@ +# Copyright 2025 RustFS Team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +[package] +name = "rustfs-admin" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +publish = false + +[dependencies] +chrono = "0.4" +hex = "0.4" +hmac = "0.12" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.10" +url = "2.5" diff --git a/src/sts/admin_ops.rs b/crates/rustfs-admin/src/admin_ops.rs similarity index 65% rename from src/sts/admin_ops.rs rename to crates/rustfs-admin/src/admin_ops.rs index 593d1a57..70f371fc 100644 --- a/src/sts/admin_ops.rs +++ b/crates/rustfs-admin/src/admin_ops.rs @@ -23,12 +23,40 @@ use super::helpers::{ }; use super::{ ADD_CANNED_POLICY_PATH, ADD_USER_PATH, ADMIN_SIGNING_SERVICE, INFO_CANNED_POLICY_PATH, - JSON_CONTENT_TYPE, LIST_CANNED_POLICIES_PATH, RustfsAdminClient, RustfsClientError, - RustfsServerInfo, RustfsServerInfoResponse, SERVER_INFO_PATH, SET_POLICY_PATH, USER_INFO_PATH, + JSON_CONTENT_TYPE, LIST_CANNED_POLICIES_PATH, REMOVE_USER_PATH, RustfsAdminClient, + RustfsClientError, RustfsServerInfo, RustfsServerInfoResponse, RustfsUserInfo, + SERVER_INFO_PATH, SET_POLICY_PATH, USER_INFO_PATH, }; use reqwest::StatusCode; use serde_json::Value; +fn parse_user_info_policy_names(body: &Value) -> Vec { + let Some(field) = body + .get("policyName") + .or_else(|| body.get("policy_name")) + .or_else(|| body.get("PolicyName")) + else { + return Vec::new(); + }; + + match field { + Value::String(raw) => raw + .split(',') + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToOwned::to_owned) + .collect(), + Value::Array(items) => items + .iter() + .filter_map(|item| item.as_str()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(ToOwned::to_owned) + .collect(), + _ => Vec::new(), + } +} + impl RustfsAdminClient { // Admin duties: user and policy management APIs. // (Candidly scoped to tenant admin operations.) @@ -155,7 +183,11 @@ impl RustfsAdminClient { .map_err(|_| RustfsClientError::ParseResponseFailed) } - pub async fn user_exists(&self, access_key: &str) -> Result { + /// Fetch IAM user info. Returns `Ok(None)` when the user does not exist. + pub async fn get_user_info( + &self, + access_key: &str, + ) -> Result, RustfsClientError> { if access_key.trim().is_empty() { return Err(RustfsClientError::InvalidCredentialValue { key: "accesskey" }); } @@ -178,13 +210,27 @@ impl RustfsAdminClient { .map_err(|_| RustfsClientError::RequestFailed)?; if response.status().is_success() { - return Ok(true); + let body = response + .text() + .await + .map_err(|_| RustfsClientError::RequestFailed)?; + // Existence probes (and some test fixtures) return an empty 200 body. + // Treat any successful response as "user exists"; parse policies when present. + if body.trim().is_empty() { + return Ok(Some(RustfsUserInfo { + policy_names: Vec::new(), + })); + } + let parsed: Value = serde_json::from_str(&body).unwrap_or(Value::Null); + return Ok(Some(RustfsUserInfo { + policy_names: parse_user_info_policy_names(&parsed), + })); } let status = response.status(); let (body, truncated) = RustfsClientError::limited_response_body(response).await; if status == StatusCode::NOT_FOUND || body_mentions_not_found(&body) { - return Ok(false); + return Ok(None); } Err(RustfsClientError::unexpected_status_with_limited_body( @@ -192,6 +238,15 @@ impl RustfsAdminClient { )) } + pub async fn user_exists(&self, access_key: &str) -> Result { + Ok(self.get_user_info(access_key).await?.is_some()) + } + + /// Create a user, or reset an existing user's password (RustFS's + /// MinIO-admin-compatible `add-user` is an upsert: PUT-ing an existing + /// access key resets its secret key). Callers rely on this to rotate a + /// user's password atomically when the driver's local credential Secret + /// had to be regenerated. pub async fn add_user( &self, access_key: &str, @@ -239,4 +294,71 @@ impl RustfsAdminClient { .await .map(|_| ()) } + + /// Remove a RustFS user. Missing users are treated as success (idempotent). + pub async fn remove_user(&self, access_key: &str) -> Result<(), RustfsClientError> { + if access_key.trim().is_empty() { + return Err(RustfsClientError::InvalidCredentialValue { key: "accesskey" }); + } + + let query = build_canonical_query(&[("accessKey", access_key)]); + let path = REMOVE_USER_PATH; + let url = format!("{}{}?{query}", self.base_url.trim_end_matches('/'), path); + let signed = self.sign_request("DELETE", path, &query, "", None, ADMIN_SIGNING_SERVICE)?; + let host = self.host()?; + + let response = self + .http_client + .delete(url) + .header("x-amz-date", &signed.amz_date) + .header("x-amz-content-sha256", &signed.payload_hash) + .header("authorization", &signed.authorization) + .header("host", host) + .send() + .await + .map_err(|_| RustfsClientError::RequestFailed)?; + + if response.status().is_success() { + return Ok(()); + } + + let status = response.status(); + let (body, truncated) = RustfsClientError::limited_response_body(response).await; + if status == StatusCode::NOT_FOUND || body_mentions_not_found(&body) { + return Ok(()); + } + + Err(RustfsClientError::unexpected_status_with_limited_body( + status, &body, truncated, + )) + } +} + +#[cfg(test)] +mod parse_tests { + use super::parse_user_info_policy_names; + use serde_json::json; + + #[test] + fn parses_comma_separated_policy_name() { + let body = json!({"policyName":"cosi-mlflow,cosi-grant-ba-1"}); + assert_eq!( + parse_user_info_policy_names(&body), + vec!["cosi-mlflow".to_string(), "cosi-grant-ba-1".to_string()] + ); + } + + #[test] + fn parses_policy_name_array_and_snake_case() { + let body = json!({"policy_name":["a","b"]}); + assert_eq!( + parse_user_info_policy_names(&body), + vec!["a".to_string(), "b".to_string()] + ); + } + + #[test] + fn missing_policy_field_yields_empty() { + assert!(parse_user_info_policy_names(&json!({"status":"enabled"})).is_empty()); + } } diff --git a/src/sts/core_ops.rs b/crates/rustfs-admin/src/core_ops.rs similarity index 100% rename from src/sts/core_ops.rs rename to crates/rustfs-admin/src/core_ops.rs diff --git a/src/utils/sanitize.rs b/crates/rustfs-admin/src/helpers.rs similarity index 66% rename from src/utils/sanitize.rs rename to crates/rustfs-admin/src/helpers.rs index bea44a00..88cdf36e 100644 --- a/src/utils/sanitize.rs +++ b/crates/rustfs-admin/src/helpers.rs @@ -4,7 +4,7 @@ // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // -// http://www.apache.org/licenses/LICENSE-2.0 +// http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, @@ -12,6 +12,170 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Internal helper duties: shared credential parsing, signature/hash utilities, and parsers. +use hmac::{Hmac, Mac}; +use reqwest::StatusCode; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use url::form_urlencoded; + +use super::RustfsClientError; + +/// Encode an `application/x-www-form-urlencoded` request body. +pub(super) fn build_form_body(params: &[(&str, &str)]) -> String { + let mut pairs: Vec<(String, String)> = params + .iter() + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) + .collect(); + pairs.sort_by(|(k1, v1), (k2, v2)| k1.cmp(k2).then(v1.cmp(v2))); + + let mut serializer = form_urlencoded::Serializer::new(String::new()); + for (key, value) in pairs { + serializer.append_pair(&key, &value); + } + + serializer.finish() +} + +/// Encode and sort query parameters according to the AWS SigV4 rules. +pub(super) fn build_canonical_query(params: &[(&str, &str)]) -> String { + let mut pairs: Vec<(String, String)> = params + .iter() + .map(|(key, value)| (uri_encode(key), uri_encode(value))) + .collect(); + pairs.sort_unstable(); + + pairs + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join("&") +} + +fn uri_encode(value: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(char::from(byte)); + } else { + encoded.push('%'); + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + } + encoded +} + +pub(super) fn create_bucket_body(region: Option<&str>) -> String { + let Some(region) = region.map(str::trim).filter(|region| !region.is_empty()) else { + return String::new(); + }; + + if region == "us-east-1" { + return String::new(); + } + + format!( + "{}", + escape_xml(region) + ) +} + +pub(super) fn escape_xml(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +pub(super) fn body_mentions_not_found(body: &str) -> bool { + let body = body.to_ascii_lowercase(); + body.contains("nosuchuser") + || body.contains("no such user") + || body.contains("user not exist") + || body.contains("nosuchpolicy") + || body.contains("no such policy") + || body.contains("objectlockconfigurationnotfound") + || body.contains("not found") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum BucketConflictKind { + /// S3 `BucketAlreadyOwnedByYou`: same credentials already own this bucket. + OwnedByYou, + /// S3 `BucketAlreadyExists`: a different account owns this bucket name. + OwnedByOther, +} + +pub(super) fn bucket_conflict_kind(status: StatusCode, body: &str) -> Option { + if status != StatusCode::CONFLICT { + return None; + } + let body = body.to_ascii_lowercase(); + if body.contains("bucketalreadyownedbyyou") { + Some(BucketConflictKind::OwnedByYou) + } else if body.contains("bucketalreadyexists") { + Some(BucketConflictKind::OwnedByOther) + } else { + None + } +} + +pub(super) fn extract_canned_policy_document(body: &str) -> Result { + let value = serde_json::from_str::(body) + .map_err(|_| RustfsClientError::InvalidPolicyDocument)?; + let policy = value.get("policy").unwrap_or(&value); + + serde_json::to_string(policy).map_err(|_| RustfsClientError::InvalidPolicyDocument) +} + +pub(super) fn sha256_hex(payload: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(payload); + hex::encode(hasher.finalize()) +} + +pub(super) fn hmac_sha256(key: &[u8], message: &str) -> Result, RustfsClientError> { + let mut mac = + Hmac::::new_from_slice(key).map_err(|_| RustfsClientError::SigningFailed)?; + mac.update(message.as_bytes()); + Ok(mac.finalize().into_bytes().to_vec()) +} + +pub(super) fn hmac_sha256_hex(key: &[u8], message: &str) -> Result { + let bytes = hmac_sha256(key, message)?; + Ok(hex::encode(bytes)) +} + +pub(super) fn derive_signing_key( + secret_key: &str, + date_stamp: &str, + region: &str, + service: &str, +) -> Result, RustfsClientError> { + let k_secret = format!("AWS4{secret_key}").into_bytes(); + let k_date = hmac_sha256(&k_secret, date_stamp)?; + let k_region = hmac_sha256(&k_date, region)?; + let k_service = hmac_sha256(&k_region, service)?; + hmac_sha256(&k_service, "aws4_request") +} + +pub(super) fn extract_xml_tag(document: &str, tag: &str) -> Option { + let open = format!("<{tag}>"); + let close = format!(""); + + let open_idx = document.find(&open)?; + let start = open_idx + open.len(); + let rest = &document[start..]; + let end = rest.find(&close)?; + + Some(rest[..end].trim().to_string()) +} + const SENSITIVE_KEYS: [&str; 22] = [ "token", "password", @@ -37,7 +201,9 @@ const SENSITIVE_KEYS: [&str; 22] = [ "credentials", ]; -pub(crate) fn redact_sensitive_pairs(message: &str) -> String { +/// Redact sensitive credential-shaped key/value pairs and XML tags from an +/// upstream error message before it is logged or surfaced in a gRPC status. +pub fn redact_sensitive_pairs(message: &str) -> String { let message = redact_sensitive_xml_tags(message); redact_sensitive_key_value_pairs(&message) } @@ -248,7 +414,7 @@ fn redacted_value(original: &str) -> String { } #[cfg(test)] -mod tests { +mod redact_tests { use super::redact_sensitive_pairs; #[test] diff --git a/crates/rustfs-admin/src/lib.rs b/crates/rustfs-admin/src/lib.rs new file mode 100644 index 00000000..4f3c8951 --- /dev/null +++ b/crates/rustfs-admin/src/lib.rs @@ -0,0 +1,554 @@ +// Copyright 2025 RustFS Team +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::{collections::BTreeMap, time::Duration}; + +use reqwest::{Certificate, Client as HttpClient, Response, StatusCode}; + +/// admin_ops: tenant admin operations (user/policy APIs). +#[path = "admin_ops.rs"] +mod admin_ops; +/// core_ops: shared request signing/dispatch internals. +#[path = "core_ops.rs"] +mod core_ops; +/// helpers: credential parsing, signing/hash utilities and parsers. +#[path = "helpers.rs"] +mod helpers; +pub use helpers::redact_sensitive_pairs; +/// pool_ops: pool lifecycle and status operations. +#[path = "pool_ops.rs"] +mod pool_ops; +/// s3_ops: bucket/object-lock operations for S3-compatible endpoints. +#[path = "s3_ops.rs"] +mod s3_ops; +/// sts_ops: temporary credential flows. +#[path = "sts_ops.rs"] +mod sts_ops; +const FORM_CONTENT_TYPE: &str = "application/x-www-form-urlencoded"; +const ASSUME_ROLE_PATH: &str = "/"; +const STS_SIGNING_SERVICE: &str = "sts"; +const JSON_CONTENT_TYPE: &str = "application/json"; +const ADD_USER_PATH: &str = "/rustfs/admin/v3/add-user"; +const REMOVE_USER_PATH: &str = "/rustfs/admin/v3/remove-user"; +const USER_INFO_PATH: &str = "/rustfs/admin/v3/user-info"; +const SET_POLICY_PATH: &str = "/rustfs/admin/v3/set-policy"; +const LIST_CANNED_POLICIES_PATH: &str = "/rustfs/admin/v3/list-canned-policies"; +const ADD_CANNED_POLICY_PATH: &str = "/rustfs/admin/v3/add-canned-policy"; +const INFO_CANNED_POLICY_PATH: &str = "/rustfs/admin/v3/info-canned-policy"; +const SERVER_INFO_PATH: &str = "/rustfs/admin/v3/info"; +const POOLS_LIST_PATH: &str = "/rustfs/admin/v3/pools/list"; +const POOLS_STATUS_PATH: &str = "/rustfs/admin/v3/pools/status"; +const POOLS_DECOMMISSION_PATH: &str = "/rustfs/admin/v3/pools/decommission"; +const POOLS_CANCEL_PATH: &str = "/rustfs/admin/v3/pools/cancel"; +const ADMIN_SIGNING_SERVICE: &str = "s3"; +const ADMIN_HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(3); +const ADMIN_HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const MAX_UPSTREAM_ERROR_BODY_BYTES: usize = 8 * 1024; +const MAX_UPSTREAM_ERROR_DETAIL_CHARS: usize = 512; + +/// Credentials read from Tenant `.spec.credsSecret`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RustfsCredentials { + pub access_key: String, + pub secret_key: String, +} + +#[derive(Debug, Clone, serde::Deserialize, PartialEq)] +pub struct RustfsPoolListItem { + pub id: usize, + #[serde(rename = "cmdline")] + pub cmd_line: String, + #[serde(rename = "lastUpdate")] + pub last_update: String, + #[serde(rename = "totalSize")] + pub total_size: Option, + #[serde(rename = "currentSize")] + pub current_size: Option, + #[serde(rename = "usedSize")] + pub used_size: Option, + pub used: Option, + pub status: String, + #[serde(rename = "decommissionInfo")] + pub decommission: Option, +} + +#[derive(Debug, Clone, serde::Deserialize, PartialEq)] +pub struct RustfsPoolStatus { + pub id: usize, + #[serde(rename = "cmdline")] + pub cmd_line: String, + #[serde(rename = "lastUpdate")] + pub last_update: String, + #[serde(rename = "decommissionInfo")] + pub decommission: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CreateBucketResult { + Created, + /// The caller's own credentials already own this bucket (S3 + /// `BucketAlreadyOwnedByYou`) — safe to treat as an idempotent retry. + AlreadyOwnedByYou, + /// A different account owns this bucket name (S3 `BucketAlreadyExists`) — + /// never treat this as a successful retry. + AlreadyExists, +} + +/// Subset of `/rustfs/admin/v3/user-info` used by COSI grant ownership checks. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RustfsUserInfo { + pub policy_names: Vec, +} + +#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq)] +pub struct RustfsPoolDecommissionInfo { + #[serde(rename = "startTime")] + pub start_time: Option, + #[serde(rename = "startSize")] + pub start_size: Option, + #[serde(rename = "totalSize")] + pub total_size: Option, + #[serde(rename = "currentSize")] + pub current_size: Option, + pub complete: Option, + pub failed: Option, + pub canceled: Option, + #[serde(rename = "objectsDecommissioned")] + pub objects_decommissioned: Option, + #[serde(rename = "objectsDecommissionedFailed")] + pub objects_decommissioned_failed: Option, + #[serde(rename = "bytesDecommissioned")] + pub bytes_decommissioned: Option, + #[serde(rename = "bytesDecommissionedFailed")] + pub bytes_decommissioned_failed: Option, +} + +#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq)] +pub struct RustfsServerInfo { + #[serde(default)] + pub usage: Option, + #[serde(default)] + pub backend: Option, + #[serde(default)] + pub pools: Option>>, +} + +#[derive(Debug, Clone, serde::Deserialize, PartialEq)] +struct RustfsServerInfoResponse { + pub info: RustfsServerInfo, +} + +#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq)] +pub struct RustfsServerUsage { + #[serde(default)] + pub size: u64, +} + +#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq)] +pub struct RustfsErasureBackend { + #[serde(default, rename = "onlineDisks")] + pub online_disks: u64, + #[serde(default, rename = "offlineDisks")] + pub offline_disks: u64, + #[serde(default, rename = "standardSCParity", alias = "StandardSCParity")] + pub standard_sc_parity: Option, + #[serde(default, rename = "totalSets")] + pub total_sets: Vec, + #[serde(default, rename = "totalDrivesPerSet", alias = "drivesPerSet")] + pub drives_per_set: Vec, +} + +#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq)] +pub struct RustfsErasureSetInfo { + #[serde(default, rename = "rawUsage")] + pub raw_usage: u64, + #[serde(default, rename = "rawCapacity")] + pub raw_capacity: u64, + #[serde(default)] + pub usage: u64, + #[serde(default, rename = "objectsCount")] + pub objects_count: u64, + #[serde(default, rename = "healDisks")] + pub heal_disks: u64, +} + +/// Temporary credentials returned by RustFS STS AssumeRole. +#[derive(Debug, Clone)] +pub struct RustfsStsCredentials { + pub access_key_id: String, + pub secret_access_key: String, + pub session_token: String, + pub expiration: String, +} + +/// Error type for RustFS admin/STS client operations. +#[derive(Debug)] +pub enum RustfsClientError { + MissingTenantNamespace, + MissingCredsSecret, + MissingCredentialKey { + key: &'static str, + }, + EmptyCredentialValue { + key: &'static str, + }, + InvalidCredentialValue { + key: &'static str, + }, + TenantSecretLookupFailed, + InvalidPolicyName, + InvalidPolicyDocument, + TenantTlsRequired, + TenantTlsNotReady, + TenantTlsClientCertificateRequired, + MissingTenantTlsCaKey { + secret: String, + key: String, + }, + TenantTlsCaSecretLookupFailed { + secret: String, + }, + InvalidTenantTlsCa, + TlsClientBuildFailed, + RequestBuildFailed, + RequestFailed, + UnexpectedStatus { + status: StatusCode, + detail: Option, + }, + ParseResponseFailed, + SigningFailed, +} + +impl std::fmt::Display for RustfsClientError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingTenantNamespace => write!(f, "tenant namespace is missing"), + Self::MissingCredsSecret => write!(f, "tenant credsSecret is missing"), + Self::MissingCredentialKey { key } => write!(f, "secret key missing: {key}"), + Self::EmptyCredentialValue { key } => write!(f, "secret key empty: {key}"), + Self::InvalidCredentialValue { key } => { + write!(f, "secret key is not valid utf8: {key}") + } + Self::TenantSecretLookupFailed => { + write!(f, "failed to load tenant credential secret") + } + Self::InvalidPolicyName => write!(f, "invalid policy name"), + Self::InvalidPolicyDocument => write!(f, "failed to parse canned policy response"), + Self::TenantTlsRequired => write!(f, "STS requires a TLS-enabled tenant"), + Self::TenantTlsNotReady => write!(f, "tenant TLS status is not ready"), + Self::TenantTlsClientCertificateRequired => { + write!(f, "tenant TLS requires a client certificate") + } + Self::MissingTenantTlsCaKey { secret, key } => { + write!(f, "tenant TLS CA secret {secret} missing key {key}") + } + Self::TenantTlsCaSecretLookupFailed { secret } => { + write!(f, "failed to load tenant TLS CA secret {secret}") + } + Self::InvalidTenantTlsCa => write!(f, "tenant TLS CA is not a valid PEM bundle"), + Self::TlsClientBuildFailed => write!(f, "failed to build TLS HTTP client"), + Self::RequestBuildFailed => write!(f, "failed to construct request"), + Self::RequestFailed => write!(f, "request failed"), + Self::UnexpectedStatus { status, detail } => { + write!(f, "upstream returned {status}")?; + if let Some(detail) = detail { + write!(f, ": {detail}")?; + } + Ok(()) + } + Self::ParseResponseFailed => write!(f, "failed to parse AssumeRole response"), + Self::SigningFailed => write!(f, "failed to compute request signature"), + } + } +} + +impl std::error::Error for RustfsClientError {} + +impl RustfsClientError { + /// Whether this error represents an upstream HTTP 404, so callers can + /// classify e.g. "missing external policy" structurally instead of by + /// matching substrings of the (possibly redacted) rendered message. + pub fn is_not_found(&self) -> bool { + matches!(self, Self::UnexpectedStatus { status, .. } if *status == StatusCode::NOT_FOUND) + } + + async fn unexpected_response(response: Response) -> Self { + let status = response.status(); + let (body, truncated) = read_limited_response_body(response).await; + Self::unexpected_status_with_limited_body(status, &body, truncated) + } + + async fn limited_response_body(response: Response) -> (String, bool) { + read_limited_response_body(response).await + } + + fn unexpected_status_with_limited_body( + status: StatusCode, + body: &str, + body_truncated: bool, + ) -> Self { + Self::UnexpectedStatus { + status, + detail: summarize_upstream_error_body(body, body_truncated), + } + } +} + +async fn read_limited_response_body(mut response: Response) -> (String, bool) { + let mut body = Vec::new(); + let read_limit = MAX_UPSTREAM_ERROR_BODY_BYTES.saturating_add(1); + + loop { + let remaining = read_limit.saturating_sub(body.len()); + if remaining == 0 { + break; + } + + let chunk = match response.chunk().await { + Ok(Some(chunk)) => chunk, + Ok(None) => break, + Err(_) => break, + }; + if chunk.len() > remaining { + body.extend_from_slice(&chunk[..remaining]); + break; + } + body.extend_from_slice(&chunk); + } + + let truncated = body.len() > MAX_UPSTREAM_ERROR_BODY_BYTES; + if truncated { + body.truncate(MAX_UPSTREAM_ERROR_BODY_BYTES); + } + + (String::from_utf8_lossy(&body).into_owned(), truncated) +} + +fn summarize_upstream_error_body(body: &str, body_truncated: bool) -> Option { + let body = body.trim(); + if body.is_empty() { + return None; + } + + if let Some(message) = helpers::extract_xml_tag(body, "Message") { + let message = decode_basic_xml_entities(&message); + let detail = match helpers::extract_xml_tag(body, "Code") { + Some(code) if !code.trim().is_empty() => { + format!("{}: {message}", decode_basic_xml_entities(&code)) + } + _ => message, + }; + return Some(sanitize_error_detail(&detail)); + } + + if let Ok(value) = serde_json::from_str::(body) + && let Some(detail) = summarize_json_error(&value) + { + return Some(sanitize_error_detail(&detail)); + } + + if body_truncated { + return Some(format!( + "response body exceeded {MAX_UPSTREAM_ERROR_BODY_BYTES} bytes" + )); + } + + Some(sanitize_error_detail(body)) +} + +fn summarize_json_error(value: &serde_json::Value) -> Option { + if let Some(message) = value.as_str() { + return Some(message.to_string()); + } + + let object = value.as_object()?; + let message = ["message", "Message", "error", "Error"] + .iter() + .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))?; + let code = ["code", "Code"] + .iter() + .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str)); + + Some(match code { + Some(code) if !code.trim().is_empty() => format!("{code}: {message}"), + _ => message.to_string(), + }) +} + +fn collapse_whitespace(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") +} + +fn sanitize_error_detail(value: &str) -> String { + let detail = collapse_whitespace(value); + let detail = redact_sensitive_pairs(&detail); + truncate_error_detail(detail) +} + +fn truncate_error_detail(value: String) -> String { + let mut truncated = String::new(); + for (index, ch) in value.chars().enumerate() { + if index >= MAX_UPSTREAM_ERROR_DETAIL_CHARS { + truncated.push_str("..."); + return truncated; + } + truncated.push(ch); + } + truncated +} + +fn decode_basic_xml_entities(value: &str) -> String { + value + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} + +#[derive(Debug)] +struct SignedRequest { + amz_date: String, + payload_hash: String, + authorization: String, +} + +/// RustFS admin/STS client. +pub struct RustfsAdminClient { + base_url: String, + access_key: String, + secret_key: String, + region: String, + http_client: HttpClient, +} + +fn default_http_client() -> HttpClient { + HttpClient::builder() + .connect_timeout(ADMIN_HTTP_CONNECT_TIMEOUT) + .timeout(ADMIN_HTTP_REQUEST_TIMEOUT) + .build() + .unwrap_or_else(|_| HttpClient::new()) +} + +impl RustfsAdminClient { + pub const STS_VERSION: &'static str = "2011-06-15"; + pub const STS_ACTION: &'static str = "AssumeRole"; + + pub fn new_with_base_url( + base_url: impl Into, + access_key: impl Into, + secret_key: impl Into, + ) -> Self { + Self::new_with_base_url_and_http_client( + base_url, + access_key, + secret_key, + default_http_client(), + ) + } + + pub fn new_with_base_url_and_ca_pem( + base_url: impl Into, + access_key: impl Into, + secret_key: impl Into, + ca_pem: &[u8], + ) -> Result { + let certs = Certificate::from_pem_bundle(ca_pem) + .map_err(|_| RustfsClientError::InvalidTenantTlsCa)?; + let mut builder = HttpClient::builder() + .connect_timeout(ADMIN_HTTP_CONNECT_TIMEOUT) + .timeout(ADMIN_HTTP_REQUEST_TIMEOUT); + for cert in certs { + builder = builder.add_root_certificate(cert); + } + let http_client = builder + .build() + .map_err(|_| RustfsClientError::TlsClientBuildFailed)?; + + Ok(Self::new_with_base_url_and_http_client( + base_url, + access_key, + secret_key, + http_client, + )) + } + + pub fn new_with_base_url_and_http_client( + base_url: impl Into, + access_key: impl Into, + secret_key: impl Into, + http_client: HttpClient, + ) -> Self { + Self { + base_url: base_url.into(), + access_key: access_key.into(), + secret_key: secret_key.into(), + region: "us-east-1".to_string(), + http_client, + } + } +} + +#[cfg(test)] +mod tests { + use super::helpers::build_canonical_query; + use super::{RustfsClientError, StatusCode}; + + #[test] + fn canonical_query_uses_sigv4_uri_encoding_and_encoded_sort_order() { + let query = build_canonical_query(&[ + ("z", "a b~c/雪"), + ("a~", "second"), + ("a ", "first"), + ("amp", "&="), + ("dup", "z"), + ("dup", "a"), + ("empty", ""), + ("雪", "key"), + ]); + + assert_eq!( + query, + "%E9%9B%AA=key&a%20=first&=%26%3D&a~=second&dup=a&dup=z&empty=&z=a%20b~c%2F%E9%9B%AA" + ); + } + + /// Regression test for the redaction step dropped during the rustfs-admin + /// extraction: an upstream error body containing credential-shaped + /// fields must never reach `Display`/gRPC status in cleartext. + #[test] + fn unexpected_status_redacts_sensitive_upstream_error_summary_end_to_end() { + let body = r#"{"code":"InvalidRequest","message":"secretkey: SK_TEST clientSecret: oidc-secret SecretAccessKey: SK_STS AccessKeyId: AKIA_STS SK_XML AKIA_XML"}"#; + let err = RustfsClientError::unexpected_status_with_limited_body( + StatusCode::BAD_REQUEST, + body, + false, + ); + + let message = err.to_string(); + assert!(message.contains("secretkey: ")); + assert!(message.contains("clientSecret: ")); + assert!(message.contains("SecretAccessKey: ")); + assert!(message.contains("AccessKeyId: ")); + assert!(message.contains("")); + assert!(message.contains("")); + assert!(!message.contains("SK_TEST")); + assert!(!message.contains("oidc-secret")); + assert!(!message.contains("SK_STS")); + assert!(!message.contains("AKIA_STS")); + assert!(!message.contains("SK_XML")); + assert!(!message.contains("AKIA_XML")); + } +} diff --git a/src/sts/pool_ops.rs b/crates/rustfs-admin/src/pool_ops.rs similarity index 100% rename from src/sts/pool_ops.rs rename to crates/rustfs-admin/src/pool_ops.rs diff --git a/src/sts/s3_ops.rs b/crates/rustfs-admin/src/s3_ops.rs similarity index 72% rename from src/sts/s3_ops.rs rename to crates/rustfs-admin/src/s3_ops.rs index 0df217fc..fefcbcbf 100644 --- a/src/sts/s3_ops.rs +++ b/crates/rustfs-admin/src/s3_ops.rs @@ -19,7 +19,8 @@ use reqwest::StatusCode; use super::helpers::{ - body_mentions_not_found, bucket_already_exists, build_canonical_query, create_bucket_body, + BucketConflictKind, body_mentions_not_found, bucket_conflict_kind, build_canonical_query, + create_bucket_body, }; use super::{ADMIN_SIGNING_SERVICE, CreateBucketResult, RustfsAdminClient, RustfsClientError}; @@ -82,8 +83,48 @@ impl RustfsAdminClient { let status = response.status(); let (body, truncated) = RustfsClientError::limited_response_body(response).await; - if bucket_already_exists(status, &body) { - return Ok(CreateBucketResult::AlreadyExists); + match bucket_conflict_kind(status, &body) { + Some(BucketConflictKind::OwnedByYou) => { + return Ok(CreateBucketResult::AlreadyOwnedByYou); + } + Some(BucketConflictKind::OwnedByOther) => return Ok(CreateBucketResult::AlreadyExists), + None => {} + } + + Err(RustfsClientError::unexpected_status_with_limited_body( + status, &body, truncated, + )) + } + + /// Delete a bucket. Missing buckets are treated as success (idempotent). + pub async fn delete_bucket(&self, bucket: &str) -> Result<(), RustfsClientError> { + if bucket.trim().is_empty() { + return Err(RustfsClientError::RequestBuildFailed); + } + + let path = format!("/{bucket}"); + let signed = self.sign_request("DELETE", &path, "", "", None, ADMIN_SIGNING_SERVICE)?; + let host = self.host()?; + + let response = self + .http_client + .delete(format!("{}{}", self.base_url.trim_end_matches('/'), path)) + .header("x-amz-date", &signed.amz_date) + .header("x-amz-content-sha256", &signed.payload_hash) + .header("authorization", &signed.authorization) + .header("host", host) + .send() + .await + .map_err(|_| RustfsClientError::RequestFailed)?; + + if response.status().is_success() { + return Ok(()); + } + + let status = response.status(); + let (body, truncated) = RustfsClientError::limited_response_body(response).await; + if status == StatusCode::NOT_FOUND || body_mentions_not_found(&body) { + return Ok(()); } Err(RustfsClientError::unexpected_status_with_limited_body( diff --git a/src/sts/sts_ops.rs b/crates/rustfs-admin/src/sts_ops.rs similarity index 75% rename from src/sts/sts_ops.rs rename to crates/rustfs-admin/src/sts_ops.rs index ba45bccb..c13e9b83 100644 --- a/src/sts/sts_ops.rs +++ b/crates/rustfs-admin/src/sts_ops.rs @@ -14,11 +14,11 @@ //! STS boundary: //! - temporary credentials and AssumeRole request composition/response parsing. -use super::helpers::{build_form_body, parse_assume_role_response}; +use super::helpers::{build_form_body, extract_xml_tag}; use super::{ - ASSUME_ROLE_PATH, FORM_CONTENT_TYPE, RustfsAdminClient, RustfsClientError, STS_SIGNING_SERVICE, + ASSUME_ROLE_PATH, FORM_CONTENT_TYPE, RustfsAdminClient, RustfsClientError, + RustfsStsCredentials, STS_SIGNING_SERVICE, }; -use crate::sts::types::StsAssumeRoleCredentials; impl RustfsAdminClient { // STS duties: temporary credentials and AssumeRole API call path. @@ -28,7 +28,7 @@ impl RustfsAdminClient { &self, policy: Option<&str>, duration_seconds: u64, - ) -> Result { + ) -> Result { let mut params = vec![ ("Version", Self::STS_VERSION.to_string()), ("Action", Self::STS_ACTION.to_string()), @@ -79,6 +79,19 @@ impl RustfsAdminClient { .await .map_err(|_| RustfsClientError::RequestFailed)?; - parse_assume_role_response(&body).ok_or(RustfsClientError::ParseResponseFailed) + let access_key_id = + extract_xml_tag(&body, "AccessKeyId").ok_or(RustfsClientError::ParseResponseFailed)?; + let secret_access_key = extract_xml_tag(&body, "SecretAccessKey") + .ok_or(RustfsClientError::ParseResponseFailed)?; + let session_token = + extract_xml_tag(&body, "SessionToken").ok_or(RustfsClientError::ParseResponseFailed)?; + let expiration = + extract_xml_tag(&body, "Expiration").ok_or(RustfsClientError::ParseResponseFailed)?; + Ok(RustfsStsCredentials { + access_key_id, + secret_access_key, + session_token, + expiration, + }) } } diff --git a/docs/operator-user-guide.md b/docs/operator-user-guide.md index 01ac3569..444a781b 100644 --- a/docs/operator-user-guide.md +++ b/docs/operator-user-guide.md @@ -1209,6 +1209,14 @@ For the RustFS Tenant Console, use the Tenant admin credentials from `spec.creds - Keep Tenant examples under version control, but never commit raw Secret values. - Check `status.conditions` before debugging lower-level StatefulSets. +## 13.1 COSI `preferredAccessKey` + +When using the RustFS COSI driver (`rustfs.objectstorage.k8s.io`): + +- Prefer omitting `preferredAccessKey` so each `BucketAccess` gets a unique account id derived from the COSI grant name (`ba-`), matching Ceph COSI isolation. +- If you set `preferredAccessKey` (or `accessKey`), the value must be unique per `BucketAccess`. Reusing the same key across claims is rejected with `AlreadyExists` so credentials are never rotated out from under another workload. +- Grant retries for the same `BucketAccess` are idempotent and return the same secret; the driver does not overwrite an existing user's secret key. + ## 14. Related Documentation - [Project README](../README.md) diff --git a/src/reconcile/provisioning.rs b/src/reconcile/provisioning.rs index 6fdf633f..2433da74 100644 --- a/src/reconcile/provisioning.rs +++ b/src/reconcile/provisioning.rs @@ -1799,7 +1799,7 @@ async fn reconcile_bucket( CreateBucketResult::Created => { "RustFS bucket was created with object lock enabled" } - CreateBucketResult::AlreadyExists => { + CreateBucketResult::AlreadyExists | CreateBucketResult::AlreadyOwnedByYou => { "Bucket already existed with object lock enabled" } }; @@ -1817,7 +1817,7 @@ async fn reconcile_bucket( CreateBucketResult::Created => { "Bucket was created but object lock is not enabled" } - CreateBucketResult::AlreadyExists => { + CreateBucketResult::AlreadyExists | CreateBucketResult::AlreadyOwnedByYou => { "Bucket already exists but object lock is not enabled" } }; @@ -1835,7 +1835,7 @@ async fn reconcile_bucket( CreateBucketResult::Created => { format!("failed to verify created bucket object lock: {error}") } - CreateBucketResult::AlreadyExists => { + CreateBucketResult::AlreadyExists | CreateBucketResult::AlreadyOwnedByYou => { format!("failed to verify existing bucket object lock: {error}") } }; @@ -1853,7 +1853,9 @@ async fn reconcile_bucket( let message = match create_result { CreateBucketResult::Created => "RustFS bucket was created", - CreateBucketResult::AlreadyExists => "RustFS bucket already exists", + CreateBucketResult::AlreadyExists | CreateBucketResult::AlreadyOwnedByYou => { + "RustFS bucket already exists" + } }; let item = run.item( previous, diff --git a/src/status.rs b/src/status.rs index 1baa699c..42e6ecd6 100644 --- a/src/status.rs +++ b/src/status.rs @@ -19,8 +19,8 @@ use crate::types::v1alpha1::status::{ pool, summarize_current_state, }; use crate::types::v1alpha1::tenant::Tenant; -use crate::utils::sanitize::redact_sensitive_pairs; use kube::runtime::events::EventType; +use rustfs_admin::redact_sensitive_pairs; const LEGACY_PROGRESSING_CONDITION: &str = "Progressing"; diff --git a/src/sts/helpers.rs b/src/sts/helpers.rs deleted file mode 100644 index b0975bde..00000000 --- a/src/sts/helpers.rs +++ /dev/null @@ -1,226 +0,0 @@ -// Copyright 2025 RustFS Team -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Internal helper duties: shared credential parsing, signature/hash utilities, and parsers. -use std::collections::BTreeMap; - -use hmac::{Hmac, Mac}; -use k8s_openapi::ByteString; -use reqwest::StatusCode; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use url::form_urlencoded; - -use crate::Tenant; -use crate::sts::types::StsAssumeRoleCredentials; - -use super::{RustfsClientError, RustfsCredentials}; - -pub(super) fn extract_credentials( - data: Option<&BTreeMap>, -) -> Result { - let secret_data = data.ok_or(RustfsClientError::TenantSecretLookupFailed)?; - - Ok(RustfsCredentials { - access_key: get_secret_value(secret_data, "accesskey")?, - secret_key: get_secret_value(secret_data, "secretkey")?, - }) -} - -pub(super) fn tenant_tls_enabled(tenant: &Tenant) -> bool { - tenant.spec.tls.as_ref().is_some_and(|tls| tls.is_enabled()) -} - -pub(super) fn tenant_tls_client_certificate_required(tenant: &Tenant) -> bool { - tenant - .status - .as_ref() - .and_then(|status| status.certificates.tls.as_ref()) - .and_then(|tls| tls.client_ca_secret_ref.as_ref()) - .is_some() -} - -pub(super) fn get_secret_value( - data: &BTreeMap, - field: &'static str, -) -> Result { - let raw = data - .get(field) - .ok_or(RustfsClientError::MissingCredentialKey { key: field })?; - - let value = String::from_utf8(raw.0.clone()) - .map_err(|_| RustfsClientError::InvalidCredentialValue { key: field })?; - - if value.is_empty() { - return Err(RustfsClientError::EmptyCredentialValue { key: field }); - } - - Ok(value) -} - -/// Encode an `application/x-www-form-urlencoded` request body. -pub(super) fn build_form_body(params: &[(&str, &str)]) -> String { - let mut pairs: Vec<(String, String)> = params - .iter() - .map(|(k, v)| ((*k).to_string(), (*v).to_string())) - .collect(); - pairs.sort_by(|(k1, v1), (k2, v2)| k1.cmp(k2).then(v1.cmp(v2))); - - let mut serializer = form_urlencoded::Serializer::new(String::new()); - for (key, value) in pairs { - serializer.append_pair(&key, &value); - } - - serializer.finish() -} - -/// Encode and sort query parameters according to the AWS SigV4 rules. -pub(super) fn build_canonical_query(params: &[(&str, &str)]) -> String { - let mut pairs: Vec<(String, String)> = params - .iter() - .map(|(key, value)| (uri_encode(key), uri_encode(value))) - .collect(); - pairs.sort_unstable(); - - pairs - .into_iter() - .map(|(key, value)| format!("{key}={value}")) - .collect::>() - .join("&") -} - -fn uri_encode(value: &str) -> String { - const HEX: &[u8; 16] = b"0123456789ABCDEF"; - - let mut encoded = String::with_capacity(value.len()); - for byte in value.bytes() { - if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { - encoded.push(char::from(byte)); - } else { - encoded.push('%'); - encoded.push(char::from(HEX[usize::from(byte >> 4)])); - encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); - } - } - encoded -} - -pub(super) fn create_bucket_body(region: Option<&str>) -> String { - let Some(region) = region.map(str::trim).filter(|region| !region.is_empty()) else { - return String::new(); - }; - - if region == "us-east-1" { - return String::new(); - } - - format!( - "{}", - escape_xml(region) - ) -} - -pub(super) fn escape_xml(value: &str) -> String { - value - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") -} - -pub(super) fn body_mentions_not_found(body: &str) -> bool { - let body = body.to_ascii_lowercase(); - body.contains("nosuchuser") - || body.contains("no such user") - || body.contains("user not exist") - || body.contains("nosuchpolicy") - || body.contains("no such policy") - || body.contains("objectlockconfigurationnotfound") - || body.contains("not found") -} - -pub(super) fn bucket_already_exists(status: StatusCode, body: &str) -> bool { - if status == StatusCode::CONFLICT { - let body = body.to_ascii_lowercase(); - return body.contains("bucketalreadyexists") || body.contains("bucketalreadyownedbyyou"); - } - - false -} - -pub(super) fn extract_canned_policy_document(body: &str) -> Result { - let value = serde_json::from_str::(body) - .map_err(|_| RustfsClientError::InvalidPolicyDocument)?; - let policy = value.get("policy").unwrap_or(&value); - - serde_json::to_string(policy).map_err(|_| RustfsClientError::InvalidPolicyDocument) -} - -pub(super) fn sha256_hex(payload: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(payload); - hex::encode(hasher.finalize()) -} - -pub(super) fn hmac_sha256(key: &[u8], message: &str) -> Result, RustfsClientError> { - let mut mac = - Hmac::::new_from_slice(key).map_err(|_| RustfsClientError::SigningFailed)?; - mac.update(message.as_bytes()); - Ok(mac.finalize().into_bytes().to_vec()) -} - -pub(super) fn hmac_sha256_hex(key: &[u8], message: &str) -> Result { - let bytes = hmac_sha256(key, message)?; - Ok(hex::encode(bytes)) -} - -pub(super) fn derive_signing_key( - secret_key: &str, - date_stamp: &str, - region: &str, - service: &str, -) -> Result, RustfsClientError> { - let k_secret = format!("AWS4{secret_key}").into_bytes(); - let k_date = hmac_sha256(&k_secret, date_stamp)?; - let k_region = hmac_sha256(&k_date, region)?; - let k_service = hmac_sha256(&k_region, service)?; - hmac_sha256(&k_service, "aws4_request") -} - -pub(super) fn parse_assume_role_response(body: &str) -> Option { - let access_key_id = extract_xml_tag(body, "AccessKeyId")?; - let secret_access_key = extract_xml_tag(body, "SecretAccessKey")?; - let session_token = extract_xml_tag(body, "SessionToken")?; - let expiration = extract_xml_tag(body, "Expiration")?; - - Some(StsAssumeRoleCredentials { - access_key_id, - secret_access_key, - session_token, - expiration, - }) -} - -pub(super) fn extract_xml_tag(document: &str, tag: &str) -> Option { - let open = format!("<{tag}>"); - let close = format!(""); - - let open_idx = document.find(&open)?; - let start = open_idx + open.len(); - let rest = &document[start..]; - let end = rest.find(&close)?; - - Some(rest[..end].trim().to_string()) -} diff --git a/src/sts/rustfs_client.rs b/src/sts/rustfs_client.rs index 865c27f3..a94204d6 100644 --- a/src/sts/rustfs_client.rs +++ b/src/sts/rustfs_client.rs @@ -12,443 +12,49 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::{collections::BTreeMap, time::Duration}; +use std::ops::{Deref, DerefMut}; use k8s_openapi::api::core::v1 as corev1; use kube::{Api, Client}; -use reqwest::{Certificate, Client as HttpClient, Response, StatusCode}; use crate::Tenant; use crate::cluster_dns; -use crate::utils::sanitize::redact_sensitive_pairs; -/// admin_ops: tenant admin operations (user/policy APIs). -#[path = "admin_ops.rs"] -mod admin_ops; -/// core_ops: shared request signing/dispatch internals. -#[path = "core_ops.rs"] -mod core_ops; -/// helpers: credential parsing, signing/hash utilities and parsers. -#[path = "helpers.rs"] -mod helpers; -/// pool_ops: pool lifecycle and status operations. -#[path = "pool_ops.rs"] -mod pool_ops; -/// s3_ops: bucket/object-lock operations for S3-compatible endpoints. -#[path = "s3_ops.rs"] -mod s3_ops; -/// sts_ops: temporary credential flows, AssumeRole request/response. -#[path = "sts_ops.rs"] -mod sts_ops; +pub use rustfs_admin::{ + CreateBucketResult, RustfsClientError, RustfsCredentials, RustfsErasureBackend, + RustfsErasureSetInfo, RustfsPoolDecommissionInfo, RustfsPoolListItem, RustfsPoolStatus, + RustfsServerInfo, RustfsServerUsage, RustfsUserInfo, +}; -const FORM_CONTENT_TYPE: &str = "application/x-www-form-urlencoded"; -const JSON_CONTENT_TYPE: &str = "application/json"; -const ASSUME_ROLE_PATH: &str = "/"; -const ADD_USER_PATH: &str = "/rustfs/admin/v3/add-user"; -const USER_INFO_PATH: &str = "/rustfs/admin/v3/user-info"; -const SET_POLICY_PATH: &str = "/rustfs/admin/v3/set-policy"; -const LIST_CANNED_POLICIES_PATH: &str = "/rustfs/admin/v3/list-canned-policies"; -const ADD_CANNED_POLICY_PATH: &str = "/rustfs/admin/v3/add-canned-policy"; -const INFO_CANNED_POLICY_PATH: &str = "/rustfs/admin/v3/info-canned-policy"; -const SERVER_INFO_PATH: &str = "/rustfs/admin/v3/info"; -const POOLS_LIST_PATH: &str = "/rustfs/admin/v3/pools/list"; -const POOLS_STATUS_PATH: &str = "/rustfs/admin/v3/pools/status"; -const POOLS_DECOMMISSION_PATH: &str = "/rustfs/admin/v3/pools/decommission"; -const POOLS_CANCEL_PATH: &str = "/rustfs/admin/v3/pools/cancel"; -const ADMIN_SIGNING_SERVICE: &str = "s3"; -const STS_SIGNING_SERVICE: &str = "sts"; -const ADMIN_HTTP_CONNECT_TIMEOUT: Duration = Duration::from_secs(3); -const ADMIN_HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(10); -const MAX_UPSTREAM_ERROR_BODY_BYTES: usize = 8 * 1024; -const MAX_UPSTREAM_ERROR_DETAIL_CHARS: usize = 512; +/// Tenant-aware wrapper around the kube-agnostic RustFS admin client. +pub struct RustfsAdminClient(pub rustfs_admin::RustfsAdminClient); -/// Credentials read from Tenant `.spec.credsSecret`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RustfsCredentials { - pub access_key: String, - pub secret_key: String, -} - -#[derive(Debug, Clone, serde::Deserialize, PartialEq)] -pub struct RustfsPoolListItem { - pub id: usize, - #[serde(rename = "cmdline")] - pub cmd_line: String, - #[serde(rename = "lastUpdate")] - pub last_update: String, - #[serde(rename = "totalSize")] - pub total_size: Option, - #[serde(rename = "currentSize")] - pub current_size: Option, - #[serde(rename = "usedSize")] - pub used_size: Option, - pub used: Option, - pub status: String, - #[serde(rename = "decommissionInfo")] - pub decommission: Option, -} - -#[derive(Debug, Clone, serde::Deserialize, PartialEq)] -pub struct RustfsPoolStatus { - pub id: usize, - #[serde(rename = "cmdline")] - pub cmd_line: String, - #[serde(rename = "lastUpdate")] - pub last_update: String, - #[serde(rename = "decommissionInfo")] - pub decommission: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CreateBucketResult { - Created, - AlreadyExists, -} - -#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq)] -pub struct RustfsPoolDecommissionInfo { - #[serde(rename = "startTime")] - pub start_time: Option, - #[serde(rename = "startSize")] - pub start_size: Option, - #[serde(rename = "totalSize")] - pub total_size: Option, - #[serde(rename = "currentSize")] - pub current_size: Option, - pub complete: Option, - pub failed: Option, - pub canceled: Option, - #[serde(rename = "objectsDecommissioned")] - pub objects_decommissioned: Option, - #[serde(rename = "objectsDecommissionedFailed")] - pub objects_decommissioned_failed: Option, - #[serde(rename = "bytesDecommissioned")] - pub bytes_decommissioned: Option, - #[serde(rename = "bytesDecommissionedFailed")] - pub bytes_decommissioned_failed: Option, -} - -#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq)] -pub struct RustfsServerInfo { - #[serde(default)] - pub usage: Option, - #[serde(default)] - pub backend: Option, - #[serde(default)] - pub pools: Option>>, -} - -#[derive(Debug, Clone, serde::Deserialize, PartialEq)] -pub(super) struct RustfsServerInfoResponse { - pub info: RustfsServerInfo, -} - -#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq)] -pub struct RustfsServerUsage { - #[serde(default)] - pub size: u64, -} - -#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq)] -pub struct RustfsErasureBackend { - #[serde(default, rename = "onlineDisks")] - pub online_disks: u64, - #[serde(default, rename = "offlineDisks")] - pub offline_disks: u64, - #[serde(default, rename = "standardSCParity", alias = "StandardSCParity")] - pub standard_sc_parity: Option, - #[serde(default, rename = "totalSets")] - pub total_sets: Vec, - #[serde(default, rename = "totalDrivesPerSet", alias = "drivesPerSet")] - pub drives_per_set: Vec, -} - -#[derive(Debug, Clone, Default, serde::Deserialize, PartialEq)] -pub struct RustfsErasureSetInfo { - #[serde(default, rename = "rawUsage")] - pub raw_usage: u64, - #[serde(default, rename = "rawCapacity")] - pub raw_capacity: u64, - #[serde(default)] - pub usage: u64, - #[serde(default, rename = "objectsCount")] - pub objects_count: u64, - #[serde(default, rename = "healDisks")] - pub heal_disks: u64, -} - -/// Error type for RustFS admin/STS client operations. -#[derive(Debug)] -pub enum RustfsClientError { - MissingTenantNamespace, - MissingCredsSecret, - MissingCredentialKey { - key: &'static str, - }, - EmptyCredentialValue { - key: &'static str, - }, - InvalidCredentialValue { - key: &'static str, - }, - TenantSecretLookupFailed, - InvalidPolicyName, - InvalidPolicyDocument, - TenantTlsRequired, - TenantTlsNotReady, - TenantTlsClientCertificateRequired, - MissingTenantTlsCaKey { - secret: String, - key: String, - }, - TenantTlsCaSecretLookupFailed { - secret: String, - }, - InvalidTenantTlsCa, - TlsClientBuildFailed, - RequestBuildFailed, - RequestFailed, - UnexpectedStatus { - status: StatusCode, - detail: Option, - }, - ParseResponseFailed, - SigningFailed, -} - -impl std::fmt::Display for RustfsClientError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::MissingTenantNamespace => write!(f, "tenant namespace is missing"), - Self::MissingCredsSecret => write!(f, "tenant credsSecret is missing"), - Self::MissingCredentialKey { key } => write!(f, "secret key missing: {key}"), - Self::EmptyCredentialValue { key } => write!(f, "secret key empty: {key}"), - Self::InvalidCredentialValue { key } => { - write!(f, "secret key is not valid utf8: {key}") - } - Self::TenantSecretLookupFailed => { - write!(f, "failed to load tenant credential secret") - } - Self::InvalidPolicyName => write!(f, "invalid policy name"), - Self::InvalidPolicyDocument => write!(f, "failed to parse canned policy response"), - Self::TenantTlsRequired => write!(f, "STS requires a TLS-enabled tenant"), - Self::TenantTlsNotReady => write!(f, "tenant TLS status is not ready"), - Self::TenantTlsClientCertificateRequired => { - write!(f, "tenant TLS requires a client certificate") - } - Self::MissingTenantTlsCaKey { secret, key } => { - write!(f, "tenant TLS CA secret {secret} missing key {key}") - } - Self::TenantTlsCaSecretLookupFailed { secret } => { - write!(f, "failed to load tenant TLS CA secret {secret}") - } - Self::InvalidTenantTlsCa => write!(f, "tenant TLS CA is not a valid PEM bundle"), - Self::TlsClientBuildFailed => write!(f, "failed to build TLS HTTP client"), - Self::RequestBuildFailed => write!(f, "failed to construct request"), - Self::RequestFailed => write!(f, "request failed"), - Self::UnexpectedStatus { status, detail } => { - write!(f, "upstream returned {status}")?; - if let Some(detail) = detail { - write!(f, ": {detail}")?; - } - Ok(()) - } - Self::ParseResponseFailed => write!(f, "failed to parse AssumeRole response"), - Self::SigningFailed => write!(f, "failed to compute request signature"), - } - } -} - -impl std::error::Error for RustfsClientError {} - -impl RustfsClientError { - pub(super) async fn unexpected_response(response: Response) -> Self { - let status = response.status(); - let (body, truncated) = read_limited_response_body(response).await; - Self::unexpected_status_with_limited_body(status, &body, truncated) - } - - pub(super) async fn limited_response_body(response: Response) -> (String, bool) { - read_limited_response_body(response).await - } +impl Deref for RustfsAdminClient { + type Target = rustfs_admin::RustfsAdminClient; - fn unexpected_status_with_limited_body( - status: StatusCode, - body: &str, - body_truncated: bool, - ) -> Self { - Self::UnexpectedStatus { - status, - detail: summarize_upstream_error_body(body, body_truncated), - } - } - - #[cfg(test)] - pub(super) fn unexpected_status_with_body(status: StatusCode, body: &str) -> Self { - Self::unexpected_status_with_limited_body(status, body, false) + fn deref(&self) -> &Self::Target { + &self.0 } } -async fn read_limited_response_body(mut response: Response) -> (String, bool) { - let mut body = Vec::new(); - let read_limit = MAX_UPSTREAM_ERROR_BODY_BYTES.saturating_add(1); - - loop { - let remaining = read_limit.saturating_sub(body.len()); - if remaining == 0 { - break; - } - - let chunk = match response.chunk().await { - Ok(Some(chunk)) => chunk, - Ok(None) => break, - Err(_) => break, - }; - if chunk.len() > remaining { - body.extend_from_slice(&chunk[..remaining]); - break; - } - body.extend_from_slice(&chunk); +impl DerefMut for RustfsAdminClient { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 } - - let truncated = body.len() > MAX_UPSTREAM_ERROR_BODY_BYTES; - if truncated { - body.truncate(MAX_UPSTREAM_ERROR_BODY_BYTES); - } - - (String::from_utf8_lossy(&body).into_owned(), truncated) -} - -fn summarize_upstream_error_body(body: &str, body_truncated: bool) -> Option { - let body = body.trim(); - if body.is_empty() { - return None; - } - - if let Some(message) = helpers::extract_xml_tag(body, "Message") { - let message = decode_basic_xml_entities(&message); - let detail = match helpers::extract_xml_tag(body, "Code") { - Some(code) if !code.trim().is_empty() => { - format!("{}: {message}", decode_basic_xml_entities(&code)) - } - _ => message, - }; - return Some(sanitize_error_detail(&detail)); - } - - if let Ok(value) = serde_json::from_str::(body) - && let Some(detail) = summarize_json_error(&value) - { - return Some(sanitize_error_detail(&detail)); - } - - if body_truncated { - return Some(format!( - "response body exceeded {MAX_UPSTREAM_ERROR_BODY_BYTES} bytes" - )); - } - - Some(sanitize_error_detail(body)) -} - -fn summarize_json_error(value: &serde_json::Value) -> Option { - if let Some(message) = value.as_str() { - return Some(message.to_string()); - } - - let object = value.as_object()?; - let message = ["message", "Message", "error", "Error"] - .iter() - .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str))?; - let code = ["code", "Code"] - .iter() - .find_map(|key| object.get(*key).and_then(serde_json::Value::as_str)); - - Some(match code { - Some(code) if !code.trim().is_empty() => format!("{code}: {message}"), - _ => message.to_string(), - }) -} - -fn collapse_whitespace(value: &str) -> String { - value.split_whitespace().collect::>().join(" ") -} - -fn sanitize_error_detail(value: &str) -> String { - let detail = collapse_whitespace(value); - let detail = redact_sensitive_pairs(&detail); - truncate_error_detail(detail) -} - -fn truncate_error_detail(value: String) -> String { - let mut truncated = String::new(); - for (index, ch) in value.chars().enumerate() { - if index >= MAX_UPSTREAM_ERROR_DETAIL_CHARS { - truncated.push_str("..."); - return truncated; - } - truncated.push(ch); - } - truncated -} - -fn decode_basic_xml_entities(value: &str) -> String { - value - .replace("<", "<") - .replace(">", ">") - .replace(""", "\"") - .replace("'", "'") - .replace("&", "&") -} - -#[derive(Debug)] -struct SignedRequest { - amz_date: String, - payload_hash: String, - authorization: String, -} - -/// RustFS admin/STS client. -pub struct RustfsAdminClient { - base_url: String, - access_key: String, - secret_key: String, - region: String, - http_client: HttpClient, -} - -fn default_http_client() -> HttpClient { - HttpClient::builder() - .connect_timeout(ADMIN_HTTP_CONNECT_TIMEOUT) - .timeout(ADMIN_HTTP_REQUEST_TIMEOUT) - .build() - .unwrap_or_else(|_| HttpClient::new()) -} - -fn tls_tenant_base_url(tenant: &Tenant, cluster_domain: &str) -> Result { - let namespace = tenant - .namespace() - .map_err(|_| RustfsClientError::MissingTenantNamespace)?; - let service_fqdn = - cluster_dns::service_fqdn(&tenant.headless_service_name(), &namespace, cluster_domain); - Ok(format!("https://{service_fqdn}:9000")) } impl RustfsAdminClient { - pub const STS_VERSION: &'static str = "2011-06-15"; - pub const STS_ACTION: &'static str = "AssumeRole"; + pub const STS_VERSION: &'static str = rustfs_admin::RustfsAdminClient::STS_VERSION; + pub const STS_ACTION: &'static str = rustfs_admin::RustfsAdminClient::STS_ACTION; pub fn new_with_base_url( base_url: impl Into, access_key: impl Into, secret_key: impl Into, ) -> Self { - Self::new_with_base_url_and_http_client( - base_url, - access_key, - secret_key, - default_http_client(), - ) + Self(rustfs_admin::RustfsAdminClient::new_with_base_url( + base_url, access_key, secret_key, + )) } pub fn new_with_base_url_and_ca_pem( @@ -457,39 +63,26 @@ impl RustfsAdminClient { secret_key: impl Into, ca_pem: &[u8], ) -> Result { - let certs = Certificate::from_pem_bundle(ca_pem) - .map_err(|_| RustfsClientError::InvalidTenantTlsCa)?; - let mut builder = HttpClient::builder() - .connect_timeout(ADMIN_HTTP_CONNECT_TIMEOUT) - .timeout(ADMIN_HTTP_REQUEST_TIMEOUT); - for cert in certs { - builder = builder.add_root_certificate(cert); - } - let http_client = builder - .build() - .map_err(|_| RustfsClientError::TlsClientBuildFailed)?; - - Ok(Self::new_with_base_url_and_http_client( - base_url, - access_key, - secret_key, - http_client, - )) + rustfs_admin::RustfsAdminClient::new_with_base_url_and_ca_pem( + base_url, access_key, secret_key, ca_pem, + ) + .map(Self) } pub fn new_with_base_url_and_http_client( base_url: impl Into, access_key: impl Into, secret_key: impl Into, - http_client: HttpClient, + http_client: reqwest::Client, ) -> Self { - Self { - base_url: base_url.into(), - access_key: access_key.into(), - secret_key: secret_key.into(), - region: "us-east-1".to_string(), - http_client, - } + Self( + rustfs_admin::RustfsAdminClient::new_with_base_url_and_http_client( + base_url, + access_key, + secret_key, + http_client, + ), + ) } pub fn from_tenant( @@ -518,15 +111,25 @@ impl RustfsAdminClient { credentials: RustfsCredentials, cluster_domain: &str, ) -> Result { - if !helpers::tenant_tls_enabled(tenant) { + if !tenant.spec.tls.as_ref().is_some_and(|tls| tls.is_enabled()) { return Err(RustfsClientError::TenantTlsRequired); } - if helpers::tenant_tls_client_certificate_required(tenant) { + if tenant + .status + .as_ref() + .and_then(|status| status.certificates.tls.as_ref()) + .and_then(|tls| tls.client_ca_secret_ref.as_ref()) + .is_some() + { return Err(RustfsClientError::TenantTlsClientCertificateRequired); } - let base_url = tls_tenant_base_url(tenant, cluster_domain)?; - + let namespace = tenant + .namespace() + .map_err(|_| RustfsClientError::MissingTenantNamespace)?; + let service_fqdn = + cluster_dns::service_fqdn(&tenant.headless_service_name(), &namespace, cluster_domain); + let base_url = format!("https://{service_fqdn}:9000"); match Self::load_tenant_tls_ca(kube_client, tenant).await? { Some(ca_pem) => Self::new_with_base_url_and_ca_pem( base_url, @@ -546,21 +149,18 @@ impl RustfsAdminClient { kube_client: &Client, tenant: &Tenant, ) -> Result>, RustfsClientError> { - if !helpers::tenant_tls_enabled(tenant) { + if !tenant.spec.tls.as_ref().is_some_and(|tls| tls.is_enabled()) { return Ok(None); } - let tls_status = tenant .status .as_ref() .and_then(|status| status.certificates.tls.as_ref()) .filter(|tls| tls.ready) .ok_or(RustfsClientError::TenantTlsNotReady)?; - let Some(ca_ref) = tls_status.ca_secret_ref.as_ref() else { return Ok(None); }; - let namespace = tenant .namespace() .map_err(|_| RustfsClientError::MissingTenantNamespace)?; @@ -581,11 +181,9 @@ impl RustfsAdminClient { secret: ca_ref.name.clone(), key: key.to_string(), })?; - Ok(Some(ca_pem)) } - /// Read Tenant credential Secret and return access/secret key pair. pub async fn load_tenant_credentials( kube_client: &Client, tenant: &Tenant, @@ -595,7 +193,6 @@ impl RustfsAdminClient { .creds_secret .as_ref() .ok_or(RustfsClientError::MissingCredsSecret)?; - let namespace = tenant .namespace() .map_err(|_| RustfsClientError::MissingTenantNamespace)?; @@ -604,11 +201,38 @@ impl RustfsAdminClient { .get(&reference.name) .await .map_err(|_| RustfsClientError::TenantSecretLookupFailed)?; - - helpers::extract_credentials(secret.data.as_ref()) + let data = secret + .data + .as_ref() + .ok_or(RustfsClientError::TenantSecretLookupFailed)?; + let credential = |key: &'static str| -> Result { + let value = data + .get(key) + .ok_or(RustfsClientError::MissingCredentialKey { key })?; + let value = String::from_utf8(value.0.clone()) + .map_err(|_| RustfsClientError::InvalidCredentialValue { key })?; + if value.is_empty() { + return Err(RustfsClientError::EmptyCredentialValue { key }); + } + Ok(value) + }; + Ok(RustfsCredentials { + access_key: credential("accesskey")?, + secret_key: credential("secretkey")?, + }) + } + + pub async fn assume_role( + &self, + policy: Option<&str>, + duration_seconds: u64, + ) -> Result { + let credentials = self.0.assume_role(policy, duration_seconds).await?; + Ok(crate::sts::types::StsAssumeRoleCredentials { + access_key_id: credentials.access_key_id, + secret_access_key: credentials.secret_access_key, + session_token: credentials.session_token, + expiration: credentials.expiration, + }) } } - -#[cfg(test)] -#[path = "tests.rs"] -mod tests; diff --git a/src/sts/tests.rs b/src/sts/tests.rs deleted file mode 100644 index 502a92aa..00000000 --- a/src/sts/tests.rs +++ /dev/null @@ -1,1210 +0,0 @@ -// Copyright 2025 RustFS Team -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -//! Unit/integration tests for RustfsAdminClient split operation modules. - -use axum::{ - Router, - body::Body, - extract::State, - http::{Request, StatusCode}, - routing::{get, post, put}, -}; -use k8s_openapi::{ByteString, api::core::v1 as corev1}; -use serde_json::Value; -use std::{collections::BTreeMap, sync::Arc}; -use tokio::sync::Mutex; - -use super::{ - ADD_USER_PATH, ADMIN_SIGNING_SERVICE, CreateBucketResult, FORM_CONTENT_TYPE, JSON_CONTENT_TYPE, - LIST_CANNED_POLICIES_PATH, MAX_UPSTREAM_ERROR_BODY_BYTES, POOLS_DECOMMISSION_PATH, - POOLS_LIST_PATH, POOLS_STATUS_PATH, RustfsAdminClient, RustfsClientError, SERVER_INFO_PATH, - SET_POLICY_PATH, STS_SIGNING_SERVICE, USER_INFO_PATH, - helpers::{ - build_canonical_query, build_form_body, derive_signing_key, extract_canned_policy_document, - extract_credentials, hmac_sha256_hex, parse_assume_role_response, sha256_hex, - }, - tls_tenant_base_url, -}; - -const TEST_ACCESS_KEY: &str = "access"; -const TEST_SECRET_KEY: &str = "secret"; -const TEST_REGION: &str = "us-east-1"; - -#[test] -fn canonical_query_uses_sigv4_uri_encoding_and_encoded_sort_order() { - let query = build_canonical_query(&[ - ("z", "a b~c/雪"), - ("a~", "second"), - ("a ", "first"), - ("amp", "&="), - ("dup", "z"), - ("dup", "a"), - ("empty", ""), - ("雪", "key"), - ]); - - assert_eq!( - query, - "%E9%9B%AA=key&a%20=first&=%26%3D&a~=second&dup=a&dup=z&empty=&z=a%20b~c%2F%E9%9B%AA" - ); -} - -#[test] -fn form_body_keeps_html_form_encoding() { - assert_eq!( - build_form_body(&[("Policy", "a b~c/雪")]), - "Policy=a+b%7Ec%2F%E9%9B%AA" - ); -} - -#[test] -fn duplicate_query_values_match_independent_sigv4_verification() { - let query = - build_canonical_query(&[("dup", "z z"), ("dup", "a+a"), ("dup", "雪"), ("empty", "")]); - assert_eq!(query, "dup=%E9%9B%AA&dup=a%2Ba&dup=z%20z&empty="); - - let client = RustfsAdminClient::new_with_base_url( - "https://rustfs.example.test:9000", - TEST_ACCESS_KEY, - TEST_SECRET_KEY, - ); - let signed = client - .sign_request("GET", "/synthetic", &query, "", None, ADMIN_SIGNING_SERVICE) - .unwrap(); - let request = CapturedRequest { - method: "GET".to_string(), - path: "/synthetic".to_string(), - query, - body: String::new(), - host: "rustfs.example.test:9000".to_string(), - content_type: String::new(), - amz_date: signed.amz_date, - payload_hash: signed.payload_hash, - authorization: signed.authorization, - }; - - assert_sigv4_matches_wire(&request, ADMIN_SIGNING_SERVICE); -} - -fn secret_with_fields(fields: Vec<(&str, &[u8])>) -> corev1::Secret { - let mut data = BTreeMap::new(); - for (key, value) in fields { - data.insert(key.to_string(), ByteString(value.to_vec())); - } - - corev1::Secret { - data: Some(data), - ..Default::default() - } -} - -fn assert_oversized_upstream_body_hidden(err: RustfsClientError) { - assert_eq!( - err.to_string(), - format!( - "upstream returned 502 Bad Gateway: response body exceeded {MAX_UPSTREAM_ERROR_BODY_BYTES} bytes" - ) - ); -} - -#[test] -fn tls_tenant_base_url_uses_custom_cluster_domain() { - let mut tenant = crate::tests::create_test_tenant(None, None); - tenant.metadata.name = Some("prod-rustfs".to_string()); - tenant.metadata.namespace = Some("mse".to_string()); - - assert_eq!( - tls_tenant_base_url(&tenant, "k8s.mse.cloud").unwrap(), - "https://prod-rustfs-hl.mse.svc.k8s.mse.cloud:9000" - ); -} - -#[test] -fn extract_credentials_reports_missing_access_key() { - let secret = secret_with_fields(vec![("secretkey", b"sekret")]); - - let err = extract_credentials(secret.data.as_ref()).expect_err("expected missing access key"); - assert!(matches!( - err, - RustfsClientError::MissingCredentialKey { key: "accesskey" } - )); -} - -#[test] -fn extract_credentials_reports_non_utf8_access_key() { - let secret = secret_with_fields(vec![("accesskey", &[0xff, 0xfe]), ("secretkey", b"sekret")]); - - let err = extract_credentials(secret.data.as_ref()).expect_err("expected invalid utf8"); - assert!(matches!( - err, - RustfsClientError::InvalidCredentialValue { key: "accesskey" } - )); -} - -#[test] -fn extract_credentials_reports_missing_secret_key() { - let secret = secret_with_fields(vec![("accesskey", b"access")]); - - let err = extract_credentials(secret.data.as_ref()).expect_err("expected missing secret key"); - assert!(matches!( - err, - RustfsClientError::MissingCredentialKey { key: "secretkey" } - )); -} - -#[test] -fn extract_credentials_reports_non_utf8_secret_key() { - let secret = secret_with_fields(vec![("accesskey", b"access"), ("secretkey", &[0xff, 0xfe])]); - - let err = extract_credentials(secret.data.as_ref()).expect_err("expected invalid utf8"); - assert!(matches!( - err, - RustfsClientError::InvalidCredentialValue { key: "secretkey" } - )); -} - -#[test] -fn extract_credentials_reports_empty_secret_key() { - let secret = secret_with_fields(vec![("accesskey", b"abc"), ("secretkey", b"")]); - - let err = extract_credentials(secret.data.as_ref()).expect_err("expected empty secret key"); - assert!(matches!( - err, - RustfsClientError::EmptyCredentialValue { key: "secretkey" } - )); -} - -#[test] -fn parse_assume_role_xml_success_and_failure() { - let body_ok = "AKISECTOKEN2026-01-01T00:00:00Z"; - let parsed = - parse_assume_role_response(body_ok).expect("valid assume role response should parse"); - - assert_eq!(parsed.access_key_id, "AKI"); - assert_eq!(parsed.secret_access_key, "SEC"); - assert_eq!(parsed.session_token, "TOKEN"); - assert_eq!(parsed.expiration, "2026-01-01T00:00:00Z"); - - assert!(parse_assume_role_response("").is_none()); -} - -#[test] -fn unexpected_status_includes_upstream_xml_error_summary() { - let err = RustfsClientError::unexpected_status_with_body( - StatusCode::BAD_REQUEST, - r#"InvalidRequestinvalid resource: unknown "*"abc"#, - ); - - let message = err.to_string(); - assert_eq!( - message, - r#"upstream returned 400 Bad Request: InvalidRequest: invalid resource: unknown "*""# - ); - assert!(!message.contains("")); -} - -#[test] -fn unexpected_status_includes_upstream_json_error_summary() { - let err = RustfsClientError::unexpected_status_with_body( - StatusCode::BAD_REQUEST, - r#"{"code":"InvalidRequest","message":"policy Resource must use ARN form"}"#, - ); - - assert_eq!( - err.to_string(), - "upstream returned 400 Bad Request: InvalidRequest: policy Resource must use ARN form" - ); -} - -#[test] -fn unexpected_status_redacts_sensitive_upstream_error_summary() { - let err = RustfsClientError::unexpected_status_with_body( - StatusCode::BAD_REQUEST, - r#"{"code":"InvalidRequest","message":"secretkey: SK_TEST clientSecret: oidc-secret SecretAccessKey: SK_STS AccessKeyId: AKIA_STS SK_XML AKIA_XML"}"#, - ); - - let message = err.to_string(); - assert!(message.contains("secretkey: ")); - assert!(message.contains("clientSecret: ")); - assert!(message.contains("SecretAccessKey: ")); - assert!(message.contains("AccessKeyId: ")); - assert!(message.contains("")); - assert!(message.contains("")); - assert!(!message.contains("SK_TEST")); - assert!(!message.contains("oidc-secret")); - assert!(!message.contains("SK_STS")); - assert!(!message.contains("AKIA_STS")); - assert!(!message.contains("SK_XML")); - assert!(!message.contains("AKIA_XML")); -} - -#[test] -fn unexpected_status_hides_truncated_unstructured_response_body() { - let retained_body = "x".repeat(MAX_UPSTREAM_ERROR_BODY_BYTES); - let err = RustfsClientError::unexpected_status_with_limited_body( - StatusCode::BAD_GATEWAY, - &retained_body, - true, - ); - - assert_eq!( - err.to_string(), - format!( - "upstream returned 502 Bad Gateway: response body exceeded {MAX_UPSTREAM_ERROR_BODY_BYTES} bytes" - ) - ); -} - -#[tokio::test] -async fn unexpected_response_preserves_exact_limit_unstructured_response_body() { - let body = "x".repeat(MAX_UPSTREAM_ERROR_BODY_BYTES); - let router = Router::new().route( - ADD_USER_PATH, - put(move || { - let body = body.clone(); - async move { (StatusCode::BAD_GATEWAY, body) } - }), - ); - - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - - let client = RustfsAdminClient::new_with_base_url(format!("http://{addr}"), "access", "secret"); - let err = client - .add_user("app-user", "secret123") - .await - .expect_err("exact limit body should still report the retained body"); - - let message = err.to_string(); - assert!(message.contains("upstream returned 502 Bad Gateway")); - assert!(!message.contains("response body exceeded")); - - server.abort(); -} - -#[tokio::test] -async fn unexpected_response_hides_over_limit_unstructured_response_body() { - let body = "x".repeat(MAX_UPSTREAM_ERROR_BODY_BYTES + 1); - let router = Router::new().route( - ADD_USER_PATH, - put(move || { - let body = body.clone(); - async move { (StatusCode::BAD_GATEWAY, body) } - }), - ); - - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - - let client = RustfsAdminClient::new_with_base_url(format!("http://{addr}"), "access", "secret"); - let err = client - .add_user("app-user", "secret123") - .await - .expect_err("oversized body should be hidden"); - - assert_oversized_upstream_body_hidden(err); - - server.abort(); -} - -#[derive(Clone, Default)] -struct Capture { - method: Arc>, - path: Arc>, - query: Arc>, - body: Arc>, - host: Arc>, - content_type: Arc>, - amz_date: Arc>, - payload_hash: Arc>, - authorization: Arc>, - object_lock_header: Arc>, -} - -#[derive(Debug)] -struct CapturedRequest { - method: String, - path: String, - query: String, - body: String, - host: String, - content_type: String, - amz_date: String, - payload_hash: String, - authorization: String, -} - -impl Capture { - async fn request(&self) -> CapturedRequest { - CapturedRequest { - method: self.method.lock().await.clone(), - path: self.path.lock().await.clone(), - query: self.query.lock().await.clone(), - body: self.body.lock().await.clone(), - host: self.host.lock().await.clone(), - content_type: self.content_type.lock().await.clone(), - amz_date: self.amz_date.lock().await.clone(), - payload_hash: self.payload_hash.lock().await.clone(), - authorization: self.authorization.lock().await.clone(), - } - } -} - -fn request_header(req: &Request, name: &str) -> String { - req.headers() - .get(name) - .and_then(|value| value.to_str().ok()) - .unwrap_or("") - .to_string() -} - -async fn capture_signed_request(capture: &Capture, req: Request) { - let method = req.method().as_str().to_string(); - let path = req.uri().path().to_string(); - let query = req.uri().query().unwrap_or("").to_string(); - let host = request_header(&req, "host"); - let content_type = request_header(&req, "content-type"); - let amz_date = request_header(&req, "x-amz-date"); - let payload_hash = request_header(&req, "x-amz-content-sha256"); - let authorization = request_header(&req, "authorization"); - let body = axum::body::to_bytes(req.into_body(), usize::MAX) - .await - .unwrap(); - let body = String::from_utf8(body.to_vec()).unwrap(); - - *capture.method.lock().await = method; - *capture.path.lock().await = path; - *capture.query.lock().await = query; - *capture.body.lock().await = body; - *capture.host.lock().await = host; - *capture.content_type.lock().await = content_type; - *capture.amz_date.lock().await = amz_date; - *capture.payload_hash.lock().await = payload_hash; - *capture.authorization.lock().await = authorization; -} - -fn assert_sigv4_matches_wire(request: &CapturedRequest, service: &str) { - let calculated_payload_hash = sha256_hex(request.body.as_bytes()); - assert_eq!(request.payload_hash, calculated_payload_hash); - - let signed_header_names = if request.content_type.is_empty() { - "host;x-amz-content-sha256;x-amz-date" - } else { - "content-type;host;x-amz-content-sha256;x-amz-date" - }; - let mut canonical_headers = String::new(); - if !request.content_type.is_empty() { - canonical_headers.push_str("content-type:"); - canonical_headers.push_str(request.content_type.trim()); - canonical_headers.push('\n'); - } - canonical_headers.push_str("host:"); - canonical_headers.push_str(request.host.trim()); - canonical_headers.push_str("\nx-amz-content-sha256:"); - canonical_headers.push_str(request.payload_hash.trim()); - canonical_headers.push_str("\nx-amz-date:"); - canonical_headers.push_str(request.amz_date.trim()); - canonical_headers.push('\n'); - - let canonical_request = format!( - "{}\n{}\n{}\n{}\n{}\n{}", - request.method, - request.path, - request.query, - canonical_headers, - signed_header_names, - request.payload_hash - ); - let date_stamp = request - .amz_date - .get(..8) - .expect("x-amz-date must start with YYYYMMDD"); - let credential_scope = format!("{date_stamp}/{TEST_REGION}/{service}/aws4_request"); - let string_to_sign = format!( - "AWS4-HMAC-SHA256\n{}\n{}\n{}", - request.amz_date, - credential_scope, - sha256_hex(canonical_request.as_bytes()) - ); - let signing_key = - derive_signing_key(TEST_SECRET_KEY, date_stamp, TEST_REGION, service).unwrap(); - let signature = hmac_sha256_hex(&signing_key, &string_to_sign).unwrap(); - let expected_authorization = format!( - "AWS4-HMAC-SHA256 Credential={TEST_ACCESS_KEY}/{credential_scope}, SignedHeaders={signed_header_names}, Signature={signature}" - ); - - assert_eq!(request.authorization, expected_authorization); -} - -#[tokio::test] -async fn assume_role_request_targets_root_path_and_action_is_assume_role() { - let capture = Capture::default(); - let route_capture = capture.clone(); - - let router = Router::new().route( - "/", - post( - move |State(c): State, req: Request| async move { - capture_signed_request(&c, req).await; - - let response = - "AKISECTOKEN2026-01-01T00:00:00Z"; - (StatusCode::OK, response) - }, - ), - ) - .with_state(route_capture.clone()); - - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - - let client = RustfsAdminClient::new_with_base_url(format!("http://{addr}"), "access", "secret"); - - let creds = client - .assume_role(Some(r#"{"Statement":[{"Resource":"a b~+/雪"}]}"#), 3600) - .await - .unwrap(); - assert_eq!(creds.access_key_id, "AKI"); - - let request = capture.request().await; - assert_eq!(request.path, "/"); - assert_eq!( - request.body, - "Action=AssumeRole&DurationSeconds=3600&Policy=%7B%22Statement%22%3A%5B%7B%22Resource%22%3A%22a+b%7E%2B%2F%E9%9B%AA%22%7D%5D%7D&Version=2011-06-15" - ); - assert!(request.query.is_empty()); - assert_eq!(request.content_type, FORM_CONTENT_TYPE); - assert_sigv4_matches_wire(&request, STS_SIGNING_SERVICE); - - server.abort(); -} - -#[tokio::test] -async fn info_canned_policy_uses_expected_path_and_query() { - let capture = Capture::default(); - let route_capture = capture.clone(); - - let router = Router::new() - .route( - "/rustfs/admin/v3/info-canned-policy", - get( - move |State(c): State, req: Request| async move { - let path = req.uri().path().to_string(); - let query = req.uri().query().unwrap_or("").to_string(); - let authorization = req - .headers() - .get("authorization") - .and_then(|value| value.to_str().ok()) - .unwrap_or("") - .to_string(); - - *c.path.lock().await = path; - *c.query.lock().await = query; - *c.authorization.lock().await = authorization; - - ( - StatusCode::OK, - "{\"policy_name\":\"tenant-policy\",\"policy\":{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"allow\",\"Effect\":\"Allow\"}]}}", - ) - }, - ), - ) - .with_state(route_capture.clone()); - - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - - let client = RustfsAdminClient::new_with_base_url(format!("http://{addr}"), "access", "secret"); - - let policy = client.get_canned_policy("tenant-policy").await.unwrap(); - let policy_value = serde_json::from_str::(&policy).unwrap(); - assert_eq!(policy_value["Version"], "2012-10-17"); - assert_eq!(policy_value["Statement"][0]["Sid"], "allow"); - - assert_eq!( - &*capture.path.lock().await, - "/rustfs/admin/v3/info-canned-policy" - ); - assert!(capture.query.lock().await.contains("name=tenant-policy")); - assert!( - capture - .authorization - .lock() - .await - .contains("/s3/aws4_request") - ); - - server.abort(); -} - -#[tokio::test] -async fn list_canned_policies_extracts_policy_document_and_canonicalizes_json() { - let capture = Capture::default(); - let route_capture = capture.clone(); - - let router = Router::new() - .route( - LIST_CANNED_POLICIES_PATH, - get( - move |State(c): State, req: Request| async move { - let path = req.uri().path().to_string(); - let query = req.uri().query().unwrap_or("").to_string(); - - *c.path.lock().await = path; - *c.query.lock().await = query; - - ( - StatusCode::OK, - serde_json::json!({ - "tenant-policy": { - "policy_name":"tenant-policy", - "policy":{ - "Statement": [{ - "Resource": "arn:aws:s3:::tenant", - "Effect": "Allow", - "Action": "s3:GetObject" - }], - "Version":"2012-10-17" - } - }, - "inline-policy": { - "Version": "2012-10-17", - "Statement": [{ - "Sid": "inline", - "Action": "s3:ListBucket", - "Effect": "Allow", - "Resource": ["arn:aws:s3:::tenant*"] - }] - } - }) - .to_string(), - ) - }, - ), - ) - .with_state(route_capture.clone()); - - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - - let client = RustfsAdminClient::new_with_base_url(format!("http://{addr}"), "access", "secret"); - let policies = client.list_canned_policies().await.unwrap(); - - let tenant_policy = serde_json::from_str::(&policies["tenant-policy"]).unwrap(); - assert_eq!(tenant_policy["Version"], "2012-10-17"); - assert_eq!(tenant_policy["Statement"][0]["Action"], "s3:GetObject"); - - let inline_policy = serde_json::from_str::(&policies["inline-policy"]).unwrap(); - assert_eq!(inline_policy["Version"], "2012-10-17"); - assert_eq!(inline_policy["Statement"][0]["Sid"], "inline"); - assert_eq!(&*capture.path.lock().await, LIST_CANNED_POLICIES_PATH); - assert!(capture.query.lock().await.is_empty()); - - server.abort(); -} - -#[tokio::test] -async fn add_canned_policy_uses_expected_path_query_body_and_admin_signing() { - let capture = Capture::default(); - let route_capture = capture.clone(); - - let router = Router::new() - .route( - "/rustfs/admin/v3/add-canned-policy", - put( - move |State(c): State, req: Request| async move { - let path = req.uri().path().to_string(); - let query = req.uri().query().unwrap_or("").to_string(); - let authorization = req - .headers() - .get("authorization") - .and_then(|value| value.to_str().ok()) - .unwrap_or("") - .to_string(); - let body_bytes = axum::body::to_bytes(req.into_body(), usize::MAX) - .await - .unwrap(); - let body = String::from_utf8(body_bytes.to_vec()).unwrap(); - - *c.path.lock().await = path; - *c.query.lock().await = query; - *c.authorization.lock().await = authorization; - *c.body.lock().await = body; - - StatusCode::OK - }, - ), - ) - .with_state(route_capture.clone()); - - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - - let client = RustfsAdminClient::new_with_base_url(format!("http://{addr}"), "access", "secret"); - let policy = r#"{"Version":"2012-10-17","Statement":[]}"#; - - client - .add_canned_policy("tenant-policy", policy) - .await - .unwrap(); - - assert_eq!( - &*capture.path.lock().await, - "/rustfs/admin/v3/add-canned-policy" - ); - assert!(capture.query.lock().await.contains("name=tenant-policy")); - assert_eq!(&*capture.body.lock().await, policy); - assert!( - capture - .authorization - .lock() - .await - .contains("/s3/aws4_request") - ); - - server.abort(); -} - -#[tokio::test] -async fn add_canned_policy_reports_upstream_policy_parse_error() { - let router = Router::new().route( - "/rustfs/admin/v3/add-canned-policy", - put(|| async { - ( - StatusCode::BAD_REQUEST, - r#"InvalidRequestinvalid resource: unknown "*""#, - ) - }), - ); - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - - let client = RustfsAdminClient::new_with_base_url(format!("http://{addr}"), "access", "secret"); - let policy = r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:*","Resource":"*"}]}"#; - let err = client - .add_canned_policy("tenant-policy", policy) - .await - .expect_err("invalid RustFS policy should include upstream parse details"); - - let message = err.to_string(); - assert!(message.contains("upstream returned 400 Bad Request")); - assert!(message.contains(r#"InvalidRequest: invalid resource: unknown "*""#)); - assert!(!message.contains("")); - - server.abort(); -} - -#[tokio::test] -async fn server_info_uses_expected_path_and_parses_wrapped_health_fields() { - let capture = Capture::default(); - let route_capture = capture.clone(); - - let router = Router::new() - .route( - SERVER_INFO_PATH, - get( - move |State(c): State, req: Request| async move { - let path = req.uri().path().to_string(); - let authorization = req - .headers() - .get("authorization") - .and_then(|value| value.to_str().ok()) - .unwrap_or("") - .to_string(); - - *c.path.lock().await = path; - *c.authorization.lock().await = authorization; - - ( - StatusCode::OK, - serde_json::json!({ - "info": { - "usage": {"size": 42}, - "backend": { - "onlineDisks": 3, - "offlineDisks": 1, - "standardSCParity": 2, - "totalSets": [1], - "totalDrivesPerSet": [4] - }, - "pools": { - "0": { - "0": { - "rawUsage": 100, - "rawCapacity": 400, - "usage": 50, - "objectsCount": 2, - "healDisks": 1 - } - } - } - }, - "admin_discovery": { - "runtimeCapabilities": "/rustfs/admin/v4/runtime/capabilities", - "clusterSnapshot": "/rustfs/admin/v4/cluster/snapshot", - "extensionsCatalog": "/rustfs/admin/v4/extensions/catalog" - }, - }) - .to_string(), - ) - }, - ), - ) - .with_state(route_capture.clone()); - - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - - let client = RustfsAdminClient::new_with_base_url(format!("http://{addr}"), "access", "secret"); - let info = client.server_info().await.unwrap(); - - let backend = info.backend.unwrap(); - assert_eq!(backend.online_disks, 3); - assert_eq!(backend.offline_disks, 1); - assert_eq!(backend.standard_sc_parity, Some(2)); - assert_eq!(info.usage.unwrap().size, 42); - assert_eq!(info.pools.unwrap()["0"]["0"].raw_capacity, 400); - assert_eq!(&*capture.path.lock().await, SERVER_INFO_PATH); - assert!( - capture - .authorization - .lock() - .await - .contains("/s3/aws4_request") - ); - - server.abort(); -} - -#[tokio::test] -async fn list_pools_parses_current_rustfs_pool_shape() { - let router = Router::new().route( - POOLS_LIST_PATH, - get(|| async { - ( - StatusCode::OK, - r#"[{"id":1,"cmdline":"http://tenant-pool-a-{0...3}.tenant-hl.ns.svc.cluster.local:9000/data/rustfs{0...3}","lastUpdate":"2026-05-20T00:00:00Z","totalSize":100,"currentSize":50,"usedSize":25,"used":25.0,"status":"running","decommissionInfo":{"startTime":"2026-05-20T00:00:00Z","complete":false,"failed":false,"canceled":false,"objectsDecommissioned":7,"objectsDecommissionedFailed":1,"bytesDecommissioned":9,"bytesDecommissionedFailed":2}}]"#, - ) - }), - ); - - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - - let client = RustfsAdminClient::new_with_base_url(format!("http://{addr}"), "access", "secret"); - - let pools = client.list_pools().await.unwrap(); - - assert_eq!(pools[0].id, 1); - assert_eq!(pools[0].status, "running"); - assert_eq!( - pools[0] - .decommission - .as_ref() - .and_then(|info| info.objects_decommissioned), - Some(7) - ); - - server.abort(); -} - -#[tokio::test] -async fn pool_decommission_start_uses_by_id_query_and_admin_signing() { - let capture = Capture::default(); - let route_capture = capture.clone(); - - let router = Router::new() - .route( - POOLS_DECOMMISSION_PATH, - post( - move |State(c): State, req: Request| async move { - *c.path.lock().await = req.uri().path().to_string(); - *c.query.lock().await = req.uri().query().unwrap_or("").to_string(); - *c.authorization.lock().await = req - .headers() - .get("authorization") - .and_then(|value| value.to_str().ok()) - .unwrap_or("") - .to_string(); - - StatusCode::OK - }, - ), - ) - .with_state(route_capture.clone()); - - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - - let client = RustfsAdminClient::new_with_base_url(format!("http://{addr}"), "access", "secret"); - - client.start_pool_decommission_by_id("1").await.unwrap(); - - assert_eq!(&*capture.path.lock().await, POOLS_DECOMMISSION_PATH); - assert_eq!(&*capture.query.lock().await, "by-id=true&pool=1"); - assert!( - capture - .authorization - .lock() - .await - .contains("/s3/aws4_request") - ); - - server.abort(); -} - -#[tokio::test] -async fn pool_status_uses_by_id_query_and_parses_decommission_info() { - let capture = Capture::default(); - let route_capture = capture.clone(); - - let router = Router::new() - .route( - POOLS_STATUS_PATH, - get( - move |State(c): State, req: Request| async move { - *c.path.lock().await = req.uri().path().to_string(); - *c.query.lock().await = req.uri().query().unwrap_or("").to_string(); - - ( - StatusCode::OK, - r#"{"id":1,"cmdline":"http://tenant-pool-a-{0...3}.tenant-hl.ns.svc.cluster.local:9000/data/rustfs{0...3}","lastUpdate":"2026-05-20T00:00:00Z","decommissionInfo":{"startTime":"2026-05-20T00:00:00Z","complete":true,"failed":false,"canceled":false,"objectsDecommissioned":10,"objectsDecommissionedFailed":0,"bytesDecommissioned":20,"bytesDecommissionedFailed":0}}"#, - ) - }, - ), - ) - .with_state(route_capture.clone()); - - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - - let client = RustfsAdminClient::new_with_base_url(format!("http://{addr}"), "access", "secret"); - - let status = client.pool_status_by_id("1").await.unwrap(); - - assert_eq!(status.id, 1); - assert_eq!(&*capture.path.lock().await, POOLS_STATUS_PATH); - assert_eq!(&*capture.query.lock().await, "by-id=true&pool=1"); - assert_eq!( - status.decommission.and_then(|info| info.complete), - Some(true) - ); - - server.abort(); -} - -#[tokio::test] -async fn add_user_uses_expected_path_query_and_body() { - let capture = Capture::default(); - let route_capture = capture.clone(); - - let router = Router::new() - .route( - ADD_USER_PATH, - put( - move |State(c): State, req: Request| async move { - capture_signed_request(&c, req).await; - StatusCode::OK - }, - ), - ) - .with_state(route_capture.clone()); - - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - - let client = RustfsAdminClient::new_with_base_url(format!("http://{addr}"), "access", "secret"); - client.add_user("app user~+/雪", "secret123").await.unwrap(); - - let request = capture.request().await; - assert_eq!(request.path, ADD_USER_PATH); - assert_eq!(request.query, "accessKey=app%20user~%2B%2F%E9%9B%AA"); - assert_eq!( - request.body, - r#"{"secretKey":"secret123","status":"enabled"}"# - ); - assert_eq!(request.content_type, JSON_CONTENT_TYPE); - assert_sigv4_matches_wire(&request, ADMIN_SIGNING_SERVICE); - - server.abort(); -} - -#[tokio::test] -async fn user_exists_limits_unexpected_error_response_body() { - let body = "x".repeat(MAX_UPSTREAM_ERROR_BODY_BYTES + 1); - let router = Router::new().route( - USER_INFO_PATH, - get(move || { - let body = body.clone(); - async move { (StatusCode::BAD_GATEWAY, body) } - }), - ); - - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - - let client = RustfsAdminClient::new_with_base_url(format!("http://{addr}"), "access", "secret"); - let err = client - .user_exists("app-user") - .await - .expect_err("unexpected user lookup error should hide oversized body"); - - assert_oversized_upstream_body_hidden(err); - - server.abort(); -} - -#[tokio::test] -async fn set_user_policy_uses_single_authoritative_mapping_call() { - let capture = Capture::default(); - let route_capture = capture.clone(); - - let router = Router::new() - .route( - SET_POLICY_PATH, - put( - move |State(c): State, req: Request| async move { - *c.path.lock().await = req.uri().path().to_string(); - *c.query.lock().await = req.uri().query().unwrap_or("").to_string(); - StatusCode::OK - }, - ), - ) - .with_state(route_capture.clone()); - - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - - let client = RustfsAdminClient::new_with_base_url(format!("http://{addr}"), "access", "secret"); - client - .set_user_policy( - "app-user", - &["app-readwrite".to_string(), "diagnostics".to_string()], - ) - .await - .unwrap(); - - assert_eq!(&*capture.path.lock().await, SET_POLICY_PATH); - assert_eq!( - &*capture.query.lock().await, - "isGroup=false&policyName=app-readwrite%2Cdiagnostics&userOrGroup=app-user" - ); - - server.abort(); -} - -#[tokio::test] -async fn set_user_policy_rejects_empty_policy_list() { - let client = RustfsAdminClient::new_with_base_url("http://127.0.0.1:1", "access", "secret"); - - let err = client - .set_user_policy("app-user", &[]) - .await - .expect_err("empty policy list should be rejected before request"); - - assert!(matches!(err, RustfsClientError::InvalidPolicyName)); -} - -#[tokio::test] -async fn bucket_object_lock_enabled_parses_enabled_response() { - let router = Router::new().route( - "/app-data", - get(|req: Request| async move { - assert_eq!(req.uri().query().unwrap_or(""), "object-lock="); - ( - StatusCode::OK, - "Enabled", - ) - }), - ); - - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - - let client = RustfsAdminClient::new_with_base_url(format!("http://{addr}"), "access", "secret"); - - assert!(client.bucket_object_lock_enabled("app-data").await.unwrap()); - - server.abort(); -} - -#[tokio::test] -async fn bucket_object_lock_enabled_limits_unexpected_error_response_body() { - let body = "x".repeat(MAX_UPSTREAM_ERROR_BODY_BYTES + 1); - let router = Router::new().route( - "/app-data", - get(move |req: Request| { - let body = body.clone(); - async move { - assert_eq!(req.uri().query().unwrap_or(""), "object-lock="); - (StatusCode::BAD_GATEWAY, body) - } - }), - ); - - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - - let client = RustfsAdminClient::new_with_base_url(format!("http://{addr}"), "access", "secret"); - let err = client - .bucket_object_lock_enabled("app-data") - .await - .expect_err("unexpected object-lock error should hide oversized body"); - - assert_oversized_upstream_body_hidden(err); - - server.abort(); -} - -#[tokio::test] -async fn create_bucket_sends_object_lock_header_and_region_body() { - let capture = Capture::default(); - let route_capture = capture.clone(); - - let router = Router::new() - .route( - "/app-data", - put( - move |State(c): State, req: Request| async move { - *c.path.lock().await = req.uri().path().to_string(); - *c.object_lock_header.lock().await = req - .headers() - .get("x-amz-bucket-object-lock-enabled") - .and_then(|value| value.to_str().ok()) - .unwrap_or("") - .to_string(); - let body_bytes = axum::body::to_bytes(req.into_body(), usize::MAX) - .await - .unwrap(); - *c.body.lock().await = String::from_utf8(body_bytes.to_vec()).unwrap(); - StatusCode::OK - }, - ), - ) - .with_state(route_capture.clone()); - - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - - let client = RustfsAdminClient::new_with_base_url(format!("http://{addr}"), "access", "secret"); - let result = client - .create_bucket("app-data", Some("us-west-2"), true) - .await - .unwrap(); - - assert_eq!(result, CreateBucketResult::Created); - assert_eq!(&*capture.path.lock().await, "/app-data"); - assert_eq!(&*capture.object_lock_header.lock().await, "true"); - assert!( - capture - .body - .lock() - .await - .contains("us-west-2") - ); - - server.abort(); -} - -#[tokio::test] -async fn create_bucket_limits_unexpected_error_response_body() { - let body = "x".repeat(MAX_UPSTREAM_ERROR_BODY_BYTES + 1); - let router = Router::new().route( - "/app-data", - put(move || { - let body = body.clone(); - async move { (StatusCode::BAD_GATEWAY, body) } - }), - ); - - let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) - .await - .unwrap(); - let addr = listener.local_addr().unwrap(); - let server = tokio::spawn(async move { axum::serve(listener, router).await.unwrap() }); - - let client = RustfsAdminClient::new_with_base_url(format!("http://{addr}"), "access", "secret"); - let err = client - .create_bucket("app-data", None, false) - .await - .expect_err("unexpected bucket create error should hide oversized body"); - - assert_oversized_upstream_body_hidden(err); - - server.abort(); -} - -#[test] -fn extract_canned_policy_document_accepts_raw_policy_document() { - let raw_policy = - "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Sid\":\"raw\",\"Effect\":\"Allow\"}]}"; - - let policy = extract_canned_policy_document(raw_policy).unwrap(); - - let policy_value = serde_json::from_str::(&policy).unwrap(); - assert_eq!(policy_value["Version"], "2012-10-17"); - assert_eq!(policy_value["Statement"][0]["Sid"], "raw"); -} diff --git a/src/utils.rs b/src/utils.rs index c227851c..394d907b 100755 --- a/src/utils.rs +++ b/src/utils.rs @@ -12,5 +12,4 @@ // See the License for the specific language governing permissions and // limitations under the License. -pub(crate) mod sanitize; pub mod tls;