diff --git a/CHANGELOG.md b/CHANGELOG.md index db3602a8..b73183fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **Equality search on encrypted JSON fields**: `WHERE col -> 'field' = value` and + `WHERE jsonb_path_query_first(col, 'field') = value` (and their `<>` forms) now + match against the encrypted STE-vec element for that field, for both the extended + and simple query protocols. Works with term filters (e.g. downcase) for + case-insensitive matching. + ## [2.2.4] - 2026-06-18 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index d2e0c684..9ff92bcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,7 +23,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "generic-array", ] @@ -39,6 +39,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", + "zeroize", +] + [[package]] name = "aes-gcm-siv" version = "0.11.1" @@ -192,12 +207,6 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" -[[package]] -name = "array-init" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" - [[package]] name = "arrayref" version = "0.3.9" @@ -321,9 +330,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "aws-lc-rs" -version = "1.16.1" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -332,9 +341,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.38.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -544,6 +553,15 @@ 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 = "block-modes" version = "0.9.1" @@ -737,15 +755,15 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "inout", ] [[package]] name = "cipherstash-client" -version = "0.34.1-alpha.4" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3d67cc26d8422509d2c20644576124e7344a4bf14ded06c7affa8dc18aabca" +checksum = "af42b947b1abab17a1aa23d194a1b0154b5c338453e93ac70e2ab644407b907c" dependencies = [ "aes-gcm-siv", "anyhow", @@ -753,9 +771,9 @@ dependencies = [ "async-trait", "base16ct", "base64", + "base64ct", "base85", "blake3", - "cfg-if", "chrono", "cipherstash-config", "cipherstash-core", @@ -771,16 +789,12 @@ dependencies = [ "log", "miette", "opaque-debug", - "open 3.2.0", "orderable-bytes", "ore-rs", "percent-encoding", "rand 0.8.6", - "recipher 0.2.2", + "recipher 0.2.3", "reqwest", - "reqwest-middleware", - "reqwest-retry", - "reqwest-tracing", "rmp-serde", "rust-stemmers", "rust_decimal", @@ -800,7 +814,7 @@ dependencies = [ "url", "uuid", "vitaminc", - "vitaminc-protected", + "vitaminc-protected 0.2.0-pre.1", "winnow 0.6.26", "zeroize", "zerokms-protocol", @@ -808,9 +822,9 @@ dependencies = [ [[package]] name = "cipherstash-config" -version = "0.34.1-alpha.4" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "283fa04db19f9bf2cb2f09e8c1505a15560310bc50fdc066734072c616aa8ca9" +checksum = "e255a8d6c38e4954146a02d07a109907cef39150073c3be4180a19b22ea6cd6c" dependencies = [ "bitflags", "serde", @@ -820,10 +834,11 @@ dependencies = [ [[package]] name = "cipherstash-core" -version = "0.34.1-alpha.4" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5bb7181053c3fc35569e0800fa7510c85ee2bffee21abbd2a7aeb498f5f0972" +checksum = "a0fb5c5b630e7fc2c716f25558ca8976b48d91b5ffb136c44d0d59e04f26c14d" dependencies = [ + "getrandom 0.2.15", "hmac", "lazy_static", "num-bigint", @@ -881,7 +896,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", - "vitaminc-protected", + "vitaminc-protected 0.1.0-pre4.2", "x509-parser", ] @@ -954,15 +969,14 @@ checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "cllw-ore" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d007a5be83ae12adbd17543f9631d64090d761c029d2f8f7eb8f8ddb2a87caf" +checksum = "4f73a23cbc15404d9b314c03b16a888f798dbc681bceeb2e18674f602f9da02d" dependencies = [ "blake3", "chrono", "hex", "orderable-bytes", - "postgres-types", "rust_decimal", "subtle", "thiserror 1.0.69", @@ -977,7 +991,7 @@ checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" dependencies = [ "cipher", "dbl", - "digest", + "digest 0.10.7", ] [[package]] @@ -1157,6 +1171,15 @@ dependencies = [ "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 = "ctr" version = "0.9.2" @@ -1168,9 +1191,9 @@ dependencies = [ [[package]] name = "cts-common" -version = "0.34.1-alpha.4" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b26644e630f2e690194c6b61f5b613b768061750f2060cf4db73ddb8058d284" +checksum = "4fb189dfbe0822d67563bae6ede04a8d4c8dcd5de960c3fd41c6f7d2c3de2720" dependencies = [ "arrayvec", "axum", @@ -1182,6 +1205,7 @@ dependencies = [ "diesel", "either", "fake 3.1.0", + "getrandom 0.4.2", "http", "miette", "nom 8.0.0", @@ -1388,11 +1412,21 @@ 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.6", "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", + "crypto-common 0.2.2", +] + [[package]] name = "dirs" version = "4.0.0" @@ -1837,11 +1871,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.0", "wasip2", "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", ] [[package]] @@ -1976,7 +2022,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -2025,6 +2071,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -2565,7 +2620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -2847,16 +2902,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "open" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2078c0039e6a54a0c42c28faa984e115fb4c2d5bf2208f77d1961002df8576f8" -dependencies = [ - "pathdiff", - "windows-sys 0.42.0", -] - [[package]] name = "open" version = "5.3.3" @@ -3114,7 +3159,6 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613283563cd90e1dfc3518d548caee47e0e725455ed619881f5cf21f36de4b48" dependencies = [ - "array-init", "bytes", "chrono", "fallible-iterator", @@ -3388,9 +3432,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.2", @@ -3491,13 +3535,13 @@ dependencies = [ [[package]] name = "recipher" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b3561e1082283a4c064635b7886aa4d24db57a43ac31c55930c10797ab5cdeb" +checksum = "9398dce78ddfce08f93e9d9a3ac64d9b0a4fed478c0a82003c6e4c90dc245125" dependencies = [ "aes", - "async-trait", "cmac", + "getrandom 0.2.15", "hex", "hex-literal", "opaque-debug", @@ -3634,73 +3678,12 @@ dependencies = [ "web-sys", ] -[[package]] -name = "reqwest-middleware" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "199dda04a536b532d0cc04d7979e39b1c763ea749bf91507017069c00b96056f" -dependencies = [ - "anyhow", - "async-trait", - "http", - "reqwest", - "serde", - "thiserror 2.0.18", - "tower-service", -] - -[[package]] -name = "reqwest-retry" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe2412db2af7d2268e7a5406be0431f37d9eb67ff390f35b395716f5f06c2eaa" -dependencies = [ - "anyhow", - "async-trait", - "futures", - "getrandom 0.2.15", - "http", - "hyper", - "reqwest", - "reqwest-middleware", - "retry-policies", - "thiserror 2.0.18", - "tokio", - "tracing", - "wasmtimer", -] - -[[package]] -name = "reqwest-tracing" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5c1a1510677d43dce9e9c0c07fc5db8772c0e5a43e4f9cef75a11affa05a578" -dependencies = [ - "anyhow", - "async-trait", - "getrandom 0.2.15", - "http", - "matchit", - "reqwest", - "reqwest-middleware", - "tracing", -] - [[package]] name = "resolv-conf" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" -[[package]] -name = "retry-policies" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a4bd6027df676bcb752d3724db0ea3c0c5fc1dd0376fec51ac7dcaf9cc69be" -dependencies = [ - "rand 0.9.2", -] - [[package]] name = "ring" version = "0.17.14" @@ -4192,7 +4175,7 @@ checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -4339,13 +4322,16 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "stack-auth" -version = "0.34.1-alpha.4" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677a79b9f48a194a8fc24870d27af6b7c62280ac5fcf85967e12165f16690d9d" dependencies = [ "aquamarine", + "base64", "cts-common", "jsonwebtoken", "miette", - "open 5.3.3", + "open", "reqwest", "serde", "serde_json", @@ -4356,16 +4342,17 @@ dependencies = [ "url", "uuid", "vitaminc", - "vitaminc-protected", + "vitaminc-protected 0.2.0-pre.1", + "web-time", "zeroize", "zerokms-protocol", ] [[package]] name = "stack-profile" -version = "0.34.1-alpha.4" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd61bc4129d2258ec1ba89d742558308560fc0f585f9c24d478685def8efd14" +checksum = "47f49a439a0bced8c2ff9b6e1e2cf448ec3e923fe9acdc6f40d80c796a208f21" dependencies = [ "dirs", "gethostname", @@ -4943,9 +4930,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.18.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unarray" @@ -5016,7 +5003,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "subtle", ] @@ -5095,9 +5082,11 @@ checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ "atomic", "getrandom 0.3.2", + "js-sys", "md-5", "serde", "sha1_smol", + "wasm-bindgen", ] [[package]] @@ -5150,39 +5139,40 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc" -version = "0.1.0-pre4.2" +version = "0.2.0-pre.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c8b739a2cb1e528e77a69267728532f52d2d5ce18ae2839e26c797859fe9015" +checksum = "d69481bc78bc3227d6c70d8aae6437c79badbf54fd9ec90c1b4ae2553068a989" dependencies = [ "vitaminc-aead", "vitaminc-encrypt", - "vitaminc-protected", + "vitaminc-protected 0.2.0-pre.1", "vitaminc-random", "vitaminc-traits", ] [[package]] name = "vitaminc-aead" -version = "0.1.0-pre4.2" +version = "0.2.0-pre.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c29cef4d4b0d018c4223d366017d2a9756012acf76e25011aaca877f3c74904" +checksum = "be80f3a3d83e69a786b97a831d660449a0437ccac3b3e369bf590afcb45569b0" dependencies = [ "bytes", "serde", - "vitaminc-protected", + "vitaminc-protected 0.2.0-pre.1", "vitaminc-random", "zeroize", ] [[package]] name = "vitaminc-encrypt" -version = "0.1.0-pre4.2" +version = "0.2.0-pre.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e3869aaf60ebb95ccbdfcf003985132325b4d1ac6f5d945ad2fbb9149afd3a" +checksum = "7477ef8ac925a75aacf5dbddfd4b17fd32f35ee9fb4a7c45ac3db80fd9ad4006" dependencies = [ + "aes-gcm", "aws-lc-rs", "vitaminc-aead", - "vitaminc-protected", + "vitaminc-protected 0.2.0-pre.1", "vitaminc-random", "zeroize", ] @@ -5194,11 +5184,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af693c39d3cd1c818ef6267539433c6ceca87840b12d24124adbc9c8ecba1709" dependencies = [ "bitvec", - "digest", + "digest 0.10.7", + "serde", + "serde_bytes", + "subtle", + "vitaminc-protected-derive 0.1.0-pre4.2", + "zeroize", +] + +[[package]] +name = "vitaminc-protected" +version = "0.2.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8472e2b76b5dedaf429708393964c3cc6f7ee40e6a43ed420288e3e4900c6af" +dependencies = [ + "bitvec", + "digest 0.11.3", "serde", "serde_bytes", "subtle", - "vitaminc-protected-derive", + "vitaminc-protected-derive 0.2.0-pre.1", "zeroize", ] @@ -5213,24 +5218,36 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "vitaminc-protected-derive" +version = "0.2.0-pre.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b01e1715676d8bf606314c2a51df0793c01bd743bae4bc00643d68f766ee1e91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "vitaminc-random" -version = "0.1.0-pre4.2" +version = "0.2.0-pre.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea9de431cb93359d293ec7e70d05d87117a57f34bfc5bc94f040b81d4dd1afd6" +checksum = "b0785c13f839240523ba8db6535384a5e8d4fe2b2f28bbddcfcb5fd6de825996" dependencies = [ - "rand 0.10.0", + "getrandom 0.4.2", + "rand 0.10.1", "thiserror 2.0.18", - "vitaminc-protected", + "vitaminc-protected 0.2.0-pre.1", "vitaminc-random-derives", "zeroize", ] [[package]] name = "vitaminc-random-derives" -version = "0.1.0-pre4.2" +version = "0.2.0-pre.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49d33ac4682235551d25c874525c20e03d4c863b39f556391f52f7a2083bfbdf" +checksum = "01e750eefb1f49940f589b2d397e2323d5df4b62bfb33b4e40e1d20a35c3f167" dependencies = [ "proc-macro2", "quote", @@ -5239,16 +5256,16 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.1.0-pre4.2" +version = "0.2.0-pre.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c25a9e51d24c3befddd71e907dd4ae9f21cfbaae065fb0ef5202e5d21cd198d0" +checksum = "3794e2c028cff00f40caea05ab6dce38181a94e13c0aaee640e7b867369780eb" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.18", - "vitaminc-protected", + "vitaminc-protected 0.2.0-pre.1", "vitaminc-random", "zeroize", ] @@ -5429,20 +5446,6 @@ dependencies = [ "semver", ] -[[package]] -name = "wasmtimer" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" -dependencies = [ - "futures", - "js-sys", - "parking_lot", - "pin-utils", - "slab", - "wasm-bindgen", -] - [[package]] name = "web-sys" version = "0.3.77" @@ -5520,7 +5523,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.48.0", ] [[package]] @@ -5666,21 +5669,6 @@ dependencies = [ "windows-link 0.1.1", ] -[[package]] -name = "windows-sys" -version = "0.42.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-sys" version = "0.45.0" @@ -6275,15 +6263,16 @@ dependencies = [ [[package]] name = "zerokms-protocol" -version = "0.12.9" +version = "0.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a2f045e2ee975a3d448419245c4621ea8844d2a004c63a96277181dc7cf8483" +checksum = "3435005f9a76f20ba27158f49e8dde391d41b9557f12c6422a14e8e9ccda094d" dependencies = [ "base64", "cipherstash-config", "const-hex", "cts-common", "fake 2.10.0", + "getrandom 0.2.15", "opaque-debug", "rand 0.8.6", "serde", diff --git a/Cargo.toml b/Cargo.toml index 5cbfa718..a6a7e142 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,6 @@ [workspace] resolver = "2" members = ["packages/*"] -# Vendored crate is consumed only via [patch.crates-io] below, not as a member. -exclude = ["vendor/stack-auth"] [workspace.package] version = "2.2.4" @@ -45,9 +43,9 @@ debug = true [workspace.dependencies] sqltk = { version = "0.10.0" } -cipherstash-client = { version = "=0.34.1-alpha.4" } -cipherstash-config = { version = "=0.34.1-alpha.4" } -cts-common = { version = "=0.34.1-alpha.4" } +cipherstash-client = { version = "0.38.0" } +cipherstash-config = { version = "0.38.0" } +cts-common = { version = "0.38.0" } thiserror = "2.0.9" tokio = { version = "1.44.2", features = ["full"] } @@ -58,13 +56,3 @@ tracing-subscriber = { version = "^0.3.20", features = [ "env-filter", "std", ] } - -# HOTFIX (CIP-3159): backport the stack-auth token-refresh CancelGuard fix onto -# the 0.34.1-alpha.4 source that cipherstash-client 0.34.1-alpha.4 pins. Without -# this, a cancelled get_token() future could strand `refresh_in_progress = true`, -# wedging all later refreshes and causing ZeroKMS "Request not authorized" exactly -# ~15 min (token TTL) after startup. The patch keeps version 0.34.1-alpha.4 so it -# satisfies cipherstash-client's exact pin while replacing the registry source. -# Remove once Proxy moves to a cipherstash-client built against stack-auth >= 0.36.0. -[patch.crates-io] -stack-auth = { path = "vendor/stack-auth" } diff --git a/mise.local.example.toml b/mise.local.example.toml index 6e72948a..39e82bb2 100644 --- a/mise.local.example.toml +++ b/mise.local.example.toml @@ -15,7 +15,7 @@ CS_CLIENT_KEY = "client-key" CS_CLIENT_ID = "client-id" # The release of EQL that the proxy tests will use and releases will be built with -CS_EQL_VERSION = "eql-2.3.0-pre.3" +CS_EQL_VERSION = "eql-2.3.1" # TLS variables are required for providing TLS to Proxy's clients. # CS_TLS__TYPE can be either "Path" or "Pem" (case-sensitive). diff --git a/mise.toml b/mise.toml index 75110dd1..1827980d 100644 --- a/mise.toml +++ b/mise.toml @@ -34,7 +34,7 @@ CS_PROXY__HOST = "host.docker.internal" # Misc DOCKER_CLI_HINTS = "false" # Please don't show us What's Next. -CS_EQL_VERSION = "eql-2.3.0-pre.3" +CS_EQL_VERSION = "eql-2.3.1" [tools] diff --git a/packages/cipherstash-proxy-integration/src/map_ope_index_order.rs b/packages/cipherstash-proxy-integration/src/map_ope_index_order.rs index 18a9d83d..df8bc666 100644 --- a/packages/cipherstash-proxy-integration/src/map_ope_index_order.rs +++ b/packages/cipherstash-proxy-integration/src/map_ope_index_order.rs @@ -1,3 +1,14 @@ +// TODO(CIP-3280): add a NULL-invariant end-to-end test for the scalar OPE +// `decode(col->>'op','hex')` ordering rewrite (eql-mapper's +// `RewriteScalarOpeOrdering`, CIP-3283). The test should confirm that rows whose +// OPE column is NULL keep their `ORDER BY ... NULLS FIRST|LAST` placement after +// the rewrite, and that `<`/`<=`/`>`/`>=` comparisons against a NULL operand +// behave as plain SQL three-valued logic. It belongs in this module alongside the +// existing `run_order_test` cases. +// +// Blocked on the unpublished CIP-3280 client: scalar OPE query terms cannot yet +// carry an `op` slot end-to-end, so the rewritten `... ->> 'op'` extraction has +// no value to read at runtime. Implement once that client ships. #[cfg(test)] mod tests { use crate::common::{ diff --git a/packages/cipherstash-proxy/src/error.rs b/packages/cipherstash-proxy/src/error.rs index aee61575..45c0a5e0 100644 --- a/packages/cipherstash-proxy/src/error.rs +++ b/packages/cipherstash-proxy/src/error.rs @@ -340,9 +340,9 @@ impl From for EncryptError { cipherstash_client::eql::EqlError::ColumnConfigurationMismatch { table, column } => { Self::ColumnConfigurationMismatch { table, column } } - cipherstash_client::eql::EqlError::CouldNotDecryptDataForKeyset { keyset_id } => { - Self::CouldNotDecryptDataForKeyset { keyset_id } - } + cipherstash_client::eql::EqlError::CouldNotDecryptDataForKeyset { + keyset_id, .. + } => Self::CouldNotDecryptDataForKeyset { keyset_id }, cipherstash_client::eql::EqlError::InvalidIndexTerm => Self::InvalidIndexTerm, cipherstash_client::eql::EqlError::MissingCiphertext(identifier) => { Self::ColumnCouldNotBeDeserialised { diff --git a/packages/cipherstash-proxy/src/lib.rs b/packages/cipherstash-proxy/src/lib.rs index 2d4ac8fa..5d230a70 100644 --- a/packages/cipherstash-proxy/src/lib.rs +++ b/packages/cipherstash-proxy/src/lib.rs @@ -16,7 +16,7 @@ pub use crate::config::{DatabaseConfig, ServerConfig, TandemConfig, TlsConfig}; pub use crate::log::init; pub use crate::proxy::Proxy; pub use cipherstash_client::encryption::Plaintext; -pub use cipherstash_client::eql::{EqlCiphertext, Identifier}; +pub use cipherstash_client::eql::{EqlCiphertext, EqlOutput, Identifier}; use std::mem; diff --git a/packages/cipherstash-proxy/src/postgresql/backend.rs b/packages/cipherstash-proxy/src/postgresql/backend.rs index b1a5c4b1..00eecac6 100644 --- a/packages/cipherstash-proxy/src/postgresql/backend.rs +++ b/packages/cipherstash-proxy/src/postgresql/backend.rs @@ -538,7 +538,7 @@ where for (col, ct) in projection_columns.iter().zip(ciphertexts) { match (col, ct) { (Some(col), Some(ct)) => { - if col.identifier != ct.identifier { + if &col.identifier != ct.identifier() { return Err(EncryptError::ColumnConfigurationMismatch { table: col.identifier.table.to_owned(), column: col.identifier.column.to_owned(), @@ -553,8 +553,8 @@ where // ciphertext with no column configuration is bad (None, Some(ct)) => { return Err(EncryptError::ColumnConfigurationMismatch { - table: ct.identifier.table.to_owned(), - column: ct.identifier.column.to_owned(), + table: ct.identifier().table.to_owned(), + column: ct.identifier().column.to_owned(), } .into()); } @@ -749,7 +749,7 @@ mod tests { _keyset_id: Option, _plaintexts: Vec>, _columns: &[Option], - ) -> Result>, Error> { + ) -> Result>, Error> { Ok(vec![]) } diff --git a/packages/cipherstash-proxy/src/postgresql/context/mod.rs b/packages/cipherstash-proxy/src/postgresql/context/mod.rs index ab3052b5..f6a6c02c 100644 --- a/packages/cipherstash-proxy/src/postgresql/context/mod.rs +++ b/packages/cipherstash-proxy/src/postgresql/context/mod.rs @@ -578,6 +578,16 @@ where self.table_resolver.clone() } + /// Returns the per-context snapshot of the [`EncryptConfig`]. + /// + /// Used by the SQL transformation stage to resolve the concrete encrypted + /// index types of a column (see [`EncryptConfigIndexResolver`]). + /// + /// [`EncryptConfigIndexResolver`]: crate::proxy::EncryptConfigIndexResolver + pub fn get_encrypt_config(&self) -> Arc { + self.encrypt_config.clone() + } + /// Examines a [`sqltk::parser::ast::Statement`] and if it is precisely equal to `SET UNSAFE_DISABLE_MAPPING = {boolean};` /// then it sets the flag [`Context::unsafe_disable_mapping`] to the provided `{boolean}`` value. /// @@ -752,7 +762,7 @@ where &self, plaintexts: Vec>, columns: &[Option], - ) -> Result>, Error> { + ) -> Result>, Error> { let keyset_id = self.keyset_identifier(); self.encryption @@ -1077,7 +1087,7 @@ mod tests { _keyset_id: Option, _plaintexts: Vec>, _columns: &[Option], - ) -> Result>, Error> { + ) -> Result>, Error> { Ok(vec![]) } diff --git a/packages/cipherstash-proxy/src/postgresql/data/from_sql.rs b/packages/cipherstash-proxy/src/postgresql/data/from_sql.rs index f70e4962..d969674a 100644 --- a/packages/cipherstash-proxy/src/postgresql/data/from_sql.rs +++ b/packages/cipherstash-proxy/src/postgresql/data/from_sql.rs @@ -126,25 +126,34 @@ fn text_from_sql( debug!(target: ENCODING, ?val, ?eql_term, ?col_type); match (eql_term, col_type) { - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Text) => { - Ok(Plaintext::new(val)) - } - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Float) => { - parse_str_as_numeric_plaintext::(val) - } - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::SmallInt) => { - parse_str_as_numeric_plaintext::(val) - } - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Int) => { - parse_str_as_numeric_plaintext::(val) - } - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::BigInt) => { - parse_str_as_numeric_plaintext::(val) - } - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::BigUInt) => { - parse_str_as_numeric_plaintext::(val) - } - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Boolean) => { + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::Text, + ) => Ok(Plaintext::new(val)), + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::Float, + ) => parse_str_as_numeric_plaintext::(val), + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::SmallInt, + ) => parse_str_as_numeric_plaintext::(val), + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::Int, + ) => parse_str_as_numeric_plaintext::(val), + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::BigInt, + ) => parse_str_as_numeric_plaintext::(val), + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::BigUInt, + ) => parse_str_as_numeric_plaintext::(val), + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::Boolean, + ) => { let val = match val { "TRUE" | "true" | "t" | "y" | "yes" | "on" | "1" => true, "FALSE" | "f" | "false" | "n" | "no" | "off" | "0" => false, @@ -153,17 +162,22 @@ fn text_from_sql( Ok(Plaintext::new(val)) } // NaiveDate::parse_from_str ignores time and offset so these are all valid - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Date) => { - NaiveDate::parse_from_str(val, "%Y-%m-%d") - .map_err(|_| MappingError::CouldNotParseParameter) - .map(Plaintext::new) - } - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Decimal) => { - Decimal::from_str(val) - .map_err(|_| MappingError::CouldNotParseParameter) - .map(Plaintext::new) - } - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Timestamp) => { + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::Date, + ) => NaiveDate::parse_from_str(val, "%Y-%m-%d") + .map_err(|_| MappingError::CouldNotParseParameter) + .map(Plaintext::new), + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::Decimal, + ) => Decimal::from_str(val) + .map_err(|_| MappingError::CouldNotParseParameter) + .map(Plaintext::new), + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::Timestamp, + ) => { unimplemented!("Timestamp") } @@ -176,6 +190,15 @@ fn text_from_sql( }; Ok(Plaintext::new(val)) } + // A jsonb sv *term* comparison RHS (ordering or equality) must be + // reduced to its underlying scalar so it can be encrypted as a STE-vec + // query term (`oc` for CLLW ORE leaves, `hm` for hmac/term-filter + // leaves). + (EqlTermVariant::SteVecTerm, ColumnType::Json) => { + let value = serde_json::from_str::(val) + .map_err(|_| MappingError::CouldNotParseParameter)?; + json_scalar_to_plaintext(&value) + } (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Json) => { serde_json::from_str::(val) .map_err(|_| MappingError::CouldNotParseParameter) @@ -202,55 +225,83 @@ fn binary_from_sql( debug!(target: ENCODING, ?pg_type, ?eql_term, ?col_type); match (eql_term, col_type, pg_type) { - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Text, _) => { - parse_bytes_from_sql::(bytes, pg_type).map(Plaintext::new) - } - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Boolean, _) => { - parse_bytes_from_sql::(bytes, pg_type).map(Plaintext::new) - } - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Date, _) => { - parse_bytes_from_sql::(bytes, pg_type).map(Plaintext::new) - } - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Float, _) => { - parse_bytes_from_sql::(bytes, pg_type).map(Plaintext::new) - } - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::SmallInt, _) => { - parse_bytes_from_sql::(bytes, pg_type).map(Plaintext::new) - } + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::Text, + _, + ) => parse_bytes_from_sql::(bytes, pg_type).map(Plaintext::new), + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::Boolean, + _, + ) => parse_bytes_from_sql::(bytes, pg_type).map(Plaintext::new), + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::Date, + _, + ) => parse_bytes_from_sql::(bytes, pg_type).map(Plaintext::new), + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::Float, + _, + ) => parse_bytes_from_sql::(bytes, pg_type).map(Plaintext::new), + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::SmallInt, + _, + ) => parse_bytes_from_sql::(bytes, pg_type).map(Plaintext::new), // INT4 and INT2 can be converted to Int plaintext - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Int, &Type::INT4) => { - parse_bytes_from_sql::(bytes, pg_type).map(Plaintext::new) - } - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Int, &Type::INT2) => { - parse_bytes_from_sql::(bytes, pg_type).map(|i| Plaintext::new(i as i32)) - } + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::Int, + &Type::INT4, + ) => parse_bytes_from_sql::(bytes, pg_type).map(Plaintext::new), + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::Int, + &Type::INT2, + ) => parse_bytes_from_sql::(bytes, pg_type).map(|i| Plaintext::new(i as i32)), // INT8, INT4 and INT2 can be converted to BigInt plaintext - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::BigInt, &Type::INT8) => { - parse_bytes_from_sql::(bytes, pg_type).map(Plaintext::new) - } - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::BigInt, &Type::INT4) => { - parse_bytes_from_sql::(bytes, pg_type).map(|i| Plaintext::new(i as i64)) - } - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::BigInt, &Type::INT2) => { - parse_bytes_from_sql::(bytes, pg_type).map(|i| Plaintext::new(i as i64)) - } + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::BigInt, + &Type::INT8, + ) => parse_bytes_from_sql::(bytes, pg_type).map(Plaintext::new), + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::BigInt, + &Type::INT4, + ) => parse_bytes_from_sql::(bytes, pg_type).map(|i| Plaintext::new(i as i64)), + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::BigInt, + &Type::INT2, + ) => parse_bytes_from_sql::(bytes, pg_type).map(|i| Plaintext::new(i as i64)), // INT8, INT4 and INT2 can be converted to BigUInt plaintext (note the sign change) - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::BigUInt, &Type::INT8) => { - parse_bytes_from_sql::(bytes, pg_type).map(|b| Plaintext::new(b as u64)) - } - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::BigUInt, &Type::INT4) => { - parse_bytes_from_sql::(bytes, pg_type).map(|b| Plaintext::new(b as u64)) - } - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::BigUInt, &Type::INT2) => { - parse_bytes_from_sql::(bytes, pg_type).map(|b| Plaintext::new(b as u64)) - } + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::BigUInt, + &Type::INT8, + ) => parse_bytes_from_sql::(bytes, pg_type).map(|b| Plaintext::new(b as u64)), + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::BigUInt, + &Type::INT4, + ) => parse_bytes_from_sql::(bytes, pg_type).map(|b| Plaintext::new(b as u64)), + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::BigUInt, + &Type::INT2, + ) => parse_bytes_from_sql::(bytes, pg_type).map(|b| Plaintext::new(b as u64)), // Even though basically any number can be a decimal, `rust_decimal` only supports converting from NUMERIC // Text values will be handled by the text_from_sql function (see below) - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Decimal, &Type::NUMERIC) => { - parse_bytes_from_sql::(bytes, pg_type).map(Plaintext::new) - } + ( + EqlTermVariant::Full | EqlTermVariant::Partial | EqlTermVariant::SteVecTerm, + ColumnType::Decimal, + &Type::NUMERIC, + ) => parse_bytes_from_sql::(bytes, pg_type).map(Plaintext::new), // If JSONB, JSONPATH values are treated as strings (EqlTermVariant::JsonPath, ColumnType::Json, &Type::JSONPATH) => { @@ -273,6 +324,19 @@ fn binary_from_sql( Plaintext::new(val) }) } + // A jsonb sv *term* comparison RHS (ordering or equality) must be + // reduced to its underlying scalar so it can be encrypted as a STE-vec + // query term (`oc` for CLLW ORE leaves, `hm` for hmac/term-filter + // leaves). + ( + EqlTermVariant::SteVecTerm, + ColumnType::Json, + &Type::JSON | &Type::JSONB | &Type::BYTEA, + ) => { + let value = parse_bytes_from_sql::(bytes, pg_type)?; + json_scalar_to_plaintext(&value) + } + // Python psycopg sends JSON/B as BYTEA ( EqlTermVariant::Full | EqlTermVariant::Partial, @@ -314,6 +378,37 @@ where .map(Plaintext::new) } +/// Converts a *scalar* `serde_json::Value` into the matching scalar +/// [`Plaintext`]. +/// +/// This is used for the right-hand side of a jsonb STE-vec *term* comparison — +/// ordering (`col -> selector $param`) or equality +/// (`col -> selector = $param`). The comparison is performed against a single +/// extracted leaf value, so the parameter must be encrypted as a STE-vec query +/// term, which requires a scalar plaintext (number or string) rather than +/// [`Plaintext::Json`]. +/// +/// Numbers are mapped to [`Plaintext::Float`] (f64) so that the orderable +/// encoding of the query term matches how the stored jsonb document's numeric +/// leaves are encoded: cipherstash-client's STE-vec storage path always +/// converts a JSON number leaf to its `f64` orderable representation (see +/// `OrderableTerm::Number` derived via `f64::to_orderable_bytes`). Encoding the +/// query term as an integer (`Plaintext::Int` / `BigInt`) would use a different +/// orderable byte representation and produce incorrect comparison results. +/// +/// Non-scalar values (objects, arrays) and JSON `null` are rejected — these +/// term comparisons are only defined against scalar leaves. +fn json_scalar_to_plaintext(value: &serde_json::Value) -> Result { + match value { + serde_json::Value::String(s) => Ok(Plaintext::new(s.to_owned())), + serde_json::Value::Number(n) => n + .as_f64() + .map(Plaintext::new) + .ok_or(MappingError::CouldNotParseParameter), + _ => Err(MappingError::CouldNotParseParameter), + } +} + fn decimal_from_sql( decimal: &BigDecimal, column_type: ColumnType, @@ -417,6 +512,51 @@ mod tests { } } + /// A jsonb sv term comparison RHS (`EqlTermVariant::SteVecTerm`, ordering or + /// equality) on a `ColumnType::Json` column must produce a *scalar* + /// `Plaintext` (the underlying number/string), not `Plaintext::Json`, + /// because the STE-vec term generator only accepts scalar values. + #[test] + pub fn ste_vec_term_json_numeric_is_scalar_plaintext() { + log::init(LogConfig::default()); + + // Text-format numeric value `4` against a Json column, as the RHS of a + // jsonb sv term comparison. + let param = BindParam::new(FormatCode::Text, to_message(b"4")); + + let pt = bind_param_from_sql( + ¶m, + &Type::JSONB, + EqlTermVariant::SteVecTerm, + ColumnType::Json, + ) + .unwrap() + .unwrap(); + + // Numbers map to f64 so the orderable encoding matches the stored + // jsonb document's numeric leaves. + assert_eq!(pt, Plaintext::Float(Some(4.0))); + } + + #[test] + pub fn ste_vec_term_json_string_is_scalar_plaintext() { + log::init(LogConfig::default()); + + // Text-format string value `"C"` (JSON-encoded) against a Json column. + let param = BindParam::new(FormatCode::Text, to_message(b"\"C\"")); + + let pt = bind_param_from_sql( + ¶m, + &Type::JSONB, + EqlTermVariant::SteVecTerm, + ColumnType::Json, + ) + .unwrap() + .unwrap(); + + assert_eq!(pt, Plaintext::Text(Some("C".to_string()))); + } + #[test] pub fn bind_param_to_plaintext_i64() { log::init(LogConfig::default()); diff --git a/packages/cipherstash-proxy/src/postgresql/frontend.rs b/packages/cipherstash-proxy/src/postgresql/frontend.rs index 87199677..7f285a32 100644 --- a/packages/cipherstash-proxy/src/postgresql/frontend.rs +++ b/packages/cipherstash-proxy/src/postgresql/frontend.rs @@ -26,8 +26,8 @@ use crate::prometheus::{ STATEMENTS_ENCRYPTED_TOTAL, STATEMENTS_PASSTHROUGH_MAPPING_DISABLED_TOTAL, STATEMENTS_PASSTHROUGH_TOTAL, STATEMENTS_UNMAPPABLE_TOTAL, }; -use crate::proxy::EncryptionService; -use crate::EqlCiphertext; +use crate::proxy::{EncryptConfigIndexResolver, EncryptionService}; +use crate::EqlOutput; use bytes::BytesMut; use cipherstash_client::encryption::Plaintext; use eql_mapper::{self, EqlMapperError, EqlTerm, TypeCheckedStatement}; @@ -582,13 +582,13 @@ where /// # Returns /// /// Vector of encrypted values corresponding to each literal, with `None` for - /// literals that don't require encryption and `Some(EqlCiphertext)` for encrypted values. + /// literals that don't require encryption and `Some(EqlOutput)` for encrypted values. async fn encrypt_literals( &mut self, session_id: SessionId, typed_statement: &TypeCheckedStatement<'_>, literal_columns: &Vec>, - ) -> Result>, Error> { + ) -> Result>, Error> { let literal_values = typed_statement.literal_values(); if literal_values.is_empty() { debug!(target: MAPPER, @@ -643,7 +643,7 @@ where async fn transform_statement( &mut self, typed_statement: &TypeCheckedStatement<'_>, - encrypted_literals: &Vec>, + encrypted_literals: &Vec>, ) -> Result, Error> { // Convert literals to ast Expr let mut encrypted_expressions = vec![]; @@ -1042,7 +1042,7 @@ where session_id: Option, bind: &Bind, statement: &Statement, - ) -> Result>, Error> { + ) -> Result>, Error> { let plaintexts = bind.to_plaintext(&statement.param_columns, &statement.postgres_param_types)?; @@ -1084,7 +1084,21 @@ where &self, statement: &'a ast::Statement, ) -> Result, Error> { - match eql_mapper::type_check(self.context.get_table_resolver(), statement) { + // The index resolver exposes the concrete encrypted-index types of a + // column to the SQL transformation stage (e.g. so a scalar OPE column's + // ordering is rewritten to a byte comparison of the `op` ciphertext). It + // is a side-channel: inference and unification are unaffected. A missing + // config maps every column to an empty set, reproducing the + // pre-resolver behaviour. + let index_resolver = Arc::new(EncryptConfigIndexResolver::new( + self.context.get_encrypt_config(), + )); + + match eql_mapper::type_check_with_indexes( + self.context.get_table_resolver(), + statement, + index_resolver, + ) { Ok(typed_statement) => { debug!(target: MAPPER, client_id = self.context.client_id, diff --git a/packages/cipherstash-proxy/src/postgresql/messages/bind.rs b/packages/cipherstash-proxy/src/postgresql/messages/bind.rs index a8dbf734..5446bd0d 100644 --- a/packages/cipherstash-proxy/src/postgresql/messages/bind.rs +++ b/packages/cipherstash-proxy/src/postgresql/messages/bind.rs @@ -8,7 +8,7 @@ use crate::postgresql::protocol::BytesMutReadString; use crate::{SIZE_I16, SIZE_I32}; use bytes::{Buf, BufMut, BytesMut}; use cipherstash_client::encryption::Plaintext; -use cipherstash_client::eql::EqlCiphertext; +use cipherstash_client::eql::EqlOutput; use postgres_types::Type; use std::fmt::{self, Display, Formatter}; use std::io::Cursor; @@ -81,7 +81,7 @@ impl Bind { Ok(plaintexts) } - pub fn rewrite(&mut self, encrypted: Vec>) -> Result<(), Error> { + pub fn rewrite(&mut self, encrypted: Vec>) -> Result<(), Error> { for (idx, ct) in encrypted.iter().enumerate() { if let Some(ct) = ct { let json = serde_json::to_value(ct)?; diff --git a/packages/cipherstash-proxy/src/postgresql/messages/data_row.rs b/packages/cipherstash-proxy/src/postgresql/messages/data_row.rs index e512eb66..26cbf723 100644 --- a/packages/cipherstash-proxy/src/postgresql/messages/data_row.rs +++ b/packages/cipherstash-proxy/src/postgresql/messages/data_row.rs @@ -5,10 +5,15 @@ use crate::{ postgresql::Column, }; use bytes::{Buf, BufMut, BytesMut}; -use cipherstash_client::eql::EqlCiphertext; +use cipherstash_client::eql::{EqlCiphertext, EQL_SCHEMA_VERSION}; use std::io::Cursor; use tracing::{debug, error}; +/// The version header byte that prefixes a PostgreSQL `jsonb` value in the +/// binary wire format. A bare jsonb value is encoded as this byte followed by +/// the UTF-8 JSON text. +const JSONB_VERSION_HEADER: u8 = 1; + #[derive(Debug, Clone)] pub struct DataRow { pub columns: Vec, @@ -180,7 +185,41 @@ impl TryFrom<&mut DataColumn> for EqlCiphertext { fn try_from(col: &mut DataColumn) -> Result { if let Some(bytes) = &col.bytes { - if &bytes[0..=1] == b"(\"" { + if bytes.first() == Some(&b'{') { + // Bare jsonb text encoding. + // + // A jsonb sv-element projected via `->` / `->>` arrives as a + // plain jsonb object (`{...}`) rather than the `("...")` + // composite-record wrapper of an `eql_v2_encrypted` column + // value. Route it directly to the json deserializer, which + // reshapes the bare entry (carrying its own root ciphertext `c`, + // identifier `i`, and version `v`) into a decryptable + // `EqlCiphertext`. + match eql_ciphertext_from_json(bytes) { + Ok(e) => return Ok(e), + Err(err) => { + debug!(target: DECRYPT, error = err.to_string()); + return Err(err.into()); + } + } + } else if bytes.first() == Some(&JSONB_VERSION_HEADER) { + // Bare jsonb binary encoding. + // + // The binary wire format for a bare `jsonb` value is a single + // version header byte (`0x01`) followed by the UTF-8 JSON text. + // This is how a jsonb sv-element projected via `->` / `->>` + // arrives when the column is returned in binary format (no + // 12-byte composite rowtype header, unlike an + // `eql_v2_encrypted` column value). + let sliced = &bytes[1..]; + match eql_ciphertext_from_json(sliced) { + Ok(e) => return Ok(e), + Err(err) => { + debug!(target: DECRYPT, error = err.to_string()); + return Err(err.into()); + } + } + } else if bytes.starts_with(b"(\"") { // Text encoding // Encrypted record is in the form ("{}") // json data can be extracted by dropping the first and last two bytes to remove (" and ") @@ -191,7 +230,7 @@ impl TryFrom<&mut DataColumn> for EqlCiphertext { let input = String::from_utf8_lossy(sliced).to_string(); let input = input.replace("\"\"", "\""); - match serde_json::from_str(&input) { + match eql_ciphertext_from_json(input.as_bytes()) { Ok(e) => return Ok(e), Err(err) => { debug!(target: DECRYPT, error = err.to_string()); @@ -221,7 +260,7 @@ impl TryFrom<&mut DataColumn> for EqlCiphertext { let start = 12 + 1; let sliced = &bytes[start..]; - match serde_json::from_slice(sliced) { + match eql_ciphertext_from_json(sliced) { Ok(e) => { return Ok(e); } @@ -237,6 +276,64 @@ impl TryFrom<&mut DataColumn> for EqlCiphertext { } } +/// Deserialize an EQL ciphertext payload read from the database. +/// +/// Supports both the current EQL v2.x storage format (a tagged object +/// discriminated by `"k"`, e.g. `{"k":"ct",...}`) and the legacy pre-v2.x flat +/// format that predates the `cipherstash-client` 0.37 upgrade. Existing customer +/// databases may still hold values written in the legacy format, so the proxy +/// must continue to read them transparently. +fn eql_ciphertext_from_json(input: &[u8]) -> Result { + let value: serde_json::Value = serde_json::from_slice(input)?; + + // The current format always carries the `k` discriminator. Anything without + // it is a legacy payload and is remapped onto the current schema. + if value.get("k").is_some() { + serde_json::from_value(value) + } else { + serde_json::from_value(legacy_to_current(value)) + } +} + +/// Remap a legacy (pre-v2.x) EQL payload onto the current scalar storage shape. +/// +/// The legacy format stored the encrypted record under `c`, the identifier under +/// `i`, and index terms under `m` (bloom filter), `o` (block ORE), and `u` +/// (HMAC). The current scalar payload (`k = "ct"`) renames these to `bf`, `ob`, +/// and `hm` respectively. Decryption only requires the root ciphertext (`c`) and +/// identifier (`i`); index terms are carried over best-effort. Legacy structured +/// (JSON / STE-vec) payloads also retained a root `c`, so they decrypt correctly +/// through the same scalar mapping. +fn legacy_to_current(old: serde_json::Value) -> serde_json::Value { + use serde_json::Value; + + let mut new = serde_json::Map::new(); + new.insert("k".to_string(), Value::String("ct".to_string())); + new.insert( + "v".to_string(), + old.get("v") + .filter(|v| !v.is_null()) + .cloned() + .unwrap_or_else(|| Value::from(EQL_SCHEMA_VERSION)), + ); + + // Carry over a field from the legacy payload under a (possibly renamed) key, + // skipping nulls so optional terms stay absent rather than `null`. + let mut carry = |old_key: &str, new_key: &str| { + if let Some(v) = old.get(old_key).filter(|v| !v.is_null()) { + new.insert(new_key.to_string(), v.clone()); + } + }; + + carry("i", "i"); // identifier + carry("c", "c"); // encrypted record + carry("u", "hm"); // HMAC (exact match) + carry("m", "bf"); // bloom filter (LIKE / ILIKE) + carry("o", "ob"); // block ORE (ordering) + + Value::Object(new) +} + #[cfg(test)] mod tests { use super::DataRow; @@ -284,7 +381,7 @@ mod tests { assert_eq!( column_config[1].as_ref().unwrap().identifier, - encrypted[1].as_ref().unwrap().identifier + *encrypted[1].as_ref().unwrap().identifier() ); } @@ -333,7 +430,7 @@ mod tests { assert_eq!( column_config[0].as_ref().unwrap().identifier, - encrypted[0].as_ref().unwrap().identifier + *encrypted[0].as_ref().unwrap().identifier() ); } @@ -374,7 +471,7 @@ mod tests { assert_eq!( column_config[2].as_ref().unwrap().identifier, - encrypted[2].as_ref().unwrap().identifier + *encrypted[2].as_ref().unwrap().identifier() ); } @@ -424,6 +521,62 @@ mod tests { assert_eq!(data_col.bytes, None); } + /// A jsonb sv-element projected via `->` arrives as a *bare* jsonb object + /// in text format (it does not have the `("...")` composite wrapper of an + /// `eql_v2_encrypted` column value). It carries the per-element ciphertext + /// `c`, the root identifier `i`, the version `v`, plus sv-element fields + /// (`s`, `oc`/`hm`, `a`). The proxy must recognise this shape and reshape + /// it into a decryptable `EqlCiphertext` whose `c` decrypts to the field + /// value. + #[test] + pub fn ste_vec_entry_text_is_parsed_as_ciphertext() { + use super::EqlCiphertext; + + log::init(LogConfig::with_level(LogLevel::Debug)); + + let entry = br#"{"a": false, "c": "mBbMF!z<&zkCI#C(LG|JGgb4%8P*wqkS%pz;p$Q2dVbkzls&6BQKOW?wDZIup6=moW)AKnN}-xzJIMkO*^AoVomfFWT+0Br%3!kepuh", "i": {"c": "encrypted_jsonb", "t": "encrypted"}, "oc": "0d7c40c51d5ea764cc8720c6be8110abf88b683f518fabc85561ecbd22a2d0359d339cf773c040b031d17e05814032e691", "s": "aafcf9a9d134046781689e670a7ff8e8", "v": 2}"#; + + let mut column = DataColumn { + bytes: Some(to_message(entry)), + }; + + let ciphertext = EqlCiphertext::try_from(&mut column) + .expect("bare ste_vec_entry should parse into an EqlCiphertext"); + + assert_eq!( + *ciphertext.identifier(), + Identifier::new("encrypted", "encrypted_jsonb") + ); + } + + /// A jsonb sv-element projected via `->` is also delivered in *binary* + /// jsonb format (version header byte `0x01` followed by the JSON text) when + /// the result column is returned in binary format. The proxy must strip the + /// version header and reshape the entry into a decryptable `EqlCiphertext`. + #[test] + pub fn ste_vec_entry_binary_jsonb_is_parsed_as_ciphertext() { + use super::EqlCiphertext; + + log::init(LogConfig::with_level(LogLevel::Debug)); + + let json = br#"{"a": false, "c": "mBbMF!z<&zkCI#C(LG|JGgb4%8P*wqkS%pz;p$Q2dVbkzls&6BQKOW?wDZIup6=moW)AKnN}-xzJIMkO*^AoVomfFWT+0Br%3!kepuh", "i": {"c": "encrypted_jsonb", "t": "encrypted"}, "oc": "0d7c40c51d5ea764cc8720c6be8110abf88b683f518fabc85561ecbd22a2d0359d339cf773c040b031d17e05814032e691", "s": "aafcf9a9d134046781689e670a7ff8e8", "v": 2}"#; + + // Binary jsonb wire format: version header byte (1) + JSON text. + let mut bytes = BytesMut::new(); + bytes.extend_from_slice(&[1u8]); + bytes.extend_from_slice(json); + + let mut column = DataColumn { bytes: Some(bytes) }; + + let ciphertext = EqlCiphertext::try_from(&mut column) + .expect("binary jsonb ste_vec_entry should parse into an EqlCiphertext"); + + assert_eq!( + *ciphertext.identifier(), + Identifier::new("encrypted", "encrypted_jsonb") + ); + } + #[test] pub fn data_row_column_len() { let column = DataColumn { bytes: None }; diff --git a/packages/cipherstash-proxy/src/proxy/encrypt_config/index_resolver.rs b/packages/cipherstash-proxy/src/proxy/encrypt_config/index_resolver.rs new file mode 100644 index 00000000..4234abdb --- /dev/null +++ b/packages/cipherstash-proxy/src/proxy/encrypt_config/index_resolver.rs @@ -0,0 +1,188 @@ +//! Proxy-side [`IndexResolver`] backed by the loaded [`EncryptConfig`]. +//! +//! `eql-mapper`'s transformation stage asks for the concrete encrypted-index +//! kinds of a `(table, column)` pair so index-specific rewrite rules (e.g. +//! scalar OPE ordering) can target the right function. The concrete index types +//! already live in [`EncryptConfig`] (keyed by [`eql::Identifier`]); this +//! resolver translates a `cipherstash-config` [`IndexType`] into the +//! `eql-mapper`-local [`IndexKind`] and exposes them through the +//! [`IndexResolver`] trait. +//! +//! It owns an `Arc` snapshot so a single statement sees a +//! consistent view of the config even if the background reloader swaps in a new +//! one mid-transform. + +use std::collections::HashSet; +use std::sync::Arc; + +use cipherstash_client::eql::Identifier; +use cipherstash_config::column::IndexType; +use eql_mapper::{IndexKind, IndexResolver, TableColumn}; + +use super::EncryptConfig; + +/// Maps a `cipherstash-config` [`IndexType`] to the `eql-mapper`-local +/// [`IndexKind`]. Index parameters (tokenizer, ste-vec prefix, …) are dropped: +/// transformation rules only care about the index *family*. +fn index_kind_of(index_type: &IndexType) -> IndexKind { + match index_type { + IndexType::Ore => IndexKind::Ore, + IndexType::Ope => IndexKind::Ope, + IndexType::Match { .. } => IndexKind::Match, + IndexType::Unique { .. } => IndexKind::Unique, + IndexType::SteVec { .. } => IndexKind::SteVec, + } +} + +/// An [`IndexResolver`] backed by a snapshot of the [`EncryptConfig`]. +#[derive(Debug)] +pub struct EncryptConfigIndexResolver { + encrypt_config: Arc, +} + +impl EncryptConfigIndexResolver { + pub fn new(encrypt_config: Arc) -> Self { + Self { encrypt_config } + } +} + +impl IndexResolver for EncryptConfigIndexResolver { + fn resolve(&self, table_column: &TableColumn) -> HashSet { + // Mirror how the column mapper derives an Identifier from a TableColumn + // (unquoted ident text) so the lookup keys match. + let identifier = Identifier::new( + table_column.table.value.to_string(), + table_column.column.value.to_string(), + ); + + match self.encrypt_config.get_column_config(&identifier) { + Some(config) => config + .indexes + .iter() + .map(|index| index_kind_of(&index.index_type)) + .collect(), + // Unknown column (e.g. encrypt config not yet loaded for it) → empty + // set, so rules fall back to their default behaviour. + None => HashSet::new(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::proxy::encrypt_config::EncryptConfig; + use cipherstash_config::CanonicalEncryptionConfig; + use eql_mapper::TableColumn; + use serde_json::json; + use sqltk::parser::ast::Ident; + + fn config_from(json: serde_json::Value) -> EncryptConfig { + let canonical: CanonicalEncryptionConfig = serde_json::from_value(json).unwrap(); + let map = super::super::manager::canonical_to_map(canonical).unwrap(); + EncryptConfig::new_from_config(map) + } + + fn table_column(table: &str, column: &str) -> TableColumn { + TableColumn { + table: Ident::new(table), + column: Ident::new(column), + } + } + + #[test] + fn resolves_ope_index_to_ope_kind() { + let config = config_from(json!({ + "v": 1, + "tables": { "users": { "salary": { "indexes": { "ope": {} } } } } + })); + + let resolver = EncryptConfigIndexResolver::new(Arc::new(config)); + + assert_eq!( + resolver.resolve(&table_column("users", "salary")), + HashSet::from_iter([IndexKind::Ope]) + ); + } + + #[test] + fn resolves_ore_index_to_ore_kind() { + let config = config_from(json!({ + "v": 1, + "tables": { "users": { "salary": { "indexes": { "ore": {} } } } } + })); + + let resolver = EncryptConfigIndexResolver::new(Arc::new(config)); + + assert_eq!( + resolver.resolve(&table_column("users", "salary")), + HashSet::from_iter([IndexKind::Ore]) + ); + } + + #[test] + fn resolves_multi_index_column_to_full_set() { + let config = config_from(json!({ + "v": 1, + "tables": { + "users": { + "email": { "indexes": { "unique": {}, "match": {}, "ore": {} } } + } + } + })); + + let resolver = EncryptConfigIndexResolver::new(Arc::new(config)); + + assert_eq!( + resolver.resolve(&table_column("users", "email")), + HashSet::from_iter([IndexKind::Unique, IndexKind::Match, IndexKind::Ore]) + ); + } + + #[test] + fn resolves_ste_vec_index_to_ste_vec_kind() { + let config = config_from(json!({ + "v": 1, + "tables": { + "users": { + "event_data": { + "cast_as": "jsonb", + "indexes": { "ste_vec": { "prefix": "event-data" } } + } + } + } + })); + + let resolver = EncryptConfigIndexResolver::new(Arc::new(config)); + + assert_eq!( + resolver.resolve(&table_column("users", "event_data")), + HashSet::from_iter([IndexKind::SteVec]) + ); + } + + #[test] + fn unknown_column_resolves_to_empty_set() { + let config = config_from(json!({ + "v": 1, + "tables": { "users": { "salary": { "indexes": { "ope": {} } } } } + })); + + let resolver = EncryptConfigIndexResolver::new(Arc::new(config)); + + assert_eq!( + resolver.resolve(&table_column("users", "unknown")), + HashSet::new() + ); + } + + #[test] + fn empty_config_resolves_to_empty_set() { + let resolver = EncryptConfigIndexResolver::new(Arc::new(EncryptConfig::new())); + + assert_eq!( + resolver.resolve(&table_column("users", "salary")), + HashSet::new() + ); + } +} diff --git a/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs b/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs index 65dcaf0d..5494b3a7 100644 --- a/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs +++ b/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs @@ -236,7 +236,9 @@ fn configuration_table_not_found(e: &tokio_postgres::Error) -> bool { msg.contains("eql_v2_configuration") && msg.contains("does not exist") } -fn canonical_to_map(canonical: CanonicalEncryptionConfig) -> Result { +pub(super) fn canonical_to_map( + canonical: CanonicalEncryptionConfig, +) -> Result { Ok(canonical .into_config_map()? .into_iter() @@ -248,7 +250,9 @@ fn canonical_to_map(canonical: CanonicalEncryptionConfig) -> Result; @@ -156,7 +156,7 @@ pub trait EncryptionService: Send + Sync { keyset_id: Option, plaintexts: Vec>, columns: &[Option], - ) -> Result>, Error>; + ) -> Result>, Error>; /// Decrypt values retrieved from the database async fn decrypt( diff --git a/packages/cipherstash-proxy/src/proxy/zerokms/zerokms.rs b/packages/cipherstash-proxy/src/proxy/zerokms/zerokms.rs index 15e120d3..6410c78c 100644 --- a/packages/cipherstash-proxy/src/proxy/zerokms/zerokms.rs +++ b/packages/cipherstash-proxy/src/proxy/zerokms/zerokms.rs @@ -13,7 +13,7 @@ use cipherstash_client::{ encryption::{Plaintext, QueryOp}, eql::{ decrypt_eql, encrypt_eql, EqlCiphertext, EqlDecryptOpts, EqlEncryptOpts, EqlOperation, - PreparedPlaintext, + EqlOutput, PreparedPlaintext, }, schema::column::IndexType, }; @@ -157,7 +157,7 @@ impl EncryptionService for ZeroKms { keyset_id: Option, plaintexts: Vec>, columns: &[Option], - ) -> Result>, Error> { + ) -> Result>, Error> { debug!(target: ENCRYPT, msg="Encrypt", ?keyset_id, default_keyset_id = ?self.default_keyset_id); // A keyset is required if no default keyset has been configured @@ -201,6 +201,23 @@ impl EncryptionService for ZeroKms { EqlOperation::Query(&index.index_type, QueryOp::SteVecSelector) }) .unwrap_or(EqlOperation::Store), + + // SteVecTerm generates the STE-vec query term for the + // right-hand side of a jsonb sv *term* comparison — + // ordering (`col -> selector $param`) or equality + // (`col -> selector = $param`). The query op emits whichever + // deterministic term the column's leaf carries: `oc` (CLLW + // ORE) for string/number leaves, `hm` (HMAC) for + // bool/null/array/object leaves. Ordering reads `oc` via + // `eql_v2.ore_cllw`; equality reads `hm`/`oc` via + // `eql_v2.eq_term`. + EqlTermVariant::SteVecTerm => col + .config + .indexes + .iter() + .find(|i| matches!(i.index_type, IndexType::SteVec { .. })) + .map(|index| EqlOperation::Query(&index.index_type, QueryOp::SteVecTerm)) + .unwrap_or(EqlOperation::Store), }; let prepared = PreparedPlaintext::new( @@ -216,7 +233,7 @@ impl EncryptionService for ZeroKms { // If no plaintexts to encrypt, return all None if prepared_plaintexts.is_empty() { - return Ok(vec![None; plaintexts.len()]); + return Ok((0..plaintexts.len()).map(|_| None).collect()); } // Use default opts since cipher is already initialized with the correct keyset @@ -231,9 +248,9 @@ impl EncryptionService for ZeroKms { debug!(target: ENCRYPT, msg="encrypt_eql completed", count = encrypted.len(), duration_ms = encrypt_duration.as_millis()); // Reconstruct the result vector with None values in the right places - let mut result: Vec> = vec![None; plaintexts.len()]; - for (idx, ciphertext) in indices.into_iter().zip(encrypted.into_iter()) { - result[idx] = Some(ciphertext); + let mut result: Vec> = (0..plaintexts.len()).map(|_| None).collect(); + for (idx, output) in indices.into_iter().zip(encrypted.into_iter()) { + result[idx] = Some(output); } Ok(result) diff --git a/packages/eql-mapper/src/eql_mapper.rs b/packages/eql-mapper/src/eql_mapper.rs index 50bde65b..8aea02f6 100644 --- a/packages/eql-mapper/src/eql_mapper.rs +++ b/packages/eql-mapper/src/eql_mapper.rs @@ -2,8 +2,8 @@ use super::importer::{ImportError, Importer}; use crate::{ inference::{TypeError, TypeInferencer}, unifier::{EqlTerm, Projection, Type, Unifier, Value}, - DepMut, Param, ParamError, ScopeError, ScopeTracker, TableResolver, TypeCheckedStatement, - TypeRegistry, + DepMut, EmptyIndexResolver, IndexResolver, Param, ParamError, ScopeError, ScopeTracker, + TableResolver, TypeCheckedStatement, TypeRegistry, }; use sqltk::parser::ast::{self as ast, Statement}; use sqltk::{Break, NodeKey, Visitable, Visitor}; @@ -34,10 +34,27 @@ use tracing::{event, span, Level}; pub fn type_check<'ast>( resolver: Arc, statement: &'ast Statement, +) -> Result, EqlMapperError> { + type_check_with_indexes(resolver, statement, Arc::new(EmptyIndexResolver)) +} + +/// Like [`type_check`] but additionally takes an [`IndexResolver`] that exposes +/// the concrete encrypted-index types of `(table, column)` pairs to the SQL +/// *transformation* stage. +/// +/// Type inference and unification are identical to [`type_check`]: the +/// [`IndexResolver`] is a side-channel consulted only by transformation rules +/// (via [`TypeCheckedStatement::transform`]) to choose index-specific target +/// functions. Passing [`EmptyIndexResolver`] is exactly equivalent to calling +/// [`type_check`]. +pub fn type_check_with_indexes<'ast>( + resolver: Arc, + statement: &'ast Statement, + index_resolver: Arc, ) -> Result, EqlMapperError> { let mut mapper = EqlMapper::<'ast>::new_with_resolver(resolver); match statement.accept(&mut mapper) { - ControlFlow::Continue(()) => mapper.resolve(statement), + ControlFlow::Continue(()) => mapper.resolve(statement, index_resolver), ControlFlow::Break(Break::Err(err)) => Err(err), ControlFlow::Break(_) => Err(EqlMapperError::InternalError(String::from( "unexpected Break value in type_check", @@ -138,6 +155,7 @@ impl<'ast> EqlMapper<'ast> { pub fn resolve( self, statement: &'ast Statement, + index_resolver: Arc, ) -> Result, EqlMapperError> { let span_begin = span!( target: "eqlmapper::spans", @@ -181,6 +199,7 @@ impl<'ast> EqlMapper<'ast> { params, literals, Arc::new(node_types), + index_resolver, )) } Err(err) => { @@ -222,13 +241,20 @@ impl<'ast> EqlMapper<'ast> { fn param_types(&self, unifier: &Unifier<'ast>) -> Result, EqlMapperError> { let params = self.registry.borrow().resolved_param_types(unifier)?; + let (ste_vec_params, _) = self.ste_vec_term_rhs_keys(unifier); let params = params .into_iter() .map(|(p, ty)| -> Result<(Param, Value), EqlMapperError> { let ty = ty.follow_tvars(unifier); match &*ty { - Type::Value(value) => Ok((p, value.clone())), + Type::Value(value) => { + let value = reclassify_as_ste_vec_term_if( + value.clone(), + ste_vec_params.contains(&p), + ); + Ok((p, value)) + } other => Err(TypeError::Expected(format!( "expected param '{p}' to resolve to a scalar type but got '{other}'" )))?, @@ -239,8 +265,81 @@ impl<'ast> EqlMapper<'ast> { Ok(params) } + /// Collects the right-hand-side operand of every jsonb STE-vec *term* + /// comparison — both *ordering* (`<`, `<=`, `>`, `>=`) and *equality* + /// (`=`, `<>`) — whose left-hand side is a jsonb STE-vec element accessor + /// (`->` / `->>` / `jsonb_path_query_first`). + /// + /// The returned sets identify the RHS values (params by [`Param`], literals + /// by [`NodeKey`]) that must be encrypted as a STE-vec query term + /// ([`EqlTerm::SteVecTerm`]) rather than a full/partial root payload. + /// Ordering binds the term to `eql_v2.ore_cllw(...)` (`oc`); equality binds + /// it to `eql_v2.eq_term(...)` (the XOR-aware `hm`/`oc` term the column's + /// leaf carries). Both require the same `SteVecTerm` reclassification — the + /// proxy's encrypt path emits whichever term the column's leaf carries. + fn ste_vec_term_rhs_keys( + &self, + unifier: &Unifier<'ast>, + ) -> ( + std::collections::HashSet, + std::collections::HashSet>, + ) { + use crate::ste_vec_ordering::{ + is_equality_operator, is_ordering_operator, is_ste_vec_accessor, + }; + use sqltk::parser::ast::Expr; + + let mut params = std::collections::HashSet::new(); + let mut literals = std::collections::HashSet::new(); + + let registry = self.registry.borrow(); + + // `is_eql` mirrors the `RewriteJsonbSteVecOrdering` / + // `RewriteJsonbSteVecEquality` rules' `is_eql_typed` check on both + // operands, so the reclassification (which changes how the value is + // encrypted) marks exactly the comparisons the SQL rewrites will + // rewrite to `eql_v2.ore_cllw(...)` / `eql_v2.eq_term(...)`. + let is_eql = |expr: &Expr| { + registry + .peek_node_type(expr) + .map(|ty| matches!(&*ty.follow_tvars(unifier), Type::Value(Value::Eql(_)))) + .unwrap_or(false) + }; + + for (expr, _) in registry.get_nodes_and_types::() { + let Expr::BinaryOp { left, op, right } = expr else { + continue; + }; + + if !(is_ordering_operator(op) || is_equality_operator(op)) + || !is_ste_vec_accessor(left) + || !is_eql(left) + || !is_eql(right) + { + continue; + } + + if let Expr::Value(value_with_span) = &**right { + match &value_with_span.value { + ast::Value::Placeholder(p) => { + if let Ok(param) = Param::try_from(p) { + params.insert(param); + } + } + other => { + literals.insert(NodeKey::new(other)); + } + } + } + } + + (params, literals) + } + /// Asks the [`TypeInferencer`] for a hashmap of literal types, validating that they are all `Value` types. fn literal_types(&self) -> Result, EqlMapperError> { + let (_, ste_vec_literals) = self.ste_vec_term_rhs_keys(&self.unifier.borrow()); + let literals = { let registry = self.registry.borrow(); registry @@ -254,7 +353,12 @@ impl<'ast> EqlMapper<'ast> { |(node, ty)| -> Result, TypeError> { let ty = ty.follow_tvars(&self.unifier.borrow()); if let Type::Value(Value::Eql(eql_term)) = &*ty { - return Ok(Some((eql_term.clone(), node))); + let is_ste_vec_rhs = ste_vec_literals.contains(&NodeKey::new(node)); + let eql_term = reclassify_eql_term_as_ste_vec_term_if( + eql_term.clone(), + is_ste_vec_rhs, + ); + return Ok(Some((eql_term, node))); } Ok(None) }, @@ -284,6 +388,35 @@ impl<'ast> EqlMapper<'ast> { } } +/// Reclassifies a resolved param [`Value`] as a STE-vec ordering term when it +/// is the right-hand side of a jsonb sv ordering comparison. +/// +/// Only EQL `Partial` / `Full` values are reclassified; everything else is +/// returned unchanged. +fn reclassify_as_ste_vec_term_if(value: Value, is_ste_vec_rhs: bool) -> Value { + if let Value::Eql(eql_term) = value { + Value::Eql(reclassify_eql_term_as_ste_vec_term_if( + eql_term, + is_ste_vec_rhs, + )) + } else { + value + } +} + +/// Reclassifies an [`EqlTerm`] as [`EqlTerm::SteVecTerm`] when `is_ste_vec_rhs` +/// is `true` and the term is a `Partial` or `Full` value. +fn reclassify_eql_term_as_ste_vec_term_if(eql_term: EqlTerm, is_ste_vec_rhs: bool) -> EqlTerm { + if !is_ste_vec_rhs { + return eql_term; + } + + match eql_term { + EqlTerm::Partial(eql_value, _) | EqlTerm::Full(eql_value) => EqlTerm::SteVecTerm(eql_value), + other => other, + } +} + /// [`Visitor`] implementation that composes the [`ScopeTracker`] visitor, the [`Importer`] and the [`TypeInferencer`] /// visitors. impl<'ast> Visitor<'ast> for EqlMapper<'ast> { diff --git a/packages/eql-mapper/src/index_resolver.rs b/packages/eql-mapper/src/index_resolver.rs new file mode 100644 index 00000000..b023a966 --- /dev/null +++ b/packages/eql-mapper/src/index_resolver.rs @@ -0,0 +1,153 @@ +//! Concrete encrypted-index resolution for the SQL *transformation* stage. +//! +//! Type inference and unification only ever see the abstract [`crate::EqlTraits`] +//! (Eq / Ord / TokenMatch / JsonLike / Contain) of an encrypted column — never +//! the concrete index type that backs it. Trait-level information is sufficient +//! to decide *whether* an operation type-checks, but the concrete index decides +//! *which* function or operator form a transformation rule should emit. +//! +//! [`IndexResolver`] provides that concrete information to transformation rules +//! as a side-channel `(table, column) -> {IndexKind}` lookup. It is deliberately +//! kept out of the unifier and the [`crate::EqlValue`] / [`crate::EqlTerm`] +//! output types so that inference remains unchanged and `eql-mapper` does not +//! depend on `cipherstash-config`. + +use std::collections::{HashMap, HashSet}; + +use crate::TableColumn; + +/// A concrete encrypted index kind, mirroring the index families that +/// `cipherstash-config` describes for an encrypted column. +/// +/// This is an `eql-mapper`-local representation so the crate does not depend on +/// `cipherstash-config`. It intentionally drops index *parameters* (tokenizer +/// settings, ste-vec prefix, …): transformation rules choose a target function +/// based only on which index *family* is present. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum IndexKind { + /// Order-revealing encryption (block ORE) — ordering via the root `ob` term. + Ore, + /// Order-preserving encryption — ordering via byte comparison of the `op` term. + Ope, + /// Structured encryption vector (jsonb) — ordering/containment over sv elements. + SteVec, + /// Bloom-filter match index — `LIKE` / `ILIKE`. + Match, + /// Deterministic (hmac) equality index. + Unique, +} + +/// Resolves the set of concrete [`IndexKind`]s configured for a `(table, column)`. +/// +/// Transformation rules consult this to pick an index-specific target. The +/// resolver is a side-channel: it does not participate in inference or +/// unification. +/// +/// A resolver that returns an empty set for every column (see +/// [`EmptyIndexResolver`]) reproduces the behaviour of rules that are not yet +/// concrete-index-aware, so it is the safe default. +pub trait IndexResolver: std::fmt::Debug + Send + Sync { + /// Returns the set of [`IndexKind`]s configured for `table_column`. + /// + /// Returns an empty set when the column is unknown to the resolver (e.g. the + /// encrypt config has not loaded it yet). Rules MUST treat an empty set as + /// "no concrete information" and fall back to their default behaviour. + fn resolve(&self, table_column: &TableColumn) -> HashSet; +} + +/// An [`IndexResolver`] that knows nothing: it returns an empty set for every +/// column. This is the default used by [`crate::type_check`] and reproduces the +/// pre-resolver transformation behaviour. +#[derive(Debug, Default, Clone, Copy)] +pub struct EmptyIndexResolver; + +impl IndexResolver for EmptyIndexResolver { + fn resolve(&self, _table_column: &TableColumn) -> HashSet { + HashSet::new() + } +} + +/// An [`IndexResolver`] backed by an in-memory map. Primarily a building block +/// for callers (and tests) that already have a `(table, column) -> indexes` +/// mapping in hand. +#[derive(Debug, Default, Clone)] +pub struct MapIndexResolver { + indexes: HashMap>, +} + +impl MapIndexResolver { + /// Creates a resolver from a `(table, column) -> {IndexKind}` map. + pub fn new(indexes: HashMap>) -> Self { + Self { indexes } + } +} + +impl IndexResolver for MapIndexResolver { + fn resolve(&self, table_column: &TableColumn) -> HashSet { + self.indexes.get(table_column).cloned().unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::TableColumn; + use sqltk::parser::ast::Ident; + + fn tc(table: &str, column: &str) -> TableColumn { + TableColumn { + table: Ident::new(table), + column: Ident::new(column), + } + } + + #[test] + fn empty_resolver_returns_empty_set_for_any_column() { + let resolver = EmptyIndexResolver; + assert_eq!( + resolver.resolve(&tc("users", "email")), + HashSet::new(), + "empty resolver must return no index kinds" + ); + } + + #[test] + fn map_resolver_returns_configured_kinds_for_known_column() { + let resolver = MapIndexResolver::new(HashMap::from_iter([( + tc("users", "salary"), + HashSet::from_iter([IndexKind::Ope]), + )])); + + assert_eq!( + resolver.resolve(&tc("users", "salary")), + HashSet::from_iter([IndexKind::Ope]) + ); + } + + #[test] + fn map_resolver_returns_full_set_for_multi_index_column() { + let resolver = MapIndexResolver::new(HashMap::from_iter([( + tc("users", "email"), + HashSet::from_iter([IndexKind::Unique, IndexKind::Match, IndexKind::Ore]), + )])); + + assert_eq!( + resolver.resolve(&tc("users", "email")), + HashSet::from_iter([IndexKind::Unique, IndexKind::Match, IndexKind::Ore]) + ); + } + + #[test] + fn map_resolver_returns_empty_set_for_unknown_column() { + let resolver = MapIndexResolver::new(HashMap::from_iter([( + tc("users", "email"), + HashSet::from_iter([IndexKind::Unique]), + )])); + + assert_eq!( + resolver.resolve(&tc("users", "unknown_column")), + HashSet::new(), + "unknown column must resolve to an empty set" + ); + } +} diff --git a/packages/eql-mapper/src/inference/unifier/eql_traits.rs b/packages/eql-mapper/src/inference/unifier/eql_traits.rs index 25c4bec3..f7d2f326 100644 --- a/packages/eql-mapper/src/inference/unifier/eql_traits.rs +++ b/packages/eql-mapper/src/inference/unifier/eql_traits.rs @@ -320,6 +320,8 @@ impl EqlTerm { EqlTerm::JsonAccessor(_) => EqlTraits::none(), EqlTerm::JsonPath(_) => EqlTraits::none(), EqlTerm::Tokenized(_) => EqlTraits::none(), + // A STE-vec ordering term is a CLLW ORE comparison value, so it satisfies `Ord`. + EqlTerm::SteVecTerm(_) => EqlTraits::from(EqlTrait::Ord), } } } diff --git a/packages/eql-mapper/src/inference/unifier/types.rs b/packages/eql-mapper/src/inference/unifier/types.rs index 8bff0355..e79f3255 100644 --- a/packages/eql-mapper/src/inference/unifier/types.rs +++ b/packages/eql-mapper/src/inference/unifier/types.rs @@ -195,6 +195,15 @@ pub enum EqlTerm { /// [`EqlValue`] that implements the EQL trait `TokenMatch`. #[display("EQL:Tokenized({})", _0)] Tokenized(EqlValue), + + /// A single STE-vec element comparison term. This is the inferred type of the right hand side of an *ordering* + /// comparison (`<`, `<=`, `>`, `>=`) whose left hand side is a jsonb STE-vec element extracted via `->` / `->>` / + /// `jsonb_path_query_first`. + /// + /// It is distinct from [`EqlTerm::Partial`] because the value must be encrypted as a CLLW ORE STE-vec query term + /// (`oc`) so that the comparison binds to `eql_v2.ore_cllw(...)` rather than the root Block-ORE (`ob`) path. + #[display("EQL:SteVecTerm({})", _0)] + SteVecTerm(EqlValue), } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Display, Hash)] @@ -209,6 +218,8 @@ pub enum EqlTermVariant { JsonPath, #[display("EQL:Tokenized")] Tokenized, + #[display("EQL:SteVecTerm")] + SteVecTerm, } impl EqlTerm { @@ -218,7 +229,8 @@ impl EqlTerm { | EqlTerm::Partial(eql_value, _) | EqlTerm::JsonAccessor(eql_value) | EqlTerm::JsonPath(eql_value) - | EqlTerm::Tokenized(eql_value) => eql_value.table_column(), + | EqlTerm::Tokenized(eql_value) + | EqlTerm::SteVecTerm(eql_value) => eql_value.table_column(), } } @@ -229,6 +241,7 @@ impl EqlTerm { EqlTerm::JsonAccessor(_) => EqlTermVariant::JsonAccessor, EqlTerm::JsonPath(_) => EqlTermVariant::JsonPath, EqlTerm::Tokenized(_) => EqlTermVariant::Tokenized, + EqlTerm::SteVecTerm(_) => EqlTermVariant::SteVecTerm, } } } diff --git a/packages/eql-mapper/src/lib.rs b/packages/eql-mapper/src/lib.rs index 09afc9a1..2d342105 100644 --- a/packages/eql-mapper/src/lib.rs +++ b/packages/eql-mapper/src/lib.rs @@ -4,11 +4,13 @@ mod dep; mod display_helpers; mod eql_mapper; mod importer; +mod index_resolver; mod inference; mod iterator_ext; mod model; mod param; mod scope_tracker; +mod ste_vec_ordering; mod transformation_rules; mod type_checked_statement; @@ -17,6 +19,7 @@ mod test_helpers; pub use display_helpers::*; pub use eql_mapper::*; +pub use index_resolver::*; pub use model::*; pub use param::*; pub use type_checked_statement::*; @@ -32,7 +35,7 @@ pub(crate) use transformation_rules::*; #[cfg(test)] mod test { - use super::{test_helpers::*, type_check}; + use super::{test_helpers::*, type_check, type_check_with_indexes}; use crate::{ projection, schema, test_helpers, unifier::{ @@ -2088,6 +2091,503 @@ mod test { ); } + /// Group B (CIP-3279): ordering comparisons on a jsonb sv-element extracted + /// via `->` must be rewritten to compare CLLW ORE terms so the SQL binds to + /// the `eql_v2.ore_cllw eql_v2.ore_cllw` operators instead of the root + /// Block-ORE (`ob`) path which raises `Expected an ore index (ob)`. + #[test] + fn jsonb_sv_ordering_rewrites_to_ore_cllw() { + // init_tracing(); + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_jsonb (EQL: JsonLike), + } + } + }); + + let statement = + parse("SELECT encrypted_jsonb FROM encrypted WHERE encrypted_jsonb -> $1 > $2"); + + match type_check(schema, &statement) { + Ok(typed) => match typed.transform(HashMap::new()) { + Ok(statement) => { + assert_eq!( + statement.to_string(), + "SELECT encrypted_jsonb FROM encrypted WHERE \ + eql_v2.ore_cllw((encrypted_jsonb -> $1::JSONB::eql_v2_encrypted)::JSONB) > \ + eql_v2.ore_cllw($2::JSONB)" + ); + } + Err(err) => panic!("transformation failed: {err}"), + }, + Err(err) => panic!("type check failed: {err}"), + } + } + + /// The `jsonb_path_query_first` form of a jsonb sv ordering comparison must + /// also rewrite to a CLLW ORE comparison. The function is first rewritten to + /// `eql_v2.jsonb_path_query_first` by `RewriteStandardSqlFnsOnEqlTypes`. + #[test] + fn jsonb_sv_ordering_path_query_first_rewrites_to_ore_cllw() { + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_jsonb (EQL: JsonLike), + } + } + }); + + let statement = parse( + "SELECT encrypted_jsonb FROM encrypted WHERE jsonb_path_query_first(encrypted_jsonb, $1) > $2", + ); + + match type_check(schema, &statement) { + Ok(typed) => match typed.transform(HashMap::new()) { + Ok(statement) => { + assert_eq!( + statement.to_string(), + "SELECT encrypted_jsonb FROM encrypted WHERE \ + eql_v2.ore_cllw(eql_v2.jsonb_path_query_first(encrypted_jsonb, $1::JSONB::eql_v2_encrypted)::JSONB) > \ + eql_v2.ore_cllw($2::JSONB)" + ); + } + Err(err) => panic!("transformation failed: {err}"), + }, + Err(err) => panic!("type check failed: {err}"), + } + } + + /// Each ordering operator (`<`, `<=`, `>`, `>=`) on a jsonb sv element must + /// rewrite to the CLLW ORE comparison preserving the operator. + #[test] + fn jsonb_sv_ordering_all_operators_rewrite_to_ore_cllw() { + for op in ["<", "<=", ">", ">="] { + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_jsonb (EQL: JsonLike), + } + } + }); + + let statement = parse(&format!( + "SELECT encrypted_jsonb FROM encrypted WHERE encrypted_jsonb -> $1 {op} $2" + )); + + match type_check(schema, &statement) { + Ok(typed) => match typed.transform(HashMap::new()) { + Ok(statement) => { + assert_eq!( + statement.to_string(), + format!( + "SELECT encrypted_jsonb FROM encrypted WHERE \ + eql_v2.ore_cllw((encrypted_jsonb -> $1::JSONB::eql_v2_encrypted)::JSONB) {op} \ + eql_v2.ore_cllw($2::JSONB)" + ) + ); + } + Err(err) => panic!("transformation failed for `{op}`: {err}"), + }, + Err(err) => panic!("type check failed for `{op}`: {err}"), + } + } + } + + /// Equality (`=`) on a jsonb sv element must NOT be rewritten to a CLLW ORE + /// comparison: equality is hmac/oc-based and resolves through the + /// `eql_v2.eq_term` path, not the ordering `eql_v2.ore_cllw` path. This + /// guards against the ordering rule over-matching the equality operators. + #[test] + fn jsonb_sv_equality_is_not_rewritten_to_ore_cllw() { + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_jsonb (EQL: JsonLike), + } + } + }); + + let statement = + parse("SELECT encrypted_jsonb FROM encrypted WHERE encrypted_jsonb -> $1 = $2"); + + match type_check(schema, &statement) { + Ok(typed) => match typed.transform(HashMap::new()) { + Ok(statement) => { + let sql = statement.to_string(); + assert!( + !sql.contains("ore_cllw"), + "equality must not bind to ore_cllw, got: {sql}" + ); + } + Err(err) => panic!("transformation failed: {err}"), + }, + Err(err) => panic!("type check failed: {err}"), + } + } + + /// Group C (CIP-3281): equality (`=`) on a jsonb sv-element extracted via + /// `->` must be rewritten to compare the XOR-aware equality terms so the SQL + /// binds to `eql_v2.eq_term` rather than the root `eql_v2_encrypted` + /// equality path. The left operand (`col -> sel`) is an + /// `eql_v2.ste_vec_entry`, so `eql_v2.eq_term(...)` reads its `hm`/`oc` + /// term; the right operand is the query payload jsonb, whose `hm`/`oc` term + /// is read with the inlined `eq_term` body (`decode(coalesce(...), 'hex')`). + #[test] + fn jsonb_sv_equality_rewrites_to_eq_term() { + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_jsonb (EQL: JsonLike), + } + } + }); + + let statement = + parse("SELECT encrypted_jsonb FROM encrypted WHERE encrypted_jsonb -> $1 = $2"); + + match type_check(schema, &statement) { + Ok(typed) => match typed.transform(HashMap::new()) { + Ok(statement) => { + assert_eq!( + statement.to_string(), + "SELECT encrypted_jsonb FROM encrypted WHERE \ + eql_v2.eq_term(encrypted_jsonb -> $1::JSONB::eql_v2_encrypted) = \ + decode(coalesce($2::JSONB ->> 'hm', $2::JSONB ->> 'oc'), 'hex')" + ); + } + Err(err) => panic!("transformation failed: {err}"), + }, + Err(err) => panic!("type check failed: {err}"), + } + } + + /// Inequality (`<>`) on a jsonb sv element must rewrite to the same + /// `eql_v2.eq_term` comparison, preserving the `<>` operator. + #[test] + fn jsonb_sv_inequality_rewrites_to_eq_term() { + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_jsonb (EQL: JsonLike), + } + } + }); + + let statement = + parse("SELECT encrypted_jsonb FROM encrypted WHERE encrypted_jsonb -> $1 <> $2"); + + match type_check(schema, &statement) { + Ok(typed) => match typed.transform(HashMap::new()) { + Ok(statement) => { + assert_eq!( + statement.to_string(), + "SELECT encrypted_jsonb FROM encrypted WHERE \ + eql_v2.eq_term(encrypted_jsonb -> $1::JSONB::eql_v2_encrypted) <> \ + decode(coalesce($2::JSONB ->> 'hm', $2::JSONB ->> 'oc'), 'hex')" + ); + } + Err(err) => panic!("transformation failed: {err}"), + }, + Err(err) => panic!("type check failed: {err}"), + } + } + + /// The `jsonb_path_query_first` form of a jsonb sv equality comparison must + /// also rewrite to the `eql_v2.eq_term` path. The function is first + /// rewritten to `eql_v2.jsonb_path_query_first` by + /// `RewriteStandardSqlFnsOnEqlTypes`; its result is an `eql_v2_encrypted`, + /// so it is cast to `::eql_v2.ste_vec_entry` (via jsonb) before `eq_term`. + #[test] + fn jsonb_sv_equality_path_query_first_rewrites_to_eq_term() { + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_jsonb (EQL: JsonLike), + } + } + }); + + let statement = parse( + "SELECT encrypted_jsonb FROM encrypted WHERE jsonb_path_query_first(encrypted_jsonb, $1) = $2", + ); + + match type_check(schema, &statement) { + Ok(typed) => match typed.transform(HashMap::new()) { + Ok(statement) => { + assert_eq!( + statement.to_string(), + "SELECT encrypted_jsonb FROM encrypted WHERE \ + eql_v2.eq_term(eql_v2.jsonb_path_query_first(encrypted_jsonb, $1::JSONB::eql_v2_encrypted)::JSONB::eql_v2.ste_vec_entry) = \ + decode(coalesce($2::JSONB ->> 'hm', $2::JSONB ->> 'oc'), 'hex')" + ); + } + Err(err) => panic!("transformation failed: {err}"), + }, + Err(err) => panic!("type check failed: {err}"), + } + } + + /// The RHS param of a jsonb sv equality comparison must resolve to + /// `EqlTerm::SteVecTerm` so the proxy encrypts it as the matching STE-vec + /// equality term (`hm`/`oc`) carried by the column's leaf. + #[test] + fn jsonb_sv_equality_rhs_param_is_ste_vec_term() { + use crate::unifier::EqlTerm; + + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_jsonb (EQL: JsonLike), + } + } + }); + + let statement = + parse("SELECT encrypted_jsonb FROM encrypted WHERE encrypted_jsonb -> $1 = $2"); + + let typed = type_check(schema, &statement) + .map_err(|err| err.to_string()) + .unwrap(); + + let (_, value) = typed + .params + .iter() + .find(|(p, _)| *p == Param(2)) + .expect("param $2 should be present"); + + assert!( + matches!(value, Value::Eql(EqlTerm::SteVecTerm(_))), + "expected $2 to be EqlTerm::SteVecTerm, got {value}" + ); + } + + /// The RHS *literal* of a jsonb sv equality comparison must also resolve to + /// `EqlTerm::SteVecTerm` so a simple-protocol query (inline literal) is + /// encrypted as the matching STE-vec equality term. + #[test] + fn jsonb_sv_equality_rhs_literal_is_ste_vec_term() { + use crate::unifier::EqlTerm; + + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_jsonb (EQL: JsonLike), + } + } + }); + + let statement = + parse("SELECT encrypted_jsonb FROM encrypted WHERE encrypted_jsonb -> 'number' = 4"); + + let typed = type_check(schema, &statement) + .map_err(|err| err.to_string()) + .unwrap(); + + let ste_vec_term_count = typed + .literals + .iter() + .filter(|(eql_term, _)| matches!(eql_term, EqlTerm::SteVecTerm(_))) + .count(); + + assert_eq!( + ste_vec_term_count, 1, + "expected exactly one SteVecTerm literal (the comparison value), got {:?}", + typed.literals + ); + } + + /// The commutative form with the accessor on the *right* + /// (`value = col -> selector`) is intentionally NOT rewritten: the rule and + /// the reclassification both gate on `is_ste_vec_accessor(left)`, so they + /// agree to leave it as root `eql_v2_encrypted` equality. This guards that + /// intentional left-operand-only behaviour so a future change that "adds + /// commutativity" to only one of the two passes is caught. + #[test] + fn jsonb_sv_equality_commutative_form_is_not_rewritten() { + use crate::unifier::EqlTerm; + + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_jsonb (EQL: JsonLike), + } + } + }); + + let statement = + parse("SELECT encrypted_jsonb FROM encrypted WHERE $2 = encrypted_jsonb -> $1"); + + let typed = type_check(schema, &statement) + .map_err(|err| err.to_string()) + .unwrap(); + + // The rewrite must not fire: no `eq_term` in the emitted SQL. + let sql = typed.transform(HashMap::new()).unwrap().to_string(); + assert!( + !sql.contains("eq_term"), + "commutative form must not be rewritten to eq_term, got: {sql}" + ); + + // And the param must NOT be reclassified to SteVecTerm — the two passes + // must stay in lockstep. + let (_, value) = typed + .params + .iter() + .find(|(p, _)| *p == Param(2)) + .expect("param $2 should be present"); + assert!( + !matches!(value, Value::Eql(EqlTerm::SteVecTerm(_))), + "commutative-form RHS must NOT be reclassified to SteVecTerm, got {value}" + ); + } + + /// The RHS param of a jsonb sv ordering comparison must resolve to + /// `EqlTerm::SteVecTerm` so the proxy encrypts it as a CLLW ORE STE-vec + /// query term (`oc`). + #[test] + fn jsonb_sv_ordering_rhs_param_is_ste_vec_term() { + use crate::unifier::EqlTerm; + + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_jsonb (EQL: JsonLike), + } + } + }); + + let statement = + parse("SELECT encrypted_jsonb FROM encrypted WHERE encrypted_jsonb -> $1 > $2"); + + let typed = type_check(schema, &statement) + .map_err(|err| err.to_string()) + .unwrap(); + + // $2 is the comparison RHS; it must be a SteVecTerm. + let (_, value) = typed + .params + .iter() + .find(|(p, _)| *p == Param(2)) + .expect("param $2 should be present"); + + assert!( + matches!(value, Value::Eql(EqlTerm::SteVecTerm(_))), + "expected $2 to be EqlTerm::SteVecTerm, got {value}" + ); + } + + /// The RHS *literal* of a jsonb sv ordering comparison must also resolve to + /// `EqlTerm::SteVecTerm` so a simple-protocol query (inline literal) is + /// encrypted as a CLLW ORE STE-vec query term (`oc`). + #[test] + fn jsonb_sv_ordering_rhs_literal_is_ste_vec_term() { + use crate::unifier::EqlTerm; + + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_jsonb (EQL: JsonLike), + } + } + }); + + let statement = + parse("SELECT encrypted_jsonb FROM encrypted WHERE encrypted_jsonb -> 'number' > 4"); + + let typed = type_check(schema, &statement) + .map_err(|err| err.to_string()) + .unwrap(); + + // The literals are (selector, comparison-value). The comparison value + // (`4`) must be a SteVecTerm; the selector (`'number'`) is a JsonAccessor. + let ste_vec_term_count = typed + .literals + .iter() + .filter(|(eql_term, _)| matches!(eql_term, EqlTerm::SteVecTerm(_))) + .count(); + + assert_eq!( + ste_vec_term_count, 1, + "expected exactly one SteVecTerm literal (the comparison value), got {:?}", + typed.literals + ); + } + + /// The RHS param of a root-scalar ordering comparison must remain + /// `EqlTerm::Partial` (root Block-ORE path), NOT be reclassified. + #[test] + fn root_scalar_ordering_rhs_param_is_partial() { + use crate::unifier::EqlTerm; + + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_int (EQL: Ord), + } + } + }); + + let statement = parse("SELECT id FROM encrypted WHERE encrypted_int > $1"); + + let typed = type_check(schema, &statement) + .map_err(|err| err.to_string()) + .unwrap(); + + let (_, value) = typed + .params + .iter() + .find(|(p, _)| *p == Param(1)) + .expect("param $1 should be present"); + + assert!( + !matches!(value, Value::Eql(EqlTerm::SteVecTerm(_))), + "root-scalar ordering RHS must NOT be reclassified to SteVecTerm, got {value}" + ); + } + + /// A root-scalar ordering comparison (not a jsonb sv accessor) must NOT be + /// rewritten — it relies on the root Block-ORE (`ob`) operators. + #[test] + fn root_scalar_ordering_is_not_rewritten_to_ore_cllw() { + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_int (EQL: Ord), + } + } + }); + + let statement = parse("SELECT id FROM encrypted WHERE encrypted_int > $1"); + + match type_check(schema, &statement) { + Ok(typed) => match typed.transform(HashMap::new()) { + Ok(statement) => { + assert_eq!( + statement.to_string(), + "SELECT id FROM encrypted WHERE encrypted_int > $1::JSONB::eql_v2_encrypted" + ); + } + Err(err) => panic!("transformation failed: {err}"), + }, + Err(err) => panic!("type check failed: {err}"), + } + } + #[test] fn ensure_eql_mapper_does_not_choke_on_elixir_ecto_schema_metadata_query() { // init_tracing(); @@ -2157,6 +2657,411 @@ mod test { .unwrap(); } + /// A scalar (non-jsonb) `eql_v2_encrypted` column whose resolved index set + /// contains `Ope` must have ordering comparisons rewritten to compare the + /// order-preserving `op` ciphertext directly using Postgres built-ins. + /// + /// `col $param` → `decode(col->>'op','hex') decode($param->>'op','hex')` + #[test] + fn scalar_ope_ordering_rewrites_to_decode_op() { + use crate::{IndexKind, MapIndexResolver}; + use std::collections::HashSet; + + for op in ["<", "<=", ">", ">="] { + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_int (EQL: Ord), + } + } + }); + + let index_resolver = Arc::new(MapIndexResolver::new(HashMap::from_iter([( + TableColumn { + table: id("encrypted"), + column: id("encrypted_int"), + }, + HashSet::from_iter([IndexKind::Ope]), + )]))); + + let statement = parse(&format!( + "SELECT id FROM encrypted WHERE encrypted_int {op} $1" + )); + + let typed = type_check_with_indexes(schema, &statement, index_resolver) + .map_err(|err| err.to_string()) + .unwrap(); + + let transformed = typed.transform(HashMap::new()).unwrap(); + + assert_eq!( + transformed.to_string(), + format!( + "SELECT id FROM encrypted WHERE \ + decode(encrypted_int ->> 'op', 'hex') {op} \ + decode($1::JSONB::eql_v2_encrypted ->> 'op', 'hex')" + ), + "operator `{op}` must rewrite to a decode(op) byte comparison" + ); + } + } + + /// `ORDER BY col [ASC|DESC] [NULLS …]` on a scalar OPE column must rewrite + /// the sort key to `decode(col->>'op','hex')`, preserving direction and + /// nulls ordering. + #[test] + fn scalar_ope_order_by_rewrites_to_decode_op() { + use crate::{IndexKind, MapIndexResolver}; + use std::collections::HashSet; + + let cases = [ + ( + "ORDER BY encrypted_int", + "ORDER BY decode(encrypted_int ->> 'op', 'hex')", + ), + ( + "ORDER BY encrypted_int ASC", + "ORDER BY decode(encrypted_int ->> 'op', 'hex') ASC", + ), + ( + "ORDER BY encrypted_int DESC", + "ORDER BY decode(encrypted_int ->> 'op', 'hex') DESC", + ), + ( + "ORDER BY encrypted_int DESC NULLS LAST", + "ORDER BY decode(encrypted_int ->> 'op', 'hex') DESC NULLS LAST", + ), + ( + "ORDER BY encrypted_int ASC NULLS FIRST", + "ORDER BY decode(encrypted_int ->> 'op', 'hex') ASC NULLS FIRST", + ), + ]; + + for (order_by, expected_order_by) in cases { + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_int (EQL: Ord), + } + } + }); + + let index_resolver = Arc::new(MapIndexResolver::new(HashMap::from_iter([( + TableColumn { + table: id("encrypted"), + column: id("encrypted_int"), + }, + HashSet::from_iter([IndexKind::Ope]), + )]))); + + let statement = parse(&format!("SELECT id FROM encrypted {order_by}")); + + let typed = type_check_with_indexes(schema, &statement, index_resolver) + .map_err(|err| err.to_string()) + .unwrap(); + + let transformed = typed.transform(HashMap::new()).unwrap(); + + assert_eq!( + transformed.to_string(), + format!("SELECT id FROM encrypted {expected_order_by}"), + "`{order_by}` must rewrite the sort key to decode(op)" + ); + } + } + + /// An ORE-only scalar column (resolved index set has `Ore`, no `Ope`) must + /// NOT be rewritten to the `decode(op)` form: it stays on the existing + /// root Block-ORE bare-operator path. + #[test] + fn scalar_ore_only_ordering_is_not_rewritten_to_decode_op() { + use crate::{IndexKind, MapIndexResolver}; + use std::collections::HashSet; + + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_int (EQL: Ord), + } + } + }); + + let index_resolver = Arc::new(MapIndexResolver::new(HashMap::from_iter([( + TableColumn { + table: id("encrypted"), + column: id("encrypted_int"), + }, + HashSet::from_iter([IndexKind::Ore]), + )]))); + + let statement = parse("SELECT id FROM encrypted WHERE encrypted_int > $1"); + + let typed = type_check_with_indexes(schema, &statement, index_resolver) + .map_err(|err| err.to_string()) + .unwrap(); + + let transformed = typed.transform(HashMap::new()).unwrap(); + + assert_eq!( + transformed.to_string(), + "SELECT id FROM encrypted WHERE encrypted_int > $1::JSONB::eql_v2_encrypted", + "ORE-only column must remain on the bare-operator path" + ); + } + + /// With no concrete index information (empty resolver, the default), a + /// scalar ordering comparison must NOT be rewritten to `decode(op)`: it + /// retains today's behaviour. This is the guard that the default/empty + /// resolver reproduces existing behaviour. + #[test] + fn scalar_ordering_with_empty_resolver_is_not_rewritten() { + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_int (EQL: Ord), + } + } + }); + + let statement = parse("SELECT id FROM encrypted WHERE encrypted_int > $1"); + + // `type_check` uses the empty resolver by default. + let typed = type_check(schema, &statement) + .map_err(|err| err.to_string()) + .unwrap(); + + let transformed = typed.transform(HashMap::new()).unwrap(); + + assert_eq!( + transformed.to_string(), + "SELECT id FROM encrypted WHERE encrypted_int > $1::JSONB::eql_v2_encrypted", + "empty resolver must reproduce today's behaviour" + ); + } + + /// Equality (`=`) on a scalar OPE column must NOT be rewritten by the + /// ordering rule (equality is a separate concern, CIP-3281). + #[test] + fn scalar_ope_equality_is_not_rewritten_to_decode_op() { + use crate::{IndexKind, MapIndexResolver}; + use std::collections::HashSet; + + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_int (EQL: Eq + Ord), + } + } + }); + + let index_resolver = Arc::new(MapIndexResolver::new(HashMap::from_iter([( + TableColumn { + table: id("encrypted"), + column: id("encrypted_int"), + }, + HashSet::from_iter([IndexKind::Ope, IndexKind::Unique]), + )]))); + + let statement = parse("SELECT id FROM encrypted WHERE encrypted_int = $1"); + + let typed = type_check_with_indexes(schema, &statement, index_resolver) + .map_err(|err| err.to_string()) + .unwrap(); + + let transformed = typed.transform(HashMap::new()).unwrap(); + + assert_eq!( + transformed.to_string(), + "SELECT id FROM encrypted WHERE encrypted_int = $1::JSONB::eql_v2_encrypted", + "equality must not be rewritten by the OPE ordering rule" + ); + } + + /// Pins the *actual* behaviour for column-on-the-right (`$param col`). + /// + /// The rule's `would_edit_comparison` guard reads `is_scalar_ope_column(left) + /// && is_eql_typed(right)`, which textually looks like it only matches the + /// EQL column on the left. In practice a bare comparison param is unified to + /// the *same* `eql_v2_encrypted` column type, so `is_scalar_ope_column` + /// resolves the param's `TableColumn` to the OPE-indexed column and the rule + /// fires for *either* operand ordering. Both sides are therefore wrapped in + /// `decode(... ->> 'op', 'hex')`. The operator itself is unchanged (`<` stays + /// `<`); correctness rests on `decode(... ->> 'op')` being an order-preserving + /// transform applied to *both* operands, so the comparison still evaluates the + /// same OPE ordering it would have on the unwrapped values. + /// + /// This differs from `RewriteJsonbSteVecOrdering`, where the jsonb accessor + /// (`col -> selector`) only appears syntactically on one side. Here there is + /// no coverage gap to pin — column-on-the-right is handled. Recorded as a + /// test so the behaviour is locked in rather than incidental. + #[test] + fn scalar_ope_ordering_column_on_right_is_rewritten() { + use crate::{IndexKind, MapIndexResolver}; + use std::collections::HashSet; + + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_int (EQL: Ord), + } + } + }); + + let index_resolver = Arc::new(MapIndexResolver::new(HashMap::from_iter([( + TableColumn { + table: id("encrypted"), + column: id("encrypted_int"), + }, + HashSet::from_iter([IndexKind::Ope]), + )]))); + + let statement = parse("SELECT id FROM encrypted WHERE $1 < encrypted_int"); + + let typed = type_check_with_indexes(schema, &statement, index_resolver) + .map_err(|err| err.to_string()) + .unwrap(); + + let transformed = typed.transform(HashMap::new()).unwrap(); + + assert_eq!( + transformed.to_string(), + "SELECT id FROM encrypted WHERE \ + decode($1::JSONB::eql_v2_encrypted ->> 'op', 'hex') < \ + decode(encrypted_int ->> 'op', 'hex')", + "column-on-the-right comparison is rewritten symmetrically to decode(op)" + ); + } + + /// Pins an accepted limitation: `col BETWEEN $a AND $b` is a distinct AST + /// node (`Expr::Between`), not the `Expr::BinaryOp` the OPE ordering rule + /// matches, so it is NOT rewritten to the `decode(op)` form. + #[test] + fn scalar_ope_ordering_between_is_not_rewritten() { + use crate::{IndexKind, MapIndexResolver}; + use std::collections::HashSet; + + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_int (EQL: Ord), + } + } + }); + + let index_resolver = Arc::new(MapIndexResolver::new(HashMap::from_iter([( + TableColumn { + table: id("encrypted"), + column: id("encrypted_int"), + }, + HashSet::from_iter([IndexKind::Ope]), + )]))); + + let statement = parse("SELECT id FROM encrypted WHERE encrypted_int BETWEEN $1 AND $2"); + + let typed = type_check_with_indexes(schema, &statement, index_resolver) + .map_err(|err| err.to_string()) + .unwrap(); + + let transformed = typed.transform(HashMap::new()).unwrap(); + + assert_eq!( + transformed.to_string(), + "SELECT id FROM encrypted WHERE encrypted_int BETWEEN \ + $1::JSONB::eql_v2_encrypted AND $2::JSONB::eql_v2_encrypted", + "BETWEEN must not be rewritten by the OPE ordering rule" + ); + } + + /// Pins an accepted limitation: `MIN(col)` / `MAX(col)` are aggregate + /// function calls, not ordering comparisons, so the OPE ordering rule does + /// not touch them. They are instead routed to the `eql_v2.min` / `eql_v2.max` + /// EQL aggregate functions by a separate rule. + #[test] + fn scalar_ope_min_max_are_not_rewritten_to_decode_op() { + use crate::{IndexKind, MapIndexResolver}; + use std::collections::HashSet; + + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_int (EQL: Ord), + } + } + }); + + let index_resolver = Arc::new(MapIndexResolver::new(HashMap::from_iter([( + TableColumn { + table: id("encrypted"), + column: id("encrypted_int"), + }, + HashSet::from_iter([IndexKind::Ope]), + )]))); + + let statement = parse("SELECT min(encrypted_int), max(encrypted_int) FROM encrypted"); + + let typed = type_check_with_indexes(schema, &statement, index_resolver) + .map_err(|err| err.to_string()) + .unwrap(); + + let transformed = typed.transform(HashMap::new()).unwrap(); + + assert_eq!( + transformed.to_string(), + "SELECT eql_v2.min(encrypted_int), eql_v2.max(encrypted_int) FROM encrypted", + "MIN/MAX must route to eql_v2 aggregates, not decode(op)" + ); + } + + /// A multi-key `ORDER BY ope_col, plaintext_col` must rewrite *only* the + /// OPE sort key to `decode(col->>'op','hex')`, leaving the plaintext key + /// untouched and preserving each key's direction / nulls ordering. + #[test] + fn scalar_ope_multi_key_order_by_rewrites_only_ope_key() { + use crate::{IndexKind, MapIndexResolver}; + use std::collections::HashSet; + + let schema = resolver(schema! { + tables: { + encrypted: { + id, + encrypted_int (EQL: Ord), + } + } + }); + + let index_resolver = Arc::new(MapIndexResolver::new(HashMap::from_iter([( + TableColumn { + table: id("encrypted"), + column: id("encrypted_int"), + }, + HashSet::from_iter([IndexKind::Ope]), + )]))); + + let statement = + parse("SELECT id FROM encrypted ORDER BY encrypted_int DESC NULLS LAST, id ASC"); + + let typed = type_check_with_indexes(schema, &statement, index_resolver) + .map_err(|err| err.to_string()) + .unwrap(); + + let transformed = typed.transform(HashMap::new()).unwrap(); + + assert_eq!( + transformed.to_string(), + "SELECT id FROM encrypted ORDER BY \ + decode(encrypted_int ->> 'op', 'hex') DESC NULLS LAST, id ASC", + "only the OPE sort key is rewritten; plaintext key and directions are preserved" + ); + } + #[test] fn functions_can_be_resolved_case_insensitively() { // init_tracing(); diff --git a/packages/eql-mapper/src/ste_vec_ordering.rs b/packages/eql-mapper/src/ste_vec_ordering.rs new file mode 100644 index 00000000..c1d7d0c3 --- /dev/null +++ b/packages/eql-mapper/src/ste_vec_ordering.rs @@ -0,0 +1,144 @@ +//! Shared predicates and operand helpers for recognising and rewriting jsonb +//! STE-vec *term* comparisons (both ordering and equality). +//! +//! Two independent passes must agree on exactly which comparisons are jsonb +//! STE-vec term comparisons: +//! +//! - The SQL rewrites +//! ([`crate::transformation_rules::RewriteJsonbSteVecOrdering`] wraps the +//! operands in `eql_v2.ore_cllw(...)`; +//! [`crate::transformation_rules::RewriteJsonbSteVecEquality`] binds them to +//! `eql_v2.eq_term(...)`). +//! - The parameter/literal reclassification (in [`crate::eql_mapper`]) marks the +//! right-hand-side value as an [`crate::EqlTerm::SteVecTerm`] so it is +//! encrypted as the matching STE-vec query term (`oc` for CLLW ORE leaves, +//! `hm` for hmac/term-filter leaves). +//! +//! If these two passes disagree, the proxy can emit STE-vec SQL against a value +//! that was *not* encrypted as a STE-vec query term (or vice versa), silently +//! producing wrong results. Keeping the structural predicates **and** the +//! encryption-binding-critical operand helpers ([`is_eql_typed`], +//! [`rhs_as_jsonb`]) in one place ensures they cannot drift apart. + +use std::collections::HashMap; + +use sqltk::parser::ast::{BinaryOperator, CastKind, DataType, Expr, ObjectName, ObjectNamePart}; +use sqltk::NodeKey; + +use crate::unifier::{Type, Value}; + +/// Returns `true` if `op` is an *ordering* comparison (`<`, `<=`, `>`, `>=`). +/// +/// Equality (`=`, `<>`) is deliberately excluded: sv equality resolves through +/// the `eql_v2.eq_term` operator path (see [`is_equality_operator`] and +/// [`crate::transformation_rules::RewriteJsonbSteVecEquality`]), not the CLLW +/// ORE ordering path. +pub(crate) fn is_ordering_operator(op: &BinaryOperator) -> bool { + matches!( + op, + BinaryOperator::Lt | BinaryOperator::LtEq | BinaryOperator::Gt | BinaryOperator::GtEq + ) +} + +/// Returns `true` if `op` is an *equality* comparison (`=`, `<>`). +/// +/// sv equality binds to the XOR-aware `eql_v2.eq_term` extractor (which +/// coalesces a leaf's `hm`/`oc` term), so it is rewritten separately from the +/// ordering comparisons handled by [`is_ordering_operator`]. +pub(crate) fn is_equality_operator(op: &BinaryOperator) -> bool { + matches!(op, BinaryOperator::Eq | BinaryOperator::NotEq) +} + +/// Returns `true` if `expr` extracts a single STE-vec element from an EQL jsonb +/// column — i.e. it is the result of `->` / `->>` or `jsonb_path_query_first`. +/// +/// These are the expressions whose value is a single sv element (carrying a +/// CLLW ORE `oc` term) rather than a root `eql_v2_encrypted` value, so an +/// ordering comparison against them must be rewritten to compare CLLW ORE terms. +pub(crate) fn is_ste_vec_accessor(expr: &Expr) -> bool { + match expr { + Expr::BinaryOp { + op: BinaryOperator::Arrow | BinaryOperator::LongArrow, + .. + } => true, + Expr::Function(function) => is_jsonb_path_query_first(&function.name), + _ => false, + } +} + +/// Matches `jsonb_path_query_first` and its `eql_v2.`-qualified rewritten form +/// (case-insensitively). +fn is_jsonb_path_query_first(name: &ObjectName) -> bool { + let parts: Vec = name + .0 + .iter() + .map(|part| match part { + ObjectNamePart::Identifier(ident) => ident.value.to_lowercase(), + }) + .collect(); + + matches!(parts.as_slice(), [f] if f == "jsonb_path_query_first") + || matches!(parts.as_slice(), [schema, f] if schema == "eql_v2" && f == "jsonb_path_query_first") +} + +/// Returns `true` if `expr` is EQL-typed in `node_types`. +/// +/// Shared by the ordering and equality rewrite rules so both bind the rewrite +/// to exactly the same operands. Kept here (rather than duplicated per rule) +/// because mis-identifying an operand as EQL-typed would bind a rewrite to a +/// value encrypted differently than the SQL extracts it. +pub(crate) fn is_eql_typed(node_types: &HashMap, Type>, expr: &Expr) -> bool { + matches!( + node_types.get(&NodeKey::new(expr)), + Some(Type::Value(Value::Eql(_))) + ) +} + +/// Reduces a STE-vec comparison's right-hand-side operand to a bare `::JSONB` +/// value. +/// +/// The casting rules wrap encrypted params/literals as +/// `::JSONB::eql_v2_encrypted`. The STE-vec extractors +/// (`eql_v2.ore_cllw(jsonb)` for ordering, the inlined `eq_term` `->>` reads for +/// equality) operate on raw `jsonb`, so the outer `::eql_v2_encrypted` cast is +/// stripped, leaving `::JSONB`. If the expression is not in the expected +/// double-cast shape it is wrapped in a `::JSONB` cast defensively. +/// +/// This cast-shape logic is encryption-binding-critical and shared by both +/// rewrite rules: a divergence would bind a value encrypted one way to SQL that +/// extracts it another way, silently producing wrong results. +pub(crate) fn rhs_as_jsonb(expr: Expr) -> Expr { + if let Expr::Cast { + kind: CastKind::DoubleColon, + expr: inner, + data_type: DataType::Custom(name, _), + .. + } = &expr + { + let is_encrypted = name.0.len() == 1 + && matches!( + &name.0[0], + ObjectNamePart::Identifier(ident) + if ident.value.eq_ignore_ascii_case("eql_v2_encrypted") + ); + + if is_encrypted { + if let Expr::Cast { + data_type: DataType::JSONB, + .. + } = &**inner + { + // Strip the outer `::eql_v2_encrypted` cast, keeping `::JSONB`. + return (**inner).clone(); + } + } + } + + // Defensive fallback: cast whatever we have to JSONB. + Expr::Cast { + kind: CastKind::DoubleColon, + expr: Box::new(expr), + data_type: DataType::JSONB, + format: None, + } +} diff --git a/packages/eql-mapper/src/transformation_rules/mod.rs b/packages/eql-mapper/src/transformation_rules/mod.rs index 2d440042..588360c4 100644 --- a/packages/eql-mapper/src/transformation_rules/mod.rs +++ b/packages/eql-mapper/src/transformation_rules/mod.rs @@ -16,6 +16,9 @@ mod cast_params_as_encrypted; mod fail_on_placeholder_change; mod preserve_effective_aliases; mod rewrite_containment_ops; +mod rewrite_jsonb_ste_vec_equality; +mod rewrite_jsonb_ste_vec_ordering; +mod rewrite_scalar_ope_ordering; mod rewrite_standard_sql_fns_on_eql_types; use std::marker::PhantomData; @@ -25,6 +28,9 @@ pub(crate) use cast_params_as_encrypted::*; pub(crate) use fail_on_placeholder_change::*; pub(crate) use preserve_effective_aliases::*; pub(crate) use rewrite_containment_ops::*; +pub(crate) use rewrite_jsonb_ste_vec_equality::*; +pub(crate) use rewrite_jsonb_ste_vec_ordering::*; +pub(crate) use rewrite_scalar_ope_ordering::*; pub(crate) use rewrite_standard_sql_fns_on_eql_types::*; use crate::EqlMapperError; diff --git a/packages/eql-mapper/src/transformation_rules/rewrite_jsonb_ste_vec_equality.rs b/packages/eql-mapper/src/transformation_rules/rewrite_jsonb_ste_vec_equality.rs new file mode 100644 index 00000000..05888d90 --- /dev/null +++ b/packages/eql-mapper/src/transformation_rules/rewrite_jsonb_ste_vec_equality.rs @@ -0,0 +1,239 @@ +use std::collections::HashMap; +use std::mem; +use std::sync::Arc; + +use sqltk::parser::ast::Value as SqltkValue; +use sqltk::parser::ast::{ + BinaryOperator, CastKind, DataType, Expr, Function, FunctionArg, FunctionArgExpr, + FunctionArgumentList, FunctionArguments, Ident, ObjectName, ObjectNamePart, ValueWithSpan, +}; +use sqltk::parser::tokenizer::Span; +use sqltk::{NodeKey, NodePath, Visitable}; + +use crate::ste_vec_ordering::{ + is_eql_typed, is_equality_operator, is_ste_vec_accessor, rhs_as_jsonb, +}; +use crate::unifier::Type; +use crate::EqlMapperError; + +use super::TransformationRule; + +/// Rewrites equality comparisons (`=`, `<>`) on a jsonb STE-vec element +/// extracted via `->` / `->>` / `jsonb_path_query_first` so that the comparison +/// binds to the XOR-aware `eql_v2.eq_term` extractor in EQL 2.3.1 instead of the +/// root `eql_v2_encrypted` equality path. +/// +/// # Why this is needed +/// +/// In EQL 2.3.1 the `->` operator on `eql_v2_encrypted` returns an +/// `eql_v2.ste_vec_entry` (a single sv element carrying exactly one +/// deterministic equality term — `hm` for bool/null/array/object/root leaves or +/// `oc` for string/number leaves). A bare equality comparison such as +/// `(col -> selector) = $param` does not resolve to the `ste_vec_entry` +/// equality operators because the right-hand side is a root sv query payload +/// (`{"k":"sv", ..., "hm"|"oc": ...}`) which is *not* a full `ste_vec_entry` +/// (it lacks the `s` / `c` fields the `ste_vec_entry` DOMAIN CHECK requires). +/// Postgres instead resolves the comparison to the root `eql_v2_encrypted` +/// equality operators, comparing the wrong (root-scope) terms. +/// +/// EQL 2.3.1 provides `eql_v2.eq_term(eql_v2.ste_vec_entry)` which returns +/// `decode(coalesce(entry ->> 'hm', entry ->> 'oc'), 'hex')` — the XOR-aware +/// deterministic equality term. The `ste_vec_entry = ste_vec_entry` operators +/// are defined as `eq_term(a) = eq_term(b)`. This rule rewrites: +/// +/// ```sql +/// (col -> selector) = $param +/// ``` +/// +/// into: +/// +/// ```sql +/// eql_v2.eq_term(col -> selector) = decode(coalesce($param ->> 'hm', $param ->> 'oc'), 'hex') +/// ``` +/// +/// The left operand keeps the accessor (which already yields an +/// `eql_v2.ste_vec_entry` for the `->` form) and wraps it in `eql_v2.eq_term`. +/// The `jsonb_path_query_first` form yields an `eql_v2_encrypted`, so it is +/// first cast to `::JSONB::eql_v2.ste_vec_entry` (the merged element carries the +/// required `s` / `c` fields, so the DOMAIN CHECK passes). +/// +/// The right operand is the query payload jsonb. There is no `eq_term(jsonb)` +/// companion overload, so the `eq_term` body is inlined directly on the raw +/// jsonb: `decode(coalesce($param ->> 'hm', $param ->> 'oc'), 'hex')`. The +/// param/literal is reduced to a bare `::JSONB` value (the outer +/// `::eql_v2_encrypted` cast applied by [`super::CastParamsAsEncrypted`] / +/// [`super::CastLiteralsAsEncrypted`] is stripped) because `->>` reads the +/// `hm` / `oc` field off the jsonb directly. +/// +/// # Operand orientation +/// +/// Only the left-operand form (`col -> selector value`) is rewritten — the +/// rule gates on `is_ste_vec_accessor(left)`. The commutative form with the +/// accessor on the *right* (`value = col -> selector`) is intentionally left +/// untouched; it falls back to root `eql_v2_encrypted` equality. This matches +/// the sibling [`super::RewriteJsonbSteVecOrdering`] rule and, critically, the +/// reclassification pass in [`crate::eql_mapper`] gates on the same predicate, +/// so the two passes always agree (a value is never reclassified as a STE-vec +/// term without the SQL also being rewritten). Supporting the commutative form +/// would require normalising both passes in lockstep. +#[derive(Debug)] +pub struct RewriteJsonbSteVecEquality<'ast> { + node_types: Arc, Type>>, +} + +impl<'ast> RewriteJsonbSteVecEquality<'ast> { + pub fn new(node_types: Arc, Type>>) -> Self { + Self { node_types } + } + + /// Returns `true` if `expr` is the `->` / `->>` accessor form, whose result + /// is already an `eql_v2.ste_vec_entry`. + fn is_arrow_accessor(expr: &Expr) -> bool { + matches!( + expr, + Expr::BinaryOp { + op: BinaryOperator::Arrow | BinaryOperator::LongArrow, + .. + } + ) + } + + /// Wraps the left-hand sv-element accessor in `eql_v2.eq_term(...)`. + /// + /// The `->` / `->>` accessor already yields an `eql_v2.ste_vec_entry`, so it + /// is passed straight to `eq_term`. The `jsonb_path_query_first` form yields + /// an `eql_v2_encrypted`, so it is cast to `::JSONB::eql_v2.ste_vec_entry` + /// first. + fn wrap_eq_term_lhs(expr: Expr) -> Expr { + let entry = if Self::is_arrow_accessor(&expr) { + expr + } else { + Self::cast_ste_vec_entry(expr) + }; + Self::call("eql_v2", "eq_term", vec![entry]) + } + + /// Casts `expr` to `::JSONB::eql_v2.ste_vec_entry`. + fn cast_ste_vec_entry(expr: Expr) -> Expr { + let jsonb = Expr::Cast { + kind: CastKind::DoubleColon, + expr: Box::new(expr), + data_type: DataType::JSONB, + format: None, + }; + + Expr::Cast { + kind: CastKind::DoubleColon, + expr: Box::new(jsonb), + data_type: DataType::Custom( + ObjectName(vec![ + ObjectNamePart::Identifier(Ident::new("eql_v2")), + ObjectNamePart::Identifier(Ident::new("ste_vec_entry")), + ]), + vec![], + ), + format: None, + } + } + + /// Builds the right-hand-side equality term: + /// `decode(coalesce( ->> 'hm', ->> 'oc'), 'hex')`. + /// + /// This inlines the body of `eql_v2.eq_term(eql_v2.ste_vec_entry)` against + /// the raw query-payload jsonb (which carries exactly one of `hm` / `oc` at + /// its top level), matching whichever deterministic term the column's leaf + /// carries. + fn build_eq_term_rhs(expr: Expr) -> Expr { + let rhs = rhs_as_jsonb(expr); + + let hm = Self::field_text(rhs.clone(), "hm"); + let oc = Self::field_text(rhs, "oc"); + let coalesce = Self::call("", "coalesce", vec![hm, oc]); + + Self::call("", "decode", vec![coalesce, Self::string_literal("hex")]) + } + + /// Builds ` ->> ''`. + fn field_text(expr: Expr, field: &str) -> Expr { + Expr::BinaryOp { + left: Box::new(expr), + op: BinaryOperator::LongArrow, + right: Box::new(Self::string_literal(field)), + } + } + + /// Builds a single-quoted string literal expression. + fn string_literal(value: &str) -> Expr { + Expr::Value(ValueWithSpan { + value: SqltkValue::SingleQuotedString(value.to_string()), + span: Span::empty(), + }) + } + + /// Builds a function call `[schema.]name(args...)`. An empty `schema` + /// produces an unqualified call (e.g. `coalesce`, `decode`). + fn call(schema: &str, name: &str, args: Vec) -> Expr { + let mut parts = Vec::new(); + if !schema.is_empty() { + parts.push(ObjectNamePart::Identifier(Ident::new(schema))); + } + parts.push(ObjectNamePart::Identifier(Ident::new(name))); + + Expr::Function(Function { + name: ObjectName(parts), + uses_odbc_syntax: false, + args: FunctionArguments::List(FunctionArgumentList { + args: args + .into_iter() + .map(|arg| FunctionArg::Unnamed(FunctionArgExpr::Expr(arg))) + .collect(), + duplicate_treatment: None, + clauses: vec![], + }), + parameters: FunctionArguments::None, + filter: None, + null_treatment: None, + over: None, + within_group: vec![], + }) + } +} + +impl<'ast> TransformationRule<'ast> for RewriteJsonbSteVecEquality<'ast> { + fn apply( + &mut self, + node_path: &NodePath<'ast>, + target_node: &mut N, + ) -> Result { + if self.would_edit(node_path, target_node) { + let expr = target_node.downcast_mut::().unwrap(); + if let Expr::BinaryOp { left, op: _, right } = expr { + let dummy = Expr::Value(ValueWithSpan { + value: SqltkValue::Null, + span: Span::empty(), + }); + let left_expr = mem::replace(&mut **left, dummy.clone()); + let right_expr = mem::replace(&mut **right, dummy); + + **left = Self::wrap_eq_term_lhs(left_expr); + **right = Self::build_eq_term_rhs(right_expr); + return Ok(true); + } + } + + Ok(false) + } + + fn would_edit(&mut self, node_path: &NodePath<'ast>, _target_node: &N) -> bool { + if let Some((Expr::BinaryOp { left, op, right },)) = node_path.last_1_as::() { + if is_equality_operator(op) + && is_ste_vec_accessor(left) + && is_eql_typed(&self.node_types, left) + && is_eql_typed(&self.node_types, right) + { + return true; + } + } + false + } +} diff --git a/packages/eql-mapper/src/transformation_rules/rewrite_jsonb_ste_vec_ordering.rs b/packages/eql-mapper/src/transformation_rules/rewrite_jsonb_ste_vec_ordering.rs new file mode 100644 index 00000000..49c2def8 --- /dev/null +++ b/packages/eql-mapper/src/transformation_rules/rewrite_jsonb_ste_vec_ordering.rs @@ -0,0 +1,153 @@ +use std::collections::HashMap; +use std::mem; +use std::sync::Arc; + +use sqltk::parser::ast::Value as SqltkValue; +use sqltk::parser::ast::{ + CastKind, DataType, Expr, Function, FunctionArg, FunctionArgExpr, FunctionArgumentList, + FunctionArguments, Ident, ObjectName, ObjectNamePart, ValueWithSpan, +}; +use sqltk::parser::tokenizer::Span; +use sqltk::{NodeKey, NodePath, Visitable}; + +use crate::ste_vec_ordering::{ + is_eql_typed, is_ordering_operator, is_ste_vec_accessor, rhs_as_jsonb, +}; +use crate::unifier::Type; +use crate::EqlMapperError; + +use super::TransformationRule; + +/// Rewrites ordering comparisons (`<`, `<=`, `>`, `>=`) on a jsonb STE-vec +/// element extracted via `->` / `->>` / `jsonb_path_query_first` so that the +/// comparison binds to the CLLW ORE operators in EQL 2.3.1 instead of the root +/// Block-ORE (`ob`) path. +/// +/// # Why this is needed +/// +/// In EQL 2.3.1 the `->` operator on `eql_v2_encrypted` returns an +/// `eql_v2.ste_vec_entry` (a single sv element carrying a CLLW ORE term `oc`). +/// A bare ordering comparison such as `(col -> selector) > $param` does not +/// resolve to the `ste_vec_entry` ordering operators because the right-hand +/// side is a root `eql_v2_encrypted` query payload (which carries a root-scope +/// `oc` term but is not a full `ste_vec_entry` and so cannot satisfy that +/// domain's CHECK constraint). Postgres instead resolves the comparison to the +/// root `eql_v2_encrypted` ordering operators which require a Block-ORE (`ob`) +/// term, raising `Expected an ore index (ob) value in json`. +/// +/// EQL 2.3.1 provides `eql_v2.ore_cllw(eql_v2.ste_vec_entry)` and the companion +/// `eql_v2.ore_cllw(jsonb)` (the documented right-hand-side parameter helper) +/// which both yield an `eql_v2.ore_cllw` composite. The `ore_cllw +/// ore_cllw` operators perform the CLLW ORE comparison. This rule rewrites: +/// +/// ```sql +/// (col -> selector) > $param +/// ``` +/// +/// into: +/// +/// ```sql +/// eql_v2.ore_cllw(col -> selector) > eql_v2.ore_cllw($param::jsonb) +/// ``` +/// +/// The right-hand side is reduced to a bare `::JSONB` value (the outer +/// `::eql_v2_encrypted` cast applied by [`super::CastParamsAsEncrypted`] / +/// [`super::CastLiteralsAsEncrypted`] is stripped) because `eql_v2.ore_cllw` +/// accepts `jsonb`, not `eql_v2_encrypted`. +#[derive(Debug)] +pub struct RewriteJsonbSteVecOrdering<'ast> { + node_types: Arc, Type>>, +} + +impl<'ast> RewriteJsonbSteVecOrdering<'ast> { + pub fn new(node_types: Arc, Type>>) -> Self { + Self { node_types } + } + + /// Wraps `expr` in a `::JSONB` cast. + /// + /// When `expr` is a binary operator (e.g. the `->` / `->>` accessor) it is + /// first wrapped in parentheses so the cast applies to the whole accessor + /// result rather than binding tighter than `->` to its right operand. The + /// `::` cast operator binds more tightly than `->` in PostgreSQL, so + /// `a -> b::JSONB` would parse as `a -> (b::JSONB)`. + fn cast_jsonb(expr: Expr) -> Expr { + let expr = match expr { + binary @ Expr::BinaryOp { .. } => Expr::Nested(Box::new(binary)), + other => other, + }; + + Expr::Cast { + kind: CastKind::DoubleColon, + expr: Box::new(expr), + data_type: DataType::JSONB, + format: None, + } + } + + /// Wraps `expr` in a call to `eql_v2.ore_cllw(...)`. + fn wrap_ore_cllw(expr: Expr) -> Expr { + Expr::Function(Function { + name: ObjectName(vec![ + ObjectNamePart::Identifier(Ident::new("eql_v2")), + ObjectNamePart::Identifier(Ident::new("ore_cllw")), + ]), + uses_odbc_syntax: false, + args: FunctionArguments::List(FunctionArgumentList { + args: vec![FunctionArg::Unnamed(FunctionArgExpr::Expr(expr))], + duplicate_treatment: None, + clauses: vec![], + }), + parameters: FunctionArguments::None, + filter: None, + null_treatment: None, + over: None, + within_group: vec![], + }) + } +} + +impl<'ast> TransformationRule<'ast> for RewriteJsonbSteVecOrdering<'ast> { + fn apply( + &mut self, + node_path: &NodePath<'ast>, + target_node: &mut N, + ) -> Result { + if self.would_edit(node_path, target_node) { + let expr = target_node.downcast_mut::().unwrap(); + if let Expr::BinaryOp { left, op: _, right } = expr { + let dummy = Expr::Value(ValueWithSpan { + value: SqltkValue::Null, + span: Span::empty(), + }); + let left_expr = mem::replace(&mut **left, dummy.clone()); + let right_expr = mem::replace(&mut **right, dummy); + + // The left operand is a jsonb sv-element accessor. Its result is + // either an `eql_v2.ste_vec_entry` (`->` / `->>`) or an + // `eql_v2_encrypted` (`jsonb_path_query_first`). Casting to + // `jsonb` normalises both to the `eql_v2.ore_cllw(jsonb)` + // overload, which reads the `oc` term identically in either + // case. + **left = Self::wrap_ore_cllw(Self::cast_jsonb(left_expr)); + **right = Self::wrap_ore_cllw(rhs_as_jsonb(right_expr)); + return Ok(true); + } + } + + Ok(false) + } + + fn would_edit(&mut self, node_path: &NodePath<'ast>, _target_node: &N) -> bool { + if let Some((Expr::BinaryOp { left, op, right },)) = node_path.last_1_as::() { + if is_ordering_operator(op) + && is_ste_vec_accessor(left) + && is_eql_typed(&self.node_types, left) + && is_eql_typed(&self.node_types, right) + { + return true; + } + } + false + } +} diff --git a/packages/eql-mapper/src/transformation_rules/rewrite_scalar_ope_ordering.rs b/packages/eql-mapper/src/transformation_rules/rewrite_scalar_ope_ordering.rs new file mode 100644 index 00000000..a92a82b5 --- /dev/null +++ b/packages/eql-mapper/src/transformation_rules/rewrite_scalar_ope_ordering.rs @@ -0,0 +1,234 @@ +use std::collections::HashMap; +use std::mem; +use std::sync::Arc; + +use sqltk::parser::ast::Value as SqltkValue; +use sqltk::parser::ast::{ + BinaryOperator, Expr, Function, FunctionArg, FunctionArgExpr, FunctionArgumentList, + FunctionArguments, Ident, ObjectName, ObjectNamePart, OrderByExpr, ValueWithSpan, +}; +use sqltk::parser::tokenizer::Span; +use sqltk::{NodeKey, NodePath, Visitable}; + +use crate::ste_vec_ordering::is_ste_vec_accessor; +use crate::unifier::{Type, Value}; +use crate::{EqlMapperError, IndexKind, IndexResolver, TableColumn}; + +use super::TransformationRule; + +/// Rewrites ordering comparisons (`<`, `<=`, `>`, `>=`) and `ORDER BY` sort +/// keys on a *scalar* (non-jsonb) `eql_v2_encrypted` column whose concrete +/// index set contains `Ope` (order-preserving encryption) so that PostgreSQL +/// compares the order-preserving `op` ciphertext directly using built-ins only. +/// +/// # Why this is needed +/// +/// An OPE-indexed column stores an order-preserving ciphertext in the `op` slot +/// of its EQL payload. Because OPE preserves order under byte (memcmp) +/// comparison, ordering can be evaluated entirely by PostgreSQL built-ins — +/// `decode(... ->> 'op', 'hex')` extracts the hex-encoded `op` bytea, and +/// `bytea` comparison is exactly the OPE order. No EQL function is required. +/// +/// This rule rewrites: +/// +/// ```sql +/// col $param +/// ``` +/// +/// into: +/// +/// ```sql +/// decode(col ->> 'op', 'hex') decode($param ->> 'op', 'hex') +/// ``` +/// +/// and: +/// +/// ```sql +/// ORDER BY col [ASC|DESC] [NULLS …] +/// ``` +/// +/// into: +/// +/// ```sql +/// ORDER BY decode(col ->> 'op', 'hex') [ASC|DESC] [NULLS …] +/// ``` +/// +/// # What is deliberately left alone +/// +/// - **ORE columns** (`Ore` present, `Ope` absent): they keep the existing +/// bare-operator root Block-ORE (`ob`) path. +/// - **Equality** (`=` / `<>`): a separate concern (handled elsewhere). +/// - **jsonb STE-vec accessors** (`col -> selector`): handled by +/// [`super::RewriteJsonbSteVecOrdering`]; this rule only matches *bare* +/// scalar EQL column references. +/// - **Columns with no concrete index info** (empty resolver): no rewrite, +/// preserving the pre-resolver behaviour. +/// +/// The right-hand-side param/literal node is moved *intact* into the new +/// `decode(...)` wrapper. ([`mem::replace`] is just the mechanism for taking +/// ownership of the boxed operand out of the `&mut` binding — it leaves the +/// original AST node untouched, not a copy.) Preserving that node means the +/// downstream [`super::CastParamsAsEncrypted`] / [`super::CastLiteralsAsEncrypted`] +/// rules still recognise and cast it to `::JSONB::eql_v2_encrypted`, yielding an +/// EQL payload that carries its own `op` term for the same column. +#[derive(Debug)] +pub struct RewriteScalarOpeOrdering<'ast> { + node_types: Arc, Type>>, + index_resolver: Arc, +} + +impl<'ast> RewriteScalarOpeOrdering<'ast> { + pub fn new( + node_types: Arc, Type>>, + index_resolver: Arc, + ) -> Self { + Self { + node_types, + index_resolver, + } + } + + /// Returns the [`TableColumn`] of `expr` if it is an EQL-typed node, else `None`. + fn eql_table_column(&self, expr: &Expr) -> Option { + match self.node_types.get(&NodeKey::new(expr)) { + Some(Type::Value(Value::Eql(eql_term))) => Some(eql_term.table_column().clone()), + _ => None, + } + } + + /// Returns `true` if `expr` is a *scalar* OPE-indexed EQL column reference. + /// + /// "Scalar" excludes jsonb STE-vec accessors (`->` / `->>` / + /// `jsonb_path_query_first`), which are handled by the jsonb ordering rule. + fn is_scalar_ope_column(&self, expr: &Expr) -> bool { + if is_ste_vec_accessor(expr) { + return false; + } + + match self.eql_table_column(expr) { + Some(table_column) => self + .index_resolver + .resolve(&table_column) + .contains(&IndexKind::Ope), + None => false, + } + } + + /// Returns `true` if `expr` is EQL-typed (regardless of concrete index). + fn is_eql_typed(&self, expr: &Expr) -> bool { + matches!( + self.node_types.get(&NodeKey::new(expr)), + Some(Type::Value(Value::Eql(_))) + ) + } + + /// Wraps `expr` in `decode( ->> 'op', 'hex')`. + /// + /// ` ->> 'op'` extracts the order-preserving ciphertext as `text`, and + /// `decode(…, 'hex')` turns it into the `bytea` whose memcmp order is the OPE + /// order. A binary operand is wrapped in parentheses first so `->>` binds to + /// the whole operand rather than its right child. + fn decode_op(expr: Expr) -> Expr { + let expr = match expr { + binary @ Expr::BinaryOp { .. } => Expr::Nested(Box::new(binary)), + other => other, + }; + + let extract_op = Expr::BinaryOp { + left: Box::new(expr), + op: BinaryOperator::LongArrow, // ->> + right: Box::new(Expr::Value(ValueWithSpan { + value: SqltkValue::SingleQuotedString("op".into()), + span: Span::empty(), + })), + }; + + Expr::Function(Function { + name: ObjectName(vec![ObjectNamePart::Identifier(Ident::new("decode"))]), + uses_odbc_syntax: false, + args: FunctionArguments::List(FunctionArgumentList { + args: vec![ + FunctionArg::Unnamed(FunctionArgExpr::Expr(extract_op)), + FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::Value(ValueWithSpan { + value: SqltkValue::SingleQuotedString("hex".into()), + span: Span::empty(), + }))), + ], + duplicate_treatment: None, + clauses: vec![], + }), + parameters: FunctionArguments::None, + filter: None, + null_treatment: None, + over: None, + within_group: vec![], + }) + } + + fn dummy_expr() -> Expr { + Expr::Value(ValueWithSpan { + value: SqltkValue::Null, + span: Span::empty(), + }) + } + + /// Returns `true` if the binary expression at the head of `node_path` is a + /// scalar OPE ordering comparison that this rule would rewrite. + fn would_edit_comparison(&self, node_path: &NodePath<'ast>) -> bool { + if let Some((Expr::BinaryOp { left, op, right },)) = node_path.last_1_as::() { + return matches!( + op, + BinaryOperator::Lt + | BinaryOperator::LtEq + | BinaryOperator::Gt + | BinaryOperator::GtEq + ) && self.is_scalar_ope_column(left) + && self.is_eql_typed(right); + } + false + } + + /// Returns `true` if the `Expr` at the head of `node_path` is the sort key + /// of an `ORDER BY` clause and is a scalar OPE column. + fn would_edit_order_by(&self, node_path: &NodePath<'ast>) -> bool { + if let Some((_order_by, expr)) = node_path.last_2_as::() { + return self.is_scalar_ope_column(expr); + } + false + } +} + +impl<'ast> TransformationRule<'ast> for RewriteScalarOpeOrdering<'ast> { + fn apply( + &mut self, + node_path: &NodePath<'ast>, + target_node: &mut N, + ) -> Result { + // Case 1: `col $param` comparison — rewrite the whole BinaryOp. + if self.would_edit_comparison(node_path) { + let expr = target_node.downcast_mut::().unwrap(); + if let Expr::BinaryOp { left, op: _, right } = expr { + let left_expr = mem::replace(&mut **left, Self::dummy_expr()); + let right_expr = mem::replace(&mut **right, Self::dummy_expr()); + + **left = Self::decode_op(left_expr); + **right = Self::decode_op(right_expr); + return Ok(true); + } + } + + // Case 2: `ORDER BY col` sort key — rewrite just the sort-key Expr. + if self.would_edit_order_by(node_path) { + let expr = target_node.downcast_mut::().unwrap(); + let inner = mem::replace(expr, Self::dummy_expr()); + *expr = Self::decode_op(inner); + return Ok(true); + } + + Ok(false) + } + + fn would_edit(&mut self, node_path: &NodePath<'ast>, _target_node: &N) -> bool { + self.would_edit_comparison(node_path) || self.would_edit_order_by(node_path) + } +} diff --git a/packages/eql-mapper/src/type_checked_statement.rs b/packages/eql-mapper/src/type_checked_statement.rs index 772ea723..1db1a1fe 100644 --- a/packages/eql-mapper/src/type_checked_statement.rs +++ b/packages/eql-mapper/src/type_checked_statement.rs @@ -6,7 +6,8 @@ use sqltk::{AsNodeKey, NodeKey, Transformable}; use crate::unifier::EqlTerm; use crate::{ CastLiteralsAsEncrypted, CastParamsAsEncrypted, DryRunnable, EqlMapperError, - FailOnPlaceholderChange, Param, PreserveEffectiveAliases, RewriteContainmentOps, + FailOnPlaceholderChange, IndexResolver, Param, PreserveEffectiveAliases, RewriteContainmentOps, + RewriteJsonbSteVecEquality, RewriteJsonbSteVecOrdering, RewriteScalarOpeOrdering, RewriteStandardSqlFnsOnEqlTypes, TransformationRule, }; @@ -43,6 +44,14 @@ pub struct TypeCheckedStatement<'ast> { /// [`Values`]: sqltk::parser::ast::Values /// [`Value`]: sqltk::parser::ast::Value pub node_types: Arc, Type>>, + + /// Side-channel lookup of concrete encrypted-index types by `(table, column)`. + /// + /// Consumed only by index-aware transformation rules to choose + /// index-specific target functions. It does not participate in inference or + /// unification. Defaults to [`crate::EmptyIndexResolver`] when the statement + /// was produced by [`crate::type_check`]. + index_resolver: Arc, } impl<'ast> TypeCheckedStatement<'ast> { @@ -52,6 +61,7 @@ impl<'ast> TypeCheckedStatement<'ast> { params: Vec<(Param, Value)>, literals: Vec<(EqlTerm, &'ast ast::Value)>, node_types: Arc, Type>>, + index_resolver: Arc, ) -> Self { Self { statement, @@ -59,6 +69,7 @@ impl<'ast> TypeCheckedStatement<'ast> { params, literals, node_types, + index_resolver, } } @@ -153,6 +164,12 @@ impl<'ast> TypeCheckedStatement<'ast> { DryRunnable::new(( RewriteStandardSqlFnsOnEqlTypes::new(Arc::clone(&self.node_types)), RewriteContainmentOps::new(Arc::clone(&self.node_types)), + RewriteJsonbSteVecOrdering::new(Arc::clone(&self.node_types)), + RewriteJsonbSteVecEquality::new(Arc::clone(&self.node_types)), + RewriteScalarOpeOrdering::new( + Arc::clone(&self.node_types), + Arc::clone(&self.index_resolver), + ), PreserveEffectiveAliases, CastLiteralsAsEncrypted::new(encrypted_literals), FailOnPlaceholderChange::new(), diff --git a/vendor/stack-auth/.gitignore b/vendor/stack-auth/.gitignore deleted file mode 100644 index ea8c4bf7..00000000 --- a/vendor/stack-auth/.gitignore +++ /dev/null @@ -1 +0,0 @@ -/target diff --git a/vendor/stack-auth/Cargo.lock b/vendor/stack-auth/Cargo.lock deleted file mode 100644 index 07fa5c2c..00000000 --- a/vendor/stack-auth/Cargo.lock +++ /dev/null @@ -1,4154 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" -dependencies = [ - "alloc-no-stdlib", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" - -[[package]] -name = "aquamarine" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f50776554130342de4836ba542aa85a4ddb361690d7e8df13774d7284c3d5c2" -dependencies = [ - "include_dir", - "itertools 0.10.5", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "arrayvec" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" -dependencies = [ - "serde", -] - -[[package]] -name = "async-compression" -version = "0.4.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68650b7df54f0293fd061972a0fb05aaf4fc0879d3b3d21a638a182c5c543b9f" -dependencies = [ - "compression-codecs", - "compression-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "atomic" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "aws-lc-rs" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" -dependencies = [ - "aws-lc-sys", - "untrusted 0.7.1", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - -[[package]] -name = "axum" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" -dependencies = [ - "axum-core", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link", -] - -[[package]] -name = "backtrace-ext" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" -dependencies = [ - "backtrace", -] - -[[package]] -name = "base32" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "bitvec" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" -dependencies = [ - "funty", - "radium", - "tap", - "wyz", -] - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "brotli" -version = "8.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] - -[[package]] -name = "brotli-decompressor" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - -[[package]] -name = "bumpalo" -version = "3.19.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" - -[[package]] -name = "bytes" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -dependencies = [ - "serde", -] - -[[package]] -name = "cached" -version = "0.54.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9718806c4a2fe9e8a56fd736f97b340dd10ed1be8ed733ed50449f351dc33cae" -dependencies = [ - "ahash", - "cached_proc_macro", - "cached_proc_macro_types", - "hashbrown 0.14.5", - "once_cell", - "thiserror 1.0.69", - "web-time", -] - -[[package]] -name = "cached_proc_macro" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f42a145ed2d10dce2191e1dcf30cfccfea9026660e143662ba5eec4017d5daa" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "cached_proc_macro_types" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" - -[[package]] -name = "cc" -version = "1.2.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chacha20" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.0", -] - -[[package]] -name = "chrono" -version = "0.4.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" -dependencies = [ - "iana-time-zone", - "num-traits", - "serde", - "windows-link", -] - -[[package]] -name = "cipherstash-config" -version = "0.34.1-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "283fa04db19f9bf2cb2f09e8c1505a15560310bc50fdc066734072c616aa8ca9" -dependencies = [ - "bitflags", - "serde", - "serde_json", - "thiserror 1.0.69", -] - -[[package]] -name = "cmake" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" -dependencies = [ - "cc", -] - -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "compression-codecs" -version = "0.4.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" -dependencies = [ - "brotli", - "compression-core", - "flate2", - "memchr", -] - -[[package]] -name = "compression-core" -version = "0.4.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" - -[[package]] -name = "const-hex" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "proptest", - "serde_core", -] - -[[package]] -name = "convert_case" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" -dependencies = [ - "unicode-segmentation", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "cts-common" -version = "0.34.1-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b26644e630f2e690194c6b61f5b613b768061750f2060cf4db73ddb8058d284" -dependencies = [ - "arrayvec", - "base32", - "cached", - "chrono", - "derive_more", - "either", - "miette", - "nom", - "regex", - "serde", - "serde_json", - "thiserror 1.0.69", - "tracing", - "url", - "utoipa", - "uuid", - "vitaminc", -] - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn", -] - -[[package]] -name = "data-encoding" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" - -[[package]] -name = "deranged" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "derive_more" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" -dependencies = [ - "derive_more-impl", -] - -[[package]] -name = "derive_more-impl" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" -dependencies = [ - "convert_case", - "proc-macro2", - "quote", - "rustc_version", - "syn", - "unicode-xid", -] - -[[package]] -name = "deunicode" -version = "1.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "dirs" -version = "4.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" -dependencies = [ - "dirs-sys", -] - -[[package]] -name = "dirs-sys" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" -dependencies = [ - "libc", - "redox_users", - "winapi", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dummy" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cac124e13ae9aa56acc4241f8c8207501d93afdd8d8e62f0c1f2e12f6508c65" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -dependencies = [ - "serde", -] - -[[package]] -name = "enum-as-inner" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "equivalent" -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 = "fake" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d391ba4af7f1d93f01fcf7b2f29e2bc9348e109dfdbf4dcbdc51dfa38dab0b6" -dependencies = [ - "deunicode", - "dummy", - "rand 0.8.6", - "uuid", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "funty" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" - -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "gethostname" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc3655aa6818d65bc620d6911f05aa7b6aeb596291e1e9f79e52df85583d1e30" -dependencies = [ - "rustix 0.38.44", - "windows-targets 0.52.6", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" -dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.0", - "wasip2", - "wasip3", -] - -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - -[[package]] -name = "h2" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hickory-proto" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" -dependencies = [ - "async-trait", - "cfg-if", - "data-encoding", - "enum-as-inner", - "futures-channel", - "futures-io", - "futures-util", - "idna", - "ipnet", - "once_cell", - "rand 0.9.2", - "ring", - "thiserror 2.0.18", - "tinyvec", - "tokio", - "tracing", - "url", -] - -[[package]] -name = "hickory-resolver" -version = "0.25.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" -dependencies = [ - "cfg-if", - "futures-util", - "hickory-proto", - "ipconfig", - "moka", - "once_cell", - "parking_lot", - "rand 0.9.2", - "resolv-conf", - "smallvec", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2 0.6.2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "include_dir" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" -dependencies = [ - "include_dir_macros", -] - -[[package]] -name = "include_dir_macros" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "indexmap" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" -dependencies = [ - "equivalent", - "hashbrown 0.16.1", - "serde", - "serde_core", -] - -[[package]] -name = "ipconfig" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b58db92f96b720de98181bbbe63c831e87005ab460c1bf306eb2622b4707997f" -dependencies = [ - "socket2 0.5.10", - "widestring", - "windows-sys 0.48.0", - "winreg", -] - -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] - -[[package]] -name = "is_ci" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" - -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - -[[package]] -name = "itertools" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" - -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - -[[package]] -name = "jni-sys" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" - -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - -[[package]] -name = "libc" -version = "0.2.180" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" - -[[package]] -name = "libredox" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" -dependencies = [ - "bitflags", - "libc", -] - -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "md-5" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" -dependencies = [ - "cfg-if", - "digest", -] - -[[package]] -name = "memchr" -version = "2.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" - -[[package]] -name = "miette" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" -dependencies = [ - "backtrace", - "backtrace-ext", - "cfg-if", - "miette-derive", - "owo-colors", - "supports-color", - "supports-hyperlinks", - "supports-unicode", - "terminal_size", - "textwrap", - "unicode-width 0.1.14", -] - -[[package]] -name = "miette-derive" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "mocktail" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053f7ba52863e22dfd2970075bbc69c4224ca6ae03896a5f69a0d5982deb5e0a" -dependencies = [ - "bytes", - "futures", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "prost", - "rand 0.9.2", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "moka" -version = "0.12.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" -dependencies = [ - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "parking_lot", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", -] - -[[package]] -name = "nom" -version = "8.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" -dependencies = [ - "memchr", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" -dependencies = [ - "critical-section", - "portable-atomic", -] - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "open" -version = "5.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" -dependencies = [ - "is-wsl", - "libc", - "pathdiff", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "owo-colors" -version = "4.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "portable-atomic" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "proptest" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" -dependencies = [ - "bitflags", - "num-traits", - "rand 0.9.2", - "rand_chacha 0.9.0", - "rand_xorshift", - "regex-syntax", - "unarray", -] - -[[package]] -name = "prost" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-derive" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" -dependencies = [ - "anyhow", - "itertools 0.12.1", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2 0.6.2", - "thiserror 2.0.18", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand 0.9.2", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.18", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2 0.6.2", - "tracing", - "windows-sys 0.60.2", -] - -[[package]] -name = "quote" -version = "1.0.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" -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 = "radium" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" - -[[package]] -name = "rand" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" -dependencies = [ - "chacha20", - "getrandom 0.4.2", - "rand_core 0.10.0", -] - -[[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 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]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_core" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" - -[[package]] -name = "rand_xorshift" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" -dependencies = [ - "rand_core 0.9.5", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - -[[package]] -name = "regex" -version = "1.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" - -[[package]] -name = "reqwest" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" -dependencies = [ - "base64", - "bytes", - "futures-core", - "futures-util", - "hickory-resolver", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "once_cell", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", -] - -[[package]] -name = "resolv-conf" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted 0.9.0", - "windows-sys 0.52.0", -] - -[[package]] -name = "rmp" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "rmp-serde" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" -dependencies = [ - "rmp", - "serde", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" -dependencies = [ - "aws-lc-rs", - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" -dependencies = [ - "core-foundation", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - -[[package]] -name = "rustls-webpki" -version = "0.103.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted 0.9.0", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "security-framework" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.149" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha1_smol" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "simd-adler32" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" - -[[package]] -name = "simple_asn1" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.18", - "time", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "socket2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "stack-auth" -version = "0.34.1-alpha.4" -dependencies = [ - "aquamarine", - "axum", - "cts-common", - "jsonwebtoken", - "miette", - "mocktail", - "open", - "reqwest", - "serde", - "serde_json", - "stack-profile", - "tempfile", - "thiserror 1.0.69", - "tokio", - "tracing", - "tracing-subscriber", - "url", - "uuid", - "vitaminc", - "vitaminc-protected", - "zeroize", - "zerokms-protocol", -] - -[[package]] -name = "stack-profile" -version = "0.34.1-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd61bc4129d2258ec1ba89d742558308560fc0f585f9c24d478685def8efd14" -dependencies = [ - "dirs", - "gethostname", - "serde", - "serde_json", - "thiserror 1.0.69", - "uuid", -] - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "supports-color" -version = "3.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" -dependencies = [ - "is_ci", -] - -[[package]] -name = "supports-hyperlinks" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" - -[[package]] -name = "supports-unicode" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" - -[[package]] -name = "syn" -version = "2.0.114" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - -[[package]] -name = "tap" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" - -[[package]] -name = "tempfile" -version = "3.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" -dependencies = [ - "fastrand", - "getrandom 0.3.4", - "once_cell", - "rustix 1.1.3", - "windows-sys 0.61.2", -] - -[[package]] -name = "terminal_size" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" -dependencies = [ - "rustix 1.1.3", - "windows-sys 0.60.2", -] - -[[package]] -name = "textwrap" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" -dependencies = [ - "unicode-linebreak", - "unicode-width 0.2.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.49.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2 0.6.2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "async-compression", - "bitflags", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "iri-string", - "pin-project-lite", - "tokio", - "tokio-util", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-serde" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" -dependencies = [ - "serde", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "serde", - "serde_json", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", - "tracing-serde", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - -[[package]] -name = "unarray" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" - -[[package]] -name = "unicode-ident" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" - -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" - -[[package]] -name = "unicode-segmentation" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" - -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utoipa" -version = "5.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" -dependencies = [ - "indexmap", - "serde", - "serde_json", - "utoipa-gen", -] - -[[package]] -name = "utoipa-gen" -version = "5.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "url", - "uuid", -] - -[[package]] -name = "uuid" -version = "1.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" -dependencies = [ - "atomic", - "getrandom 0.3.4", - "js-sys", - "md-5", - "rand 0.9.2", - "serde_core", - "sha1_smol", - "wasm-bindgen", -] - -[[package]] -name = "validator" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" -dependencies = [ - "idna", - "once_cell", - "regex", - "serde", - "serde_derive", - "serde_json", - "url", - "validator_derive", -] - -[[package]] -name = "validator_derive" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" -dependencies = [ - "darling", - "once_cell", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "vitaminc" -version = "0.1.0-pre4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c8b739a2cb1e528e77a69267728532f52d2d5ce18ae2839e26c797859fe9015" -dependencies = [ - "vitaminc-aead", - "vitaminc-encrypt", - "vitaminc-protected", - "vitaminc-random", - "vitaminc-traits", -] - -[[package]] -name = "vitaminc-aead" -version = "0.1.0-pre4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c29cef4d4b0d018c4223d366017d2a9756012acf76e25011aaca877f3c74904" -dependencies = [ - "bytes", - "serde", - "vitaminc-protected", - "vitaminc-random", - "zeroize", -] - -[[package]] -name = "vitaminc-encrypt" -version = "0.1.0-pre4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e3869aaf60ebb95ccbdfcf003985132325b4d1ac6f5d945ad2fbb9149afd3a" -dependencies = [ - "aws-lc-rs", - "vitaminc-aead", - "vitaminc-protected", - "vitaminc-random", - "zeroize", -] - -[[package]] -name = "vitaminc-protected" -version = "0.1.0-pre4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af693c39d3cd1c818ef6267539433c6ceca87840b12d24124adbc9c8ecba1709" -dependencies = [ - "bitvec", - "digest", - "serde", - "serde_bytes", - "subtle", - "vitaminc-protected-derive", - "zeroize", -] - -[[package]] -name = "vitaminc-protected-derive" -version = "0.1.0-pre4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e74520596b66eec546ef18d5376f6f18cdaf874caca9fa39e03eb12f9abb76fa" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "vitaminc-random" -version = "0.1.0-pre4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea9de431cb93359d293ec7e70d05d87117a57f34bfc5bc94f040b81d4dd1afd6" -dependencies = [ - "rand 0.10.0", - "thiserror 2.0.18", - "vitaminc-protected", - "vitaminc-random-derives", - "zeroize", -] - -[[package]] -name = "vitaminc-random-derives" -version = "0.1.0-pre4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49d33ac4682235551d25c874525c20e03d4c863b39f556391f52f7a2083bfbdf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "vitaminc-traits" -version = "0.1.0-pre4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c25a9e51d24c3befddd71e907dd4ae9f21cfbaae065fb0ef5202e5d21cd198d0" -dependencies = [ - "anyhow", - "bytes", - "rmp-serde", - "serde", - "thiserror 2.0.18", - "vitaminc-protected", - "vitaminc-random", - "zeroize", -] - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" -dependencies = [ - "cfg-if", - "futures-util", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.108" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasm-streams" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - -[[package]] -name = "web-sys" -version = "0.3.85" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - -[[package]] -name = "windows-sys" -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.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -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", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - -[[package]] -name = "windows-targets" -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", - "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", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - -[[package]] -name = "windows_aarch64_gnullvm" -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.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - -[[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.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - -[[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.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - -[[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.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - -[[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.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - -[[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.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - -[[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 = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "wyz" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" -dependencies = [ - "tap", -] - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.39" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerokms-protocol" -version = "0.12.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a2f045e2ee975a3d448419245c4621ea8844d2a004c63a96277181dc7cf8483" -dependencies = [ - "base64", - "cipherstash-config", - "const-hex", - "cts-common", - "fake", - "opaque-debug", - "rand 0.8.6", - "serde", - "static_assertions", - "thiserror 1.0.69", - "utoipa", - "uuid", - "validator", - "zeroize", -] - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" diff --git a/vendor/stack-auth/Cargo.toml b/vendor/stack-auth/Cargo.toml deleted file mode 100644 index 77f70abf..00000000 --- a/vendor/stack-auth/Cargo.toml +++ /dev/null @@ -1,166 +0,0 @@ -# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO -# -# When uploading crates to the registry Cargo will automatically -# "normalize" Cargo.toml files for maximal compatibility -# with all versions of Cargo and also rewrite `path` dependencies -# to registry (e.g., crates.io) dependencies. -# -# If you are reading this file be aware that the original Cargo.toml -# will likely look very different (and much more reasonable). -# See Cargo.toml.orig for the original contents. - -[package] -edition = "2021" -name = "stack-auth" -version = "0.34.1-alpha.4" -authors = [ - "Dan Draper ", - "Drew Thomas ", - "Fiona McCawley ", - "James Sadler ", - "Kate Andrews ", - "Lindsay Holmwood ", - "Paul Hawkins ", - "Robin Howard ", - "Toby Hede ", - "Yuji Yokoo ", -] -build = false -autolib = false -autobins = false -autoexamples = false -autotests = false -autobenches = false -description = "Authentication library for CipherStash services" -homepage = "https://cipherstash.com" -readme = "README.md" -license-file = "LICENSE" -repository = "https://github.com/cipherstash/cipherstash-suite" - -[features] -test-utils = [] - -[lib] -name = "stack_auth" -path = "src/lib.rs" - -[[example]] -name = "auto_strategy" -path = "examples/auto_strategy.rs" - -[[example]] -name = "device_code" -path = "examples/device_code.rs" -required-features = ["test-utils"] - -[dependencies.aquamarine] -version = "0.6" - -[dependencies.cts-common] -version = "0.34.1-alpha.4" -default-features = false - -[dependencies.jsonwebtoken] -version = "9.3.1" - -[dependencies.miette] -version = "7.5.0" -features = ["fancy"] - -[dependencies.open] -version = "5.3.2" - -[dependencies.reqwest] -version = "0.13" -features = [ - "brotli", - "gzip", - "json", - "rustls", - "hickory-dns", - "stream", - "form", - "query", -] -default-features = false - -[dependencies.serde] -version = "1.0" -features = ["derive"] - -[dependencies.serde_json] -version = "1.0.132" - -[dependencies.stack-profile] -version = "0.34.1-alpha.4" - -[dependencies.thiserror] -version = "1.0.56" - -[dependencies.tokio] -version = "1.47.1" -features = ["full"] - -[dependencies.tracing] -version = "0.1" -features = ["log"] - -[dependencies.url] -version = "2.5.4" -features = ["serde"] - -[dependencies.uuid] -version = "1.8" -features = [ - "v4", - "v5", - "serde", -] - -[dependencies.vitaminc] -version = "0.1.0-pre4.2" -features = [ - "random", - "protected", - "encrypt", - "protected", -] - -[dependencies.vitaminc-protected] -version = "0.1.0-pre4.2" - -[dependencies.zeroize] -version = "1.8.1" -features = ["derive"] - -[dependencies.zerokms-protocol] -version = "0.12.9" - -[dev-dependencies.axum] -version = "0.8" - -[dev-dependencies.cts-common] -version = "0.34.1-alpha.4" -default-features = false - -[dev-dependencies.mocktail] -version = "0.3.0" - -[dev-dependencies.tempfile] -version = "3.21.0" - -[dev-dependencies.tokio] -version = "1.47.1" -features = [ - "full", - "test-util", -] - -[dev-dependencies.tracing-subscriber] -version = "0.3" -features = [ - "ansi", - "json", - "env-filter", - "std", -] diff --git a/vendor/stack-auth/LICENSE b/vendor/stack-auth/LICENSE deleted file mode 100644 index 2cbd67a6..00000000 --- a/vendor/stack-auth/LICENSE +++ /dev/null @@ -1,96 +0,0 @@ -# PolyForm Internal Use License 1.0.0 - - - -## Acceptance - -In order to get any license under these terms, you must agree -to them as both strict obligations and conditions to all -your licenses. - -## Copyright License - -The licensor grants you a copyright license for the software -to do everything you might do with the software that would -otherwise infringe the licensor's copyright in it for any -permitted purpose. However, you may only make changes or -new works based on the software according to [Changes and New -Works License](#changes-and-new-works-license), and you may -not distribute the software. - -## Changes and New Works License - -The licensor grants you an additional copyright license to -make changes and new works based on the software for any -permitted purpose. - -## Patent License - -The licensor grants you a patent license for the software that -covers patent claims the licensor can license, or becomes able -to license, that you would infringe by using the software. - -## Fair Use - -You may have "fair use" rights for the software under the -law. These terms do not limit them. - -## Internal Business Use - -Use of the software for the internal business operations of -you and your company is use for a permitted purpose. - -## No Other Rights - -These terms do not allow you to sublicense or transfer any of -your licenses to anyone else, or prevent the licensor from -granting licenses to anyone else. These terms do not imply -any other licenses. - -## Patent Defense - -If you make any written claim that the software infringes or -contributes to infringement of any patent, your patent license -for the software granted under these terms ends immediately. If -your company makes such a claim, your patent license ends -immediately for work on behalf of your company. - -## Violations - -The first time you are notified in writing that you have -violated any of these terms, or done anything with the software -not covered by your licenses, your licenses can nonetheless -continue if you come into full compliance with these terms, -and take practical steps to correct past violations, within -32 days of receiving notice. Otherwise, all your licenses -end immediately. - -## No Liability - -***As far as the law allows, the software comes as is, without -any warranty or condition, and the licensor will not be liable -to you for any damages arising out of these terms or the use -or nature of the software, under any kind of legal claim.*** - -## Definitions - -The **licensor** is the individual or entity offering these -terms, and the **software** is the software the licensor makes -available under these terms. - -**You** refers to the individual or entity agreeing to these -terms. - -**Your company** is any legal entity, sole proprietorship, -or other kind of organization that you work for, plus all -organizations that have control over, are under the control of, -or are under common control with that organization. **Control** -means ownership of substantially all the assets of an entity, -or the power to direct its management and policies by vote, -contract, or otherwise. Control can be direct or indirect. - -**Your licenses** are all the licenses granted to you for the -software under these terms. - -**Use** means anything you do with the software requiring one -of your licenses. diff --git a/vendor/stack-auth/README.md b/vendor/stack-auth/README.md deleted file mode 100644 index d03c569d..00000000 --- a/vendor/stack-auth/README.md +++ /dev/null @@ -1,64 +0,0 @@ -# stack-auth - -[![Crates.io Version](https://img.shields.io/crates/v/stack-auth?style=for-the-badge)](https://crates.io/crates/stack-auth) -[![docs.rs](https://img.shields.io/docsrs/stack-auth?style=for-the-badge)](https://docs.rs/stack-auth/) -[![Built by CipherStash](https://raw.githubusercontent.com/cipherstash/meta/refs/heads/main/csbadge.svg)](https://cipherstash.com) - - [Website](https://cipherstash.com) | [Docs](https://cipherstash.com/docs) | [Discord](https://discord.com/invite/5qwXUFb6PB) - -Authentication strategies for [CipherStash](https://cipherstash.com) services. - -All strategies implement the [`AuthStrategy`] trait, which provides a single -[`get_token`](AuthStrategy::get_token) method that returns a valid -[`ServiceToken`]. Token caching and refresh are handled automatically. - -## Strategies - -| Strategy | Use case | Credentials | -|---|---|---| -| [`AutoStrategy`] | Recommended default — detects credentials automatically | `CS_CLIENT_ACCESS_KEY` + `CS_WORKSPACE_CRN`, or `~/.cipherstash/auth.json` | -| [`AccessKeyStrategy`] | Service-to-service / CI | Static access key + region | -| [`OAuthStrategy`] | Long-lived sessions with refresh | OAuth token (from device code flow or disk) | -| [`DeviceCodeStrategy`] | CLI login ([RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628)) | User authorizes in browser | -| `StaticTokenStrategy` | Tests only (`test-utils` feature) | Pre-obtained token used as-is | - -## Quick start - -For most applications, [`AutoStrategy`] is the simplest way to get started: - -```no_run -use stack_auth::AutoStrategy; - -# async fn run() -> Result<(), Box> { -let strategy = AutoStrategy::detect()?; -// That's it — get_token() handles the rest. -# Ok(()) -# } -``` - -For service-to-service authentication with an access key: - -```no_run -use stack_auth::AccessKeyStrategy; -use cts_common::Region; - -# fn run() -> Result<(), Box> { -let region = Region::aws("ap-southeast-2")?; -let key = "CSAKkeyId.keySecret".parse()?; -let strategy = AccessKeyStrategy::new(region, key)?; -# Ok(()) -# } -``` - -## Security - -Sensitive values ([`SecretToken`]) are automatically zeroized when dropped -and are masked in [`Debug`](std::fmt::Debug) output to prevent accidental -leaks in logs. - -## Token refresh - -All strategies that cache tokens ([`AccessKeyStrategy`], [`OAuthStrategy`], -[`AutoStrategy`]) share the same internal refresh engine. See the -[`AuthStrategy`] trait docs for a full description of the concurrency model -and flow diagram. diff --git a/vendor/stack-auth/examples/auto_strategy.rs b/vendor/stack-auth/examples/auto_strategy.rs deleted file mode 100644 index 0b74df06..00000000 --- a/vendor/stack-auth/examples/auto_strategy.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! Demonstrates automatic credential detection with [`AutoStrategy`]. -//! -//! `AutoStrategy` picks the best available authentication method without -//! requiring the caller to choose one explicitly. It checks for credentials -//! in the following order: -//! -//! 1. **Access key** – if `CS_CLIENT_ACCESS_KEY` is set along with -//! `CS_WORKSPACE_CRN`, an [`AccessKeyStrategy`] is used. -//! 2. **OAuth** – if a token store file exists at `~/.cipherstash/auth.json` -//! (written by `stash login`), an [`OAuthStrategy`] is used. -//! 3. If neither is available, an error is returned. -//! -//! # Running the example -//! -//! With an access key: -//! -//! ```sh -//! CS_CLIENT_ACCESS_KEY= CS_WORKSPACE_CRN= cargo run --example auto_strategy -//! ``` -//! -//! Or after authenticating via the CLI: -//! -//! ```sh -//! stash login -//! cargo run --example auto_strategy -//! ``` - -use stack_auth::{AuthStrategy, AutoStrategy}; - -#[tokio::main] -async fn main() -> Result<(), Box> { - tracing_subscriber::fmt::init(); - - // AutoStrategy detects credentials automatically: - // - // 1. CS_CLIENT_ACCESS_KEY env var → AccessKeyStrategy - // 2. ~/.cipherstash/auth.json file → OAuthStrategy - // 3. Neither → error - let strategy = AutoStrategy::detect()?; - - match &strategy { - AutoStrategy::AccessKey(_) => println!("Using access key authentication"), - AutoStrategy::OAuth(_) => println!("Using OAuth authentication"), - } - - // Obtain a token — refresh happens automatically when needed. - let token = (&strategy).get_token().await?; - println!("Subject: {}", token.subject()?); - println!("Workspace: {}", token.workspace_id()?); - println!("Issuer: {}", token.issuer()?); - - Ok(()) -} diff --git a/vendor/stack-auth/examples/device_code.rs b/vendor/stack-auth/examples/device_code.rs deleted file mode 100644 index 1fd1e727..00000000 --- a/vendor/stack-auth/examples/device_code.rs +++ /dev/null @@ -1,32 +0,0 @@ -use cts_common::Region; -use stack_auth::DeviceCodeStrategy; - -#[tokio::main] -async fn main() -> Result<(), Box> { - tracing_subscriber::fmt::init(); - - let region = Region::aws("ap-southeast-2")?; - let strategy = DeviceCodeStrategy::builder(region, "cli") - .base_url("http://localhost:3001".parse()?) - .build()?; - - // Step 1: Begin the device code flow - let pending = strategy.begin().await?; - - // Step 2: Display the code and open the browser (caller controls this) - println!("Your code is: {}", pending.user_code()); - println!("Visit: {}", pending.verification_uri_complete()); - - if !pending.open_in_browser() { - eprintln!("Could not open browser — please visit the URL above manually."); - } - - // Step 3: Poll until the user authorizes - let token = pending.poll_for_token().await?; - - println!("Token type: {}", token.token_type()); - println!("Expires in: {}s", token.expires_in()); - println!("Access token: {:?}", token.access_token()); - - Ok(()) -} diff --git a/vendor/stack-auth/src/access_key.rs b/vendor/stack-auth/src/access_key.rs deleted file mode 100644 index cef3285e..00000000 --- a/vendor/stack-auth/src/access_key.rs +++ /dev/null @@ -1,149 +0,0 @@ -use std::str::FromStr; - -use crate::SecretToken; -use vitaminc::protected::OpaqueDebug; - -/// The prefix that all CipherStash access keys start with. -const ACCESS_KEY_PREFIX: &str = "CSAK"; - -/// A CipherStash access key. -/// -/// Access keys have the format `CSAK.` and are used to -/// authenticate with the CipherStash Token Service (CTS). -/// -/// The inner value is stored as a [`SecretToken`], so it is zeroized on drop -/// and hidden from debug output. -/// -/// # Parsing -/// -/// ``` -/// use stack_auth::AccessKey; -/// -/// let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); -/// ``` -/// -/// Invalid keys are rejected: -/// -/// ``` -/// use stack_auth::AccessKey; -/// -/// assert!("not-a-valid-key".parse::().is_err()); -/// assert!("CSAKmissing-dot".parse::().is_err()); -/// assert!("CSAK.no-key-id".parse::().is_err()); -/// assert!("CSAKno-secret.".parse::().is_err()); -/// ``` -#[derive(OpaqueDebug)] -pub struct AccessKey(SecretToken); - -impl AccessKey { - /// Expose the underlying [`SecretToken`]. - pub(crate) fn into_secret_token(self) -> SecretToken { - self.0 - } -} - -// NOTE: The format validation here mirrors `UnverifiedAccessKey::new()` in -// `cts-domain`. If the `CSAK.` format changes, both -// locations must be updated. -impl FromStr for AccessKey { - type Err = InvalidAccessKey; - - fn from_str(s: &str) -> Result { - let rest = s - .strip_prefix(ACCESS_KEY_PREFIX) - .ok_or(InvalidAccessKey::MissingPrefix)?; - - let (id, secret) = rest.split_once('.').ok_or(InvalidAccessKey::MissingDot)?; - - if id.is_empty() { - return Err(InvalidAccessKey::EmptyKeyId); - } - if secret.is_empty() { - return Err(InvalidAccessKey::EmptySecret); - } - - Ok(Self(SecretToken::new(s))) - } -} - -/// Error returned when parsing an invalid access key string. -#[derive(Debug, thiserror::Error)] -pub enum InvalidAccessKey { - /// The string does not start with the `CSAK` prefix. - #[error("access key must start with \"{ACCESS_KEY_PREFIX}\"")] - MissingPrefix, - /// No `.` separator found between key ID and secret. - #[error("access key must contain a \".\" separator")] - MissingDot, - /// The key ID portion (before the `.`) is empty. - #[error("access key ID must not be empty")] - EmptyKeyId, - /// The secret portion (after the `.`) is empty. - #[error("access key secret must not be empty")] - EmptySecret, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn valid_key() { - let key: AccessKey = - "CSAKT4ZMT2AUPXI7TCD2.ZAQRW2BWXP3Z6SHR4YG2TP3N35LLU46ZAWLR3BL5WUR4IIGA" - .parse() - .unwrap(); - assert_eq!( - key.0.as_str(), - "CSAKT4ZMT2AUPXI7TCD2.ZAQRW2BWXP3Z6SHR4YG2TP3N35LLU46ZAWLR3BL5WUR4IIGA" - ); - } - - #[test] - fn missing_prefix() { - let err = "key_id.key_secret".parse::().unwrap_err(); - assert!(matches!(err, InvalidAccessKey::MissingPrefix)); - } - - #[test] - fn missing_dot() { - let err = "CSAKnodot".parse::().unwrap_err(); - assert!(matches!(err, InvalidAccessKey::MissingDot)); - } - - #[test] - fn empty_key_id() { - let err = "CSAK.secret".parse::().unwrap_err(); - assert!(matches!(err, InvalidAccessKey::EmptyKeyId)); - } - - #[test] - fn empty_secret() { - let err = "CSAKid.".parse::().unwrap_err(); - assert!(matches!(err, InvalidAccessKey::EmptySecret)); - } - - #[test] - fn empty_string() { - let err = "".parse::().unwrap_err(); - assert!(matches!(err, InvalidAccessKey::MissingPrefix)); - } - - #[test] - fn into_secret_token() { - let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); - let secret = key.into_secret_token(); - assert_eq!(secret.as_str(), "CSAKmyKeyId.myKeySecret"); - } - - #[test] - fn debug_does_not_leak() { - let key: AccessKey = "CSAKid.secret".parse().unwrap(); - let debug = format!("{key:?}"); - assert!(!debug.contains("secret")); - assert!( - debug.contains("AccessKey") && debug.contains("***"), - "debug should hide secret: {debug}" - ); - } -} diff --git a/vendor/stack-auth/src/access_key_refresher.rs b/vendor/stack-auth/src/access_key_refresher.rs deleted file mode 100644 index 396babcc..00000000 --- a/vendor/stack-auth/src/access_key_refresher.rs +++ /dev/null @@ -1,698 +0,0 @@ -use std::sync::Arc; - -use url::Url; - -use crate::refresher::Refresher; -use crate::{http_client, AuthError, SecretToken, Token}; - -/// A [`Refresher`] that uses a static access key to authenticate. -/// -/// Unlike OAuth, the access key never changes — `try_credential` always returns -/// `Some(())` and `restore` is a no-op. This means `AutoRefresh` can perform -/// initial authentication on the first `get_token()` call (cold start). -pub(crate) struct AccessKeyRefresher { - access_key: SecretToken, - base_url: Url, - audience: Option, - http_client: Arc, -} - -impl AccessKeyRefresher { - pub(crate) fn new(access_key: SecretToken, base_url: Url, audience: Option) -> Self { - Self { - access_key, - base_url, - audience, - http_client: Arc::new(http_client()), - } - } -} - -impl Refresher for AccessKeyRefresher { - type Credential = (); - - fn save(&self, _token: &Token) { - // Access key tokens are ephemeral — no persistence needed. - } - - fn try_credential(&self, _token: Option<&mut Token>) -> Option { - Some(()) - } - - fn restore(&self, _token: &mut Token, _credential: Self::Credential) { - // Nothing to restore — the access key is always available. - } - - async fn refresh(&self, _credential: &Self::Credential) -> Result { - let url = self.base_url.join("api/authorise")?; - - tracing::debug!(url = %url, "authenticating with access key"); - - let resp = self - .http_client - .post(url) - .json(&AuthoriseRequest { - access_key: self.access_key.as_str(), - audience: self.audience.as_deref(), - }) - .send() - .await?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - tracing::debug!(%status, %body, "access key auth failed"); - return Err(AuthError::Server(format!("{status}: {body}"))); - } - - let auth_resp: AuthoriseResponse = resp.json().await?; - - Ok(Token { - access_token: auth_resp.access_token, - token_type: "Bearer".to_string(), - // CTS `/api/authorise` returns `expiry` as an ABSOLUTE Unix epoch (it is - // the JWT `exp` claim), NOT a relative duration. The previous `now + expiry` - // pushed the local expiry decades into the future, so `AutoRefresh` never - // considered the token expired and never refreshed it — the token then - // silently died at its real (~15 min) `exp` and every request failed until - // the process restarted. Use the value as-is. See CIP-3233. - expires_at: auth_resp.expiry, - refresh_token: None, - region: None, - client_id: None, - device_instance_id: None, - }) - } -} - -#[derive(serde::Serialize)] -#[serde(rename_all = "camelCase")] -struct AuthoriseRequest<'a> { - access_key: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - audience: Option<&'a str>, -} - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct AuthoriseResponse { - access_token: SecretToken, - expiry: u64, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::auto_refresh::{AutoRefresh, AutoRefreshError}; - use mocktail::prelude::*; - use std::sync::Arc; - use std::time::{SystemTime, UNIX_EPOCH}; - - /// Build a mock `/api/authorise` response. CTS returns `expiry` as an - /// ABSOLUTE Unix epoch (the JWT `exp` claim), so model that faithfully: the - /// token is valid for `expires_in_secs` from now. - fn auth_response_json(access: &str, expires_in_secs: u64) -> serde_json::Value { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - serde_json::json!({ - "accessToken": access, - "expiry": now + expires_in_secs - }) - } - - async fn start_server(mocks: MockSet) -> MockServer { - let server = MockServer::new_http("access-key-refresher-test").with_mocks(mocks); - server.start().await.unwrap(); - server - } - - fn make_access_key_strategy(server: &MockServer) -> AutoRefresh { - let refresher = AccessKeyRefresher::new( - SecretToken::new("test-access-key"), - server.url(""), - Some("test-audience".to_string()), - ); - AutoRefresh::new(refresher) - } - - fn make_expired_token(access: &str) -> Token { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - Token { - access_token: SecretToken::new(access), - token_type: "Bearer".to_string(), - expires_at: now, // already expired - refresh_token: None, - region: None, - client_id: None, - device_instance_id: None, - } - } - - // ---- Regression: CTS `expiry` is an absolute epoch (CIP-3233) ---- - - /// CTS `/api/authorise` returns `expiry` as an ABSOLUTE Unix epoch (the JWT - /// `exp` claim), not a relative duration. The refresher must use it as-is. - /// - /// Pre-fix (`expires_at = now + expiry`), this token's `expires_at` lands - /// ~decades in the future, so `is_expired()` is never true — the token never - /// refreshes and silently dies at its real ~15-minute `exp`. The assertion - /// below fails under the pre-fix arithmetic (`expires_in()` ≈ 1.7e9) and - /// passes with the fix (`expires_in()` ≈ 900). - #[tokio::test] - async fn access_key_expiry_is_absolute_epoch_not_relative() { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - let absolute_expiry = now + 900; // a 15-minute token, as an absolute epoch - - let mut mocks = MockSet::new(); - mocks.mock(move |when, then| { - when.post().path("/api/authorise"); - then.json(serde_json::json!({ - "accessToken": "tok", - "expiry": absolute_expiry - })); - }); - let server = start_server(mocks).await; - - let refresher = - AccessKeyRefresher::new(SecretToken::new("CSAKid.secret"), server.url(""), None); - let token = refresher.refresh(&()).await.unwrap(); - - assert!( - token.expires_in() <= 1000, - "expires_in should be ~900s (absolute `expiry` used as-is); got {} \ - — pre-fix `now + expiry` yields ~1.7e9", - token.expires_in() - ); - assert!( - !token.is_expired(), - "a fresh 15-minute token must not be reported as already expired" - ); - } - - // ---- Initial auth tests ---- - - #[tokio::test] - async fn test_initial_auth_no_cached_token() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/api/authorise"); - then.json(auth_response_json("new-token", 3600)); - }); - let server = start_server(mocks).await; - let strategy = make_access_key_strategy(&server); - - let token = strategy.get_token().await.unwrap(); - - assert_eq!(token.as_str(), "new-token"); - } - - #[tokio::test] - async fn test_caches_token_after_initial_auth() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/api/authorise"); - then.json(auth_response_json("new-token", 3600)); - }); - let server = start_server(mocks).await; - let strategy = make_access_key_strategy(&server); - - let token1 = strategy.get_token().await.unwrap(); - assert_eq!(token1.as_str(), "new-token"); - - // Replace mock — second call should use cached token. - server.mocks().clear(); - server.mocks().mock(|when, then| { - when.post().path("/api/authorise"); - then.internal_server_error() - .json(serde_json::json!({"error": "should not be called"})); - }); - - let token2 = strategy.get_token().await.unwrap(); - assert_eq!(token2.as_str(), "new-token"); - } - - // ---- Refresh on expiry tests ---- - - #[tokio::test] - async fn test_re_authenticates_on_expiry() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/api/authorise"); - then.json(auth_response_json("refreshed-token", 3600)); - }); - let server = start_server(mocks).await; - - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); - let strategy = AutoRefresh::with_token(refresher, make_expired_token("old-token")); - - let token = strategy.get_token().await.unwrap(); - - assert_eq!(token.as_str(), "refreshed-token"); - } - - // ---- Error handling tests ---- - - #[tokio::test] - async fn test_initial_auth_failure() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/api/authorise"); - then.unauthorized() - .json(serde_json::json!({"error": "invalid key"})); - }); - let server = start_server(mocks).await; - let strategy = make_access_key_strategy(&server); - - let err = strategy.get_token().await.unwrap_err(); - - assert!(matches!(err, AutoRefreshError::Auth(_))); - } - - #[tokio::test] - async fn test_refresh_failure_returns_expired() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/api/authorise"); - then.unauthorized() - .json(serde_json::json!({"error": "invalid key"})); - }); - let server = start_server(mocks).await; - - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); - let strategy = AutoRefresh::with_token(refresher, make_expired_token("old-token")); - - let err = strategy.get_token().await.unwrap_err(); - - assert!(matches!(err, AutoRefreshError::Expired)); - } - - // ---- Cascade prevention tests ---- - - #[tokio::test] - async fn test_concurrent_initial_auth_only_one_http_call() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/api/authorise"); - then.json(auth_response_json("new-token", 3600)); - }); - let server = start_server(mocks).await; - let strategy = Arc::new(make_access_key_strategy(&server)); - - let s1 = Arc::clone(&strategy); - let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() }); - - let s2 = Arc::clone(&strategy); - let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() }); - - let (result_a, result_b) = tokio::join!(handle_a, handle_b); - let token_a = result_a.unwrap(); - let token_b = result_b.unwrap(); - - assert_eq!(token_a.as_str(), "new-token"); - assert_eq!(token_b.as_str(), "new-token"); - } - - #[tokio::test] - async fn test_concurrent_access_expired_token() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/api/authorise"); - then.json(auth_response_json("refreshed-token", 3600)); - }); - let server = start_server(mocks).await; - - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); - let strategy = Arc::new(AutoRefresh::with_token( - refresher, - make_expired_token("old-token"), - )); - - let s1 = Arc::clone(&strategy); - let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() }); - - let s2 = Arc::clone(&strategy); - let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() }); - - let (result_a, result_b) = tokio::join!(handle_a, handle_b); - let token_a = result_a.unwrap(); - let token_b = result_b.unwrap(); - - assert_eq!(token_a.as_str(), "refreshed-token"); - assert_eq!(token_b.as_str(), "refreshed-token"); - } - - // ---- Concurrent access: expiring but usable ---- - - #[tokio::test] - async fn test_concurrent_access_expiring_but_usable() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/api/authorise"); - then.json(auth_response_json("refreshed-token", 3600)); - }); - let server = start_server(mocks).await; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - let expiring_token = Token { - access_token: SecretToken::new("still-usable"), - token_type: "Bearer".to_string(), - expires_at: now + 30, // is_expired() = true (within 90s), is_usable() = true - refresh_token: None, - region: None, - client_id: None, - device_instance_id: None, - }; - - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), server.url(""), None); - let strategy = Arc::new(AutoRefresh::with_token(refresher, expiring_token)); - - let s1 = Arc::clone(&strategy); - let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() }); - - let s2 = Arc::clone(&strategy); - let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() }); - - let (result_a, result_b) = tokio::join!(handle_a, handle_b); - let token_a = result_a.unwrap(); - let token_b = result_b.unwrap(); - - // Both should succeed with either old or refreshed token. - assert!( - token_a.as_str() == "still-usable" || token_a.as_str() == "refreshed-token", - "unexpected token_a: {}", - token_a.as_str() - ); - assert!( - token_b.as_str() == "still-usable" || token_b.as_str() == "refreshed-token", - "unexpected token_b: {}", - token_b.as_str() - ); - } - - // ---- Stress tests ---- - - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::time::{Duration, Instant}; - - #[derive(Clone)] - struct CountingState { - total: Arc, - current: Arc, - peak: Arc, - } - - impl CountingState { - fn new() -> Self { - Self { - total: Arc::new(AtomicUsize::new(0)), - current: Arc::new(AtomicUsize::new(0)), - peak: Arc::new(AtomicUsize::new(0)), - } - } - - fn enter(&self) { - self.total.fetch_add(1, Ordering::SeqCst); - let prev = self.current.fetch_add(1, Ordering::SeqCst); - self.peak.fetch_max(prev + 1, Ordering::SeqCst); - } - - fn exit(&self) { - self.current.fetch_sub(1, Ordering::SeqCst); - } - - fn peak(&self) -> usize { - self.peak.load(Ordering::SeqCst) - } - - fn total(&self) -> usize { - self.total.load(Ordering::SeqCst) - } - } - - #[derive(Clone)] - struct DelayedAuthState { - counting: CountingState, - delay: Duration, - } - - async fn delayed_auth_handler( - axum::extract::State(state): axum::extract::State, - ) -> axum::Json { - state.counting.enter(); - tokio::time::sleep(state.delay).await; - state.counting.exit(); - // CTS returns `expiry` as an absolute epoch (JWT `exp`); model a token - // valid for 1 hour from now. - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - axum::Json(serde_json::json!({ - "accessToken": "refreshed-token", - "expiry": now + 3600 - })) - } - - async fn start_axum_server(state: DelayedAuthState) -> (Url, CountingState) { - let counting = state.counting.clone(); - let app = axum::Router::new() - .route("/api/authorise", axum::routing::post(delayed_auth_handler)) - .with_state(state); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - let base_url = Url::parse(&format!("http://{addr}")).unwrap(); - (base_url, counting) - } - - const CONCURRENCY: usize = 50; - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn test_stress_initial_auth() { - let state = DelayedAuthState { - counting: CountingState::new(), - delay: Duration::from_millis(200), - }; - let (base_url, stats) = start_axum_server(state).await; - - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), base_url, None); - let strategy = Arc::new(AutoRefresh::new(refresher)); - - let start = Instant::now(); - let mut handles = Vec::with_capacity(CONCURRENCY); - for _ in 0..CONCURRENCY { - let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); - } - - let results: Vec<_> = { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push(handle.await.unwrap()); - } - results - }; - let elapsed = start.elapsed(); - - for token in &results { - assert_eq!(token.as_str(), "refreshed-token"); - } - - assert!( - elapsed < Duration::from_millis(600), - "expected < 600ms, got {:?}", - elapsed - ); - assert_eq!(stats.total(), 1, "only one auth request should be made"); - assert_eq!(stats.peak(), 1, "peak concurrency to auth endpoint"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn test_stress_cached_token() { - let state = DelayedAuthState { - counting: CountingState::new(), - delay: Duration::from_millis(500), - }; - let (base_url, stats) = start_axum_server(state).await; - - // Pre-authenticate. - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), base_url, None); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - let token = Token { - access_token: SecretToken::new("cached-token"), - token_type: "Bearer".to_string(), - expires_at: now + 3600, - refresh_token: None, - region: None, - client_id: None, - device_instance_id: None, - }; - let strategy = Arc::new(AutoRefresh::with_token(refresher, token)); - - let start = Instant::now(); - let mut handles = Vec::with_capacity(CONCURRENCY); - for _ in 0..CONCURRENCY { - let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); - } - - let results: Vec<_> = { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push(handle.await.unwrap()); - } - results - }; - let elapsed = start.elapsed(); - - for token in &results { - assert_eq!(token.as_str(), "cached-token"); - } - - assert!( - elapsed < Duration::from_millis(200), - "expected < 200ms for cached tokens, got {:?}", - elapsed - ); - assert_eq!(stats.total(), 0, "no auth requests should be made"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn test_stress_expiring_but_usable_non_blocking() { - let state = DelayedAuthState { - counting: CountingState::new(), - delay: Duration::from_millis(500), - }; - let (base_url, stats) = start_axum_server(state).await; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - let expiring_token = Token { - access_token: SecretToken::new("still-usable"), - token_type: "Bearer".to_string(), - expires_at: now + 30, - refresh_token: None, - region: None, - client_id: None, - device_instance_id: None, - }; - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), base_url, None); - let strategy = Arc::new(AutoRefresh::with_token(refresher, expiring_token)); - - let start = Instant::now(); - let mut handles = Vec::with_capacity(CONCURRENCY); - for _ in 0..CONCURRENCY { - let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { - let call_start = Instant::now(); - let token = s.get_token().await.unwrap(); - (token, call_start.elapsed()) - })); - } - - let results: Vec<_> = { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push(handle.await.unwrap()); - } - results - }; - let _elapsed = start.elapsed(); - - for (token, _) in &results { - assert!( - token.as_str() == "still-usable" || token.as_str() == "refreshed-token", - "unexpected token: {}", - token.as_str() - ); - } - - // At least N-1 callers should be fast (non-blocking). - let fast_callers = results - .iter() - .filter(|(_, dur)| *dur < Duration::from_millis(100)) - .count(); - assert!( - fast_callers >= CONCURRENCY - 1, - "expected at least {} fast callers, got {}", - CONCURRENCY - 1, - fast_callers, - ); - - assert_eq!(stats.peak(), 1, "peak concurrency to auth endpoint"); - assert_eq!(stats.total(), 1, "total auth requests"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn test_stress_expired_token_blocks() { - let refresh_delay = Duration::from_millis(200); - let state = DelayedAuthState { - counting: CountingState::new(), - delay: refresh_delay, - }; - let (base_url, stats) = start_axum_server(state).await; - - let refresher = - AccessKeyRefresher::new(SecretToken::new("test-access-key"), base_url, None); - let strategy = Arc::new(AutoRefresh::with_token( - refresher, - make_expired_token("old-token"), - )); - - let start = Instant::now(); - let mut handles = Vec::with_capacity(CONCURRENCY); - for _ in 0..CONCURRENCY { - let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); - } - - let results: Vec<_> = { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push(handle.await.unwrap()); - } - results - }; - let elapsed = start.elapsed(); - - for token in &results { - assert_eq!(token.as_str(), "refreshed-token"); - } - - assert!( - elapsed < refresh_delay + Duration::from_millis(200), - "expected < {:?}, got {:?}", - refresh_delay + Duration::from_millis(200), - elapsed - ); - - assert_eq!(stats.peak(), 1, "peak concurrency to auth endpoint"); - assert_eq!(stats.total(), 1, "total auth requests"); - } -} diff --git a/vendor/stack-auth/src/access_key_strategy.rs b/vendor/stack-auth/src/access_key_strategy.rs deleted file mode 100644 index 3339eee4..00000000 --- a/vendor/stack-auth/src/access_key_strategy.rs +++ /dev/null @@ -1,112 +0,0 @@ -use cts_common::{CtsServiceDiscovery, Region, ServiceDiscovery}; - -use crate::access_key::AccessKey; -use crate::access_key_refresher::AccessKeyRefresher; -use crate::auto_refresh::AutoRefresh; -use crate::{ensure_trailing_slash, AuthError, AuthStrategy, SecretToken, ServiceToken}; - -/// An [`AuthStrategy`] that uses a static access key to authenticate. -/// -/// The first call to [`get_token`](AuthStrategy::get_token) authenticates with -/// the server. Subsequent calls return the cached token until it expires, at -/// which point re-authentication happens automatically. -/// -/// # Example -/// -/// ```no_run -/// use stack_auth::{AccessKey, AccessKeyStrategy}; -/// use cts_common::Region; -/// -/// let region = Region::aws("ap-southeast-2").unwrap(); -/// let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); -/// let strategy = AccessKeyStrategy::new(region, key).unwrap(); -/// ``` -pub struct AccessKeyStrategy { - inner: AutoRefresh, -} - -impl AccessKeyStrategy { - /// Create a new `AccessKeyStrategy` for the given region and access key. - /// - /// The auth endpoint is resolved automatically via service discovery. - pub fn new(region: Region, access_key: AccessKey) -> Result { - Self::builder(region, access_key).build() - } - - /// Return a builder for configuring an `AccessKeyStrategy` before construction. - /// - /// # Example - /// - /// ```no_run - /// use stack_auth::{AccessKey, AccessKeyStrategy}; - /// use cts_common::Region; - /// - /// let region = Region::aws("ap-southeast-2").unwrap(); - /// let key: AccessKey = "CSAKmyKeyId.myKeySecret".parse().unwrap(); - /// let strategy = AccessKeyStrategy::builder(region, key) - /// .audience("my-audience") - /// .build() - /// .unwrap(); - /// ``` - pub fn builder(region: Region, access_key: AccessKey) -> AccessKeyStrategyBuilder { - AccessKeyStrategyBuilder { - region, - access_key: access_key.into_secret_token(), - audience: None, - base_url_override: None, - } - } -} - -impl AuthStrategy for &AccessKeyStrategy { - async fn get_token(self) -> Result { - Ok(self.inner.get_token().await?) - } -} - -/// Builder for [`AccessKeyStrategy`]. -/// -/// Created via [`AccessKeyStrategy::builder`]. -pub struct AccessKeyStrategyBuilder { - region: Region, - access_key: SecretToken, - audience: Option, - base_url_override: Option, -} - -impl AccessKeyStrategyBuilder { - /// Set the audience for token requests. - pub fn audience(mut self, audience: impl Into) -> Self { - self.audience = Some(audience.into()); - self - } - - /// Override the base URL resolved by service discovery. - /// - /// Useful for pointing at a local or mock auth server during testing. - #[cfg(any(test, feature = "test-utils"))] - pub fn base_url(mut self, url: url::Url) -> Self { - self.base_url_override = Some(url); - self - } - - /// Build the [`AccessKeyStrategy`]. - /// - /// Resolves the base URL via service discovery unless overridden with - /// `base_url` (available when the `test-utils` feature is enabled). - pub fn build(self) -> Result { - let base_url = match self.base_url_override { - Some(url) => url, - None => crate::cts_base_url_from_env()? - .unwrap_or(CtsServiceDiscovery::endpoint(self.region)?), - }; - let refresher = AccessKeyRefresher::new( - self.access_key, - ensure_trailing_slash(base_url), - self.audience, - ); - Ok(AccessKeyStrategy { - inner: AutoRefresh::new(refresher), - }) - } -} diff --git a/vendor/stack-auth/src/auto_refresh.rs b/vendor/stack-auth/src/auto_refresh.rs deleted file mode 100644 index c90dc5e7..00000000 --- a/vendor/stack-auth/src/auto_refresh.rs +++ /dev/null @@ -1,1590 +0,0 @@ -use std::sync::atomic::{AtomicBool, Ordering}; - -use tokio::sync::{Mutex, MutexGuard, Notify}; - -use crate::refresher::Refresher; -use crate::{ServiceToken, Token}; - -/// Internal errors from [`AutoRefresh::get_token`]. -/// -/// Strategy wrappers convert these into [`AuthError`](crate::AuthError) for the -/// public API. -#[derive(Debug, thiserror::Error)] -pub(crate) enum AutoRefreshError { - /// No token is cached and the strategy cannot self-authenticate. - #[error("No token found")] - NotFound, - /// The token has expired and refresh failed or is unavailable. - #[error("Token has expired")] - Expired, - /// The refresh/auth HTTP call failed. - #[error("Auth error: {0}")] - Auth(#[from] crate::AuthError), -} - -impl From for crate::AuthError { - fn from(err: AutoRefreshError) -> Self { - match err { - AutoRefreshError::NotFound => crate::AuthError::NotAuthenticated, - AutoRefreshError::Expired => crate::AuthError::TokenExpired, - AutoRefreshError::Auth(e) => e, - } - } -} - -/// Caches a token in memory and uses a [`Refresher`] to re-authenticate -/// or refresh before expiry. -/// -/// See the [crate-level documentation](crate#token-refresh) for a full -/// description of the concurrency model and flow diagram. -pub(crate) struct AutoRefresh { - refresher: R, - state: Mutex, - /// Set to `true` while a refresh HTTP call is in-flight. - /// - /// Stored as an [`AtomicBool`] rather than inside [`State`] so that - /// [`CancelGuard`] can reset it on future cancellation without acquiring - /// the mutex. - refresh_in_progress: AtomicBool, - refresh_notify: Notify, -} - -struct State { - token: Option, -} - -/// Ensures [`AutoRefresh::refresh_in_progress`] is cleared and waiters are -/// notified if the refresh future is cancelled (dropped) before completing. -/// -/// On the normal path (success or handled error), the guard is defused before -/// drop so that the regular cleanup code runs instead. -struct CancelGuard<'a> { - in_progress: &'a AtomicBool, - notify: &'a Notify, - defused: bool, -} - -impl Drop for CancelGuard<'_> { - fn drop(&mut self) { - if !self.defused { - self.in_progress.store(false, Ordering::Release); - self.notify.notify_waiters(); - } - } -} - -impl CancelGuard<'_> { - fn defuse(&mut self) { - self.defused = true; - } -} - -impl State { - fn service_token(&self) -> Result { - let token = self.token.as_ref().ok_or(AutoRefreshError::NotFound)?; - Ok(ServiceToken::new(token.access_token().clone())) - } - - fn require_usable_token(&self) -> Result { - let token = self.token.as_ref().ok_or(AutoRefreshError::NotFound)?; - if token.is_usable() { - Ok(ServiceToken::new(token.access_token().clone())) - } else { - Err(AutoRefreshError::Expired) - } - } -} - -impl AutoRefresh { - /// Create a new `AutoRefresh` with no initial token. - /// - /// The first call to `get_token` will attempt initial authentication via - /// `try_credential(None)` → `refresh()`. Use this for refreshers that can - /// self-authenticate (e.g. access keys). - pub(crate) fn new(refresher: R) -> Self { - Self { - refresher, - state: Mutex::new(State { token: None }), - refresh_in_progress: AtomicBool::new(false), - refresh_notify: Notify::new(), - } - } - - /// Create a new `AutoRefresh` with a pre-loaded token. - /// - /// Use this for refreshers that cannot self-authenticate (e.g. OAuth, - /// which needs a refresh token from a prior device code flow). - pub(crate) fn with_token(refresher: R, token: Token) -> Self { - Self { - refresher, - state: Mutex::new(State { token: Some(token) }), - refresh_in_progress: AtomicBool::new(false), - refresh_notify: Notify::new(), - } - } -} - -impl AutoRefresh { - /// Retrieve a valid access token, refreshing or re-authenticating as needed. - pub(crate) async fn get_token(&self) -> Result { - let mut state = self.state.lock().await; - - if state.token.is_none() { - return self.initial_auth(&mut state).await; - } - - if !state.token.as_ref().is_some_and(|t| t.is_expired()) { - return state.service_token(); - } - - if self.refresh_in_progress.load(Ordering::Acquire) { - return self.wait_for_in_flight_refresh(state).await; - } - - let Some(credential) = self.refresher.try_credential(state.token.as_mut()) else { - return state.require_usable_token(); - }; - - self.refresh_in_progress.store(true, Ordering::Release); - - if state.token.as_ref().is_some_and(|t| t.is_usable()) { - self.refresh_non_blocking(state, credential).await - } else { - self.refresh_blocking(&mut state, credential).await - } - } - - /// No cached token — authenticate via `try_credential(None)`. - /// - /// The lock is held throughout to prevent concurrent initial-auth attempts. - async fn initial_auth(&self, state: &mut State) -> Result { - let Some(credential) = self.refresher.try_credential(None) else { - return Err(AutoRefreshError::NotFound); - }; - self.refresh_in_progress.store(true, Ordering::Release); - let mut guard = CancelGuard { - in_progress: &self.refresh_in_progress, - notify: &self.refresh_notify, - defused: false, - }; - match self.refresher.refresh(&credential).await { - Ok(new_token) => { - self.refresher.save(&new_token); - let service_token = ServiceToken::new(new_token.access_token().clone()); - state.token = Some(new_token); - self.refresh_in_progress.store(false, Ordering::Release); - // Defuse only after the token is installed and the flag cleared, - // so a cancellation anywhere up to here still fires CancelGuard's - // Drop (clears refresh_in_progress + notifies waiters). See CIP-3159. - guard.defuse(); - Ok(service_token) - } - Err(err) => { - guard.defuse(); - self.refresh_in_progress.store(false, Ordering::Release); - Err(AutoRefreshError::Auth(err)) - } - } - } - - /// Another caller is already refreshing — return the current token if still - /// usable, otherwise wait for the in-flight refresh to complete via `Notify`. - /// - /// Takes `MutexGuard` by value because the lock is dropped before awaiting - /// the notification. - async fn wait_for_in_flight_refresh( - &self, - state: MutexGuard<'_, State>, - ) -> Result { - if let Ok(token) = state.service_token() { - if state.token.as_ref().is_some_and(|t| t.is_usable()) { - return Ok(token); - } - } - // Token crossed real expiry during in-flight refresh. Wait for the - // refresh to complete rather than returning Expired. - let notified = self.refresh_notify.notified(); - drop(state); - notified.await; - // Re-check after wake — refresh may have failed. - let state = self.state.lock().await; - state.require_usable_token() - } - - /// Token is expiring but still usable — drop the lock, refresh in the - /// background of this call, and return the old (still-valid) token. - /// - /// Takes `MutexGuard` by value because the lock is dropped before the HTTP - /// request. Notifies waiters after the refresh completes (success or error). - /// - /// A [`CancelGuard`] ensures that if this future is cancelled at any point - /// before the new token is installed — including the post-HTTP, pre-install - /// re-lock window — `refresh_in_progress` is cleared and waiters are - /// notified, so subsequent callers don't hang in - /// [`wait_for_in_flight_refresh`](Self::wait_for_in_flight_refresh). See CIP-3159. - async fn refresh_non_blocking( - &self, - state: MutexGuard<'_, State>, - credential: R::Credential, - ) -> Result { - let current_service_token = state.service_token()?; - drop(state); - - let mut guard = CancelGuard { - in_progress: &self.refresh_in_progress, - notify: &self.refresh_notify, - defused: false, - }; - - match self.refresher.refresh(&credential).await { - Ok(new_token) => { - self.refresher.save(&new_token); - let mut state = self.state.lock().await; - state.token = Some(new_token); - self.refresh_in_progress.store(false, Ordering::Release); - // Defer defuse() past the re-lock + install so a cancellation - // landing on `state.lock().await` still strands neither the flag - // nor waiters. See CIP-3159. - guard.defuse(); - } - Err(err) => { - tracing::warn!(%err, "token refresh failed (token still usable)"); - let mut state = self.state.lock().await; - if let Some(token) = state.token.as_mut() { - self.refresher.restore(token, credential); - } - self.refresh_in_progress.store(false, Ordering::Release); - // Defer defuse() past the re-lock + restore for the same reason - // as the Ok branch (mirror of upstream commit 2ee370561). - guard.defuse(); - } - } - - self.refresh_notify.notify_waiters(); - Ok(current_service_token) - } - - /// Token is fully expired — refresh while holding the lock so concurrent - /// callers block on `lock().await` until the new token is available. - /// - /// A [`CancelGuard`] ensures that if this future is cancelled during the - /// HTTP request, `refresh_in_progress` is cleared and waiters are notified - /// so they don't hang indefinitely. (The credential is lost on cancel — - /// see [`CancelGuard`] docs — but subsequent callers will get `Expired` - /// rather than blocking forever.) - async fn refresh_blocking( - &self, - state: &mut State, - credential: R::Credential, - ) -> Result { - let mut guard = CancelGuard { - in_progress: &self.refresh_in_progress, - notify: &self.refresh_notify, - defused: false, - }; - match self.refresher.refresh(&credential).await { - Ok(new_token) => { - self.refresher.save(&new_token); - let service_token = ServiceToken::new(new_token.access_token().clone()); - state.token = Some(new_token); - self.refresh_in_progress.store(false, Ordering::Release); - // Defuse after install for parity with the other success paths - // (CIP-3159). The lock is held throughout here, so there is no - // await between install and defuse, but keep the invariant uniform. - guard.defuse(); - Ok(service_token) - } - Err(err) => { - guard.defuse(); - tracing::warn!(%err, "token refresh failed"); - if let Some(token) = state.token.as_mut() { - self.refresher.restore(token, credential); - } - self.refresh_in_progress.store(false, Ordering::Release); - Err(AutoRefreshError::Expired) - } - } - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod tests { - use super::*; - use crate::oauth_refresher::OAuthRefresher; - use crate::SecretToken; - use mocktail::prelude::*; - use stack_profile::ProfileStore; - use std::sync::Arc; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn make_token(access: &str, expires_in: u64, refresh: bool) -> Token { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - Token { - access_token: SecretToken::new(access), - token_type: "Bearer".to_string(), - expires_at: now + expires_in, - refresh_token: if refresh { - Some(SecretToken::new("test-refresh-token")) - } else { - None - }, - region: None, - client_id: None, - device_instance_id: None, - } - } - - fn refresh_response_json(access: &str) -> serde_json::Value { - serde_json::json!({ - "access_token": access, - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "new-refresh-token" - }) - } - - fn error_json(error: &str) -> serde_json::Value { - serde_json::json!({ - "error": error, - "error_description": format!("{error} occurred") - }) - } - - async fn start_server(mocks: MockSet) -> MockServer { - let server = MockServer::new_http("auto-refresh-test").with_mocks(mocks); - server.start().await.unwrap(); - server - } - - fn auto_refresh_with_token( - dir: &tempfile::TempDir, - server: &MockServer, - token: Token, - ) -> AutoRefresh { - let store = ProfileStore::new(dir.path()); - store.init_workspace("ZVATKW3VHMFG27DY").unwrap(); - let ws_store = store.current_workspace_store().unwrap(); - ws_store.save_profile(&token).unwrap(); - let refresher = OAuthRefresher::new( - Some(ws_store), - server.url(""), - "cli", - "ap-southeast-2.aws", - None, - ); - AutoRefresh::with_token(refresher, token) - } - - mod given_no_cached_token { - use super::*; - - #[tokio::test] - async fn returns_not_found_for_oauth() { - let server = start_server(MockSet::new()).await; - let store = ProfileStore::new("/tmp/nonexistent"); - let refresher = OAuthRefresher::new( - Some(store), - server.url(""), - "cli", - "ap-southeast-2.aws", - None, - ); - let strategy = AutoRefresh::new(refresher); - - let err = strategy.get_token().await.unwrap_err(); - - assert!( - matches!(err, AutoRefreshError::NotFound), - "expected NotFound, got: {err:?}" - ); - } - } - - mod given_fresh_token { - use super::*; - - #[tokio::test] - async fn returns_cached_token() { - let dir = tempfile::tempdir().unwrap(); - let server = start_server(MockSet::new()).await; - let strategy = - auto_refresh_with_token(&dir, &server, make_token("my-access-token", 3600, false)); - - let token = strategy.get_token().await.unwrap(); - - assert_eq!( - token.as_str(), - "my-access-token", - "should return the cached access token" - ); - } - - #[tokio::test] - async fn caches_across_calls() { - let dir = tempfile::tempdir().unwrap(); - let server = start_server(MockSet::new()).await; - let strategy = - auto_refresh_with_token(&dir, &server, make_token("my-access-token", 3600, false)); - - let token1 = strategy.get_token().await.unwrap(); - assert_eq!( - token1.as_str(), - "my-access-token", - "first call should return the cached token" - ); - - // Delete the file — second call should still return the cached token. - std::fs::remove_file( - dir.path() - .join("workspaces") - .join("ZVATKW3VHMFG27DY") - .join("auth.json"), - ) - .unwrap(); - - let token2 = strategy.get_token().await.unwrap(); - assert_eq!( - token2.as_str(), - "my-access-token", - "second call should return the cached token even after file deletion" - ); - } - - #[tokio::test] - async fn does_not_trigger_refresh() { - // Mock that would fail if hit — proves no refresh request is made. - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.internal_server_error() - .json(error_json("should_not_be_called")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = - auto_refresh_with_token(&dir, &server, make_token("fresh-token", 3600, true)); - - let token = strategy.get_token().await.unwrap(); - - assert_eq!( - token.as_str(), - "fresh-token", - "should return fresh token without triggering refresh" - ); - } - } - - mod given_fully_expired_token { - use super::*; - - mod without_refresh_token { - use super::*; - - #[tokio::test] - async fn returns_expired() { - let dir = tempfile::tempdir().unwrap(); - let server = start_server(MockSet::new()).await; - let strategy = - auto_refresh_with_token(&dir, &server, make_token("old-token", 0, false)); - - let err = strategy.get_token().await.unwrap_err(); - - assert!( - matches!(err, AutoRefreshError::Expired), - "expected Expired, got: {err:?}" - ); - } - } - - mod with_refresh_token { - use super::*; - - #[tokio::test] - async fn refreshes_and_returns_new_token() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json("refreshed-token")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = - auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); - - let token = strategy.get_token().await.unwrap(); - - assert_eq!( - token.as_str(), - "refreshed-token", - "should return the refreshed token" - ); - } - - #[tokio::test] - async fn persists_refreshed_token_to_disk() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json("refreshed-token")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = - auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); - - let _ = strategy.get_token().await.unwrap(); - - // Verify the refreshed token was saved to the workspace directory. - let store = ProfileStore::new(dir.path()); - let ws_store = store.current_workspace_store().unwrap(); - let on_disk: Token = ws_store.load_profile().unwrap(); - assert_eq!( - on_disk.access_token().as_str(), - "refreshed-token", - "refreshed token should be persisted to disk" - ); - } - - #[tokio::test] - async fn returns_expired_on_refresh_failure() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.bad_request().json(error_json("invalid_grant")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = - auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); - - let err = strategy.get_token().await.unwrap_err(); - - assert!( - matches!(err, AutoRefreshError::Expired), - "expected Expired after failed refresh, got: {err:?}" - ); - } - - #[tokio::test] - async fn restores_refresh_token_after_failure() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.bad_request().json(error_json("invalid_grant")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = - auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); - - // First call: refresh fails, returns Expired. - let err = strategy.get_token().await.unwrap_err(); - assert!( - matches!(err, AutoRefreshError::Expired), - "expected Expired on first attempt, got: {err:?}" - ); - - // Verify the refresh token was restored so a retry is possible. - let state = strategy.state.lock().await; - assert!( - state.token.is_some(), - "token should still be cached after failed refresh" - ); - assert!( - state.token.as_ref().unwrap().refresh_token().is_some(), - "refresh token should be restored for retry" - ); - drop(state); - - // Replace mock with a success response. - server.mocks().clear(); - server.mocks().mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json("refreshed-token")); - }); - - // Second call: refresh token is available → retry succeeds. - let token = strategy.get_token().await.unwrap(); - assert_eq!( - token.as_str(), - "refreshed-token", - "retry should succeed with restored refresh token" - ); - } - - #[tokio::test] - async fn sequential_calls_only_refresh_once() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json("refreshed-once")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = - auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); - - // First call triggers refresh. - let token = strategy.get_token().await.unwrap(); - assert_eq!( - token.as_str(), - "refreshed-once", - "first call should trigger refresh" - ); - - // Swap mock to track if another refresh is attempted. - server.mocks().clear(); - server.mocks().mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json("refreshed-twice")); - }); - - // Calls 2-5: the refreshed token is fresh, so no further refresh. - for _ in 0..4 { - let token = strategy.get_token().await.unwrap(); - assert_eq!( - token.as_str(), - "refreshed-once", - "should return cached refreshed token, not trigger another refresh" - ); - } - } - - #[tokio::test] - async fn prevents_second_refresh_after_success() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json("refreshed-token")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = - auto_refresh_with_token(&dir, &server, make_token("old-token", 0, true)); - - // First call refreshes successfully. - let token = strategy.get_token().await.unwrap(); - assert_eq!( - token.as_str(), - "refreshed-token", - "first call should refresh the token" - ); - - // Replace the mock with one that errors. - server.mocks().clear(); - server.mocks().mock(|when, then| { - when.post().path("/oauth/token"); - then.bad_request().json(error_json("should_not_be_called")); - }); - - // Second call should return the refreshed token without hitting - // the server again (the new token has a fresh expiry). - let token = strategy.get_token().await.unwrap(); - assert_eq!( - token.as_str(), - "refreshed-token", - "second call should return cached refreshed token" - ); - } - } - } - - mod given_expiring_but_usable_token { - use super::*; - - mod when_refresh_fails { - use super::*; - - #[tokio::test] - async fn returns_current_token() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.bad_request().json(error_json("server_error")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - // Token expires in 30s (within the 90s leeway so is_expired() = true), - // but the access token is still technically usable. - let strategy = - auto_refresh_with_token(&dir, &server, make_token("still-usable", 30, true)); - - // The refresh fails, but the access token should still be returned - // because it's still usable (30s remaining > 0). - let token = strategy.get_token().await.unwrap(); - assert_eq!( - token.as_str(), - "still-usable", - "should return still-usable token despite failed refresh" - ); - - // Verify the access token and refresh token are still present. - let state = strategy.state.lock().await; - assert!(state.token.is_some(), "token should still be cached"); - assert_eq!( - state.token.as_ref().unwrap().access_token().as_str(), - "still-usable", - "access token should be unchanged after failed refresh" - ); - assert!( - state.token.as_ref().unwrap().refresh_token().is_some(), - "refresh token should be restored after failed refresh" - ); - } - - #[tokio::test] - async fn restores_refresh_token_for_retry() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.bad_request().json(error_json("server_error")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - // Token expires in 30s — is_expired() = true, is_usable() = true. - let strategy = - auto_refresh_with_token(&dir, &server, make_token("still-usable", 30, true)); - - // First call: refresh fails, but the still-usable token is returned. - let token = strategy.get_token().await.unwrap(); - assert_eq!( - token.as_str(), - "still-usable", - "first call should return still-usable token" - ); - - // Replace mock with a success response. - server.mocks().clear(); - server.mocks().mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json("refreshed-token")); - }); - - // Second call: refresh token was restored, so the retry succeeds. - let token = strategy.get_token().await.unwrap(); - assert!( - token.as_str() == "still-usable" || token.as_str() == "refreshed-token", - "expected old or refreshed token, got: {}", - token.as_str() - ); - - // Verify the cache now holds the refreshed token. - let state = strategy.state.lock().await; - assert_eq!( - state.token.as_ref().unwrap().access_token().as_str(), - "refreshed-token", - "cache should hold the refreshed token after retry" - ); - } - } - } - - mod given_concurrent_callers { - use super::*; - - #[tokio::test] - async fn returns_usable_token_while_refreshing() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json("refreshed-token")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(auto_refresh_with_token( - &dir, - &server, - make_token("still-usable", 30, true), - )); - - let s1 = Arc::clone(&strategy); - let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() }); - - let s2 = Arc::clone(&strategy); - let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() }); - - let (result_a, result_b) = tokio::join!(handle_a, handle_b); - let token_a = result_a.unwrap(); - let token_b = result_b.unwrap(); - - assert!( - token_a.as_str() == "still-usable" || token_a.as_str() == "refreshed-token", - "unexpected token_a: {}", - token_a.as_str() - ); - assert!( - token_b.as_str() == "still-usable" || token_b.as_str() == "refreshed-token", - "unexpected token_b: {}", - token_b.as_str() - ); - } - - #[tokio::test] - async fn blocks_until_refresh_completes() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json("refreshed-token")); - }); - let server = start_server(mocks).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(auto_refresh_with_token( - &dir, - &server, - make_token("expired-token", 0, true), - )); - - let s1 = Arc::clone(&strategy); - let handle_a = tokio::spawn(async move { s1.get_token().await.unwrap() }); - - let s2 = Arc::clone(&strategy); - let handle_b = tokio::spawn(async move { s2.get_token().await.unwrap() }); - - let (result_a, result_b) = tokio::join!(handle_a, handle_b); - let token_a = result_a.unwrap(); - let token_b = result_b.unwrap(); - - assert_eq!( - token_a.as_str(), - "refreshed-token", - "caller a should receive refreshed token" - ); - assert_eq!( - token_b.as_str(), - "refreshed-token", - "caller b should receive refreshed token" - ); - } - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod stress_tests { - use super::*; - use crate::oauth_refresher::OAuthRefresher; - use crate::SecretToken; - use stack_profile::ProfileStore; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::Arc; - use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - - /// Tracks in-flight and peak concurrency for test assertions. - #[derive(Clone)] - struct CountingState { - total: Arc, - current: Arc, - peak: Arc, - } - - impl CountingState { - fn new() -> Self { - Self { - total: Arc::new(AtomicUsize::new(0)), - current: Arc::new(AtomicUsize::new(0)), - peak: Arc::new(AtomicUsize::new(0)), - } - } - - fn enter(&self) { - self.total.fetch_add(1, Ordering::SeqCst); - let prev = self.current.fetch_add(1, Ordering::SeqCst); - self.peak.fetch_max(prev + 1, Ordering::SeqCst); - } - - fn exit(&self) { - self.current.fetch_sub(1, Ordering::SeqCst); - } - - fn peak(&self) -> usize { - self.peak.load(Ordering::SeqCst) - } - - fn total(&self) -> usize { - self.total.load(Ordering::SeqCst) - } - } - - #[derive(Clone)] - struct DelayedRefreshState { - counting: CountingState, - delay: Duration, - } - - async fn delayed_refresh_handler( - axum::extract::State(state): axum::extract::State, - ) -> axum::Json { - state.counting.enter(); - tokio::time::sleep(state.delay).await; - state.counting.exit(); - axum::Json(serde_json::json!({ - "access_token": "refreshed-token", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "new-refresh-token" - })) - } - - async fn delayed_error_handler( - axum::extract::State(state): axum::extract::State, - ) -> (axum::http::StatusCode, axum::Json) { - state.counting.enter(); - tokio::time::sleep(state.delay).await; - state.counting.exit(); - ( - axum::http::StatusCode::BAD_REQUEST, - axum::Json(serde_json::json!({ - "error": "invalid_grant", - "error_description": "invalid_grant occurred" - })), - ) - } - - async fn start_axum_server( - handler: H, - state: DelayedRefreshState, - ) -> (url::Url, CountingState) - where - H: axum::handler::Handler + Clone + Send + 'static, - T: 'static, - { - let counting = state.counting.clone(); - let app = axum::Router::new() - .route("/oauth/token", axum::routing::post(handler)) - .with_state(state); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - let base_url = url::Url::parse(&format!("http://{addr}")).unwrap(); - (base_url, counting) - } - - fn make_token(access: &str, expires_in: u64, refresh: bool) -> Token { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - Token { - access_token: SecretToken::new(access), - token_type: "Bearer".to_string(), - expires_at: now + expires_in, - refresh_token: if refresh { - Some(SecretToken::new("test-refresh-token")) - } else { - None - }, - region: None, - client_id: None, - device_instance_id: None, - } - } - - fn auto_refresh_with_token( - dir: &tempfile::TempDir, - base_url: &url::Url, - token: Token, - ) -> AutoRefresh { - let store = ProfileStore::new(dir.path()); - store.init_workspace("ZVATKW3VHMFG27DY").unwrap(); - let ws_store = store.current_workspace_store().unwrap(); - ws_store.save_profile(&token).unwrap(); - let refresher = OAuthRefresher::new( - Some(ws_store), - base_url.clone(), - "cli", - "ap-southeast-2.aws", - None, - ); - AutoRefresh::with_token(refresher, token) - } - - const CONCURRENCY: usize = 50; - - mod given_fresh_token { - use super::*; - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn all_callers_return_immediately() { - let counting = CountingState::new(); - let state = DelayedRefreshState { - counting: counting.clone(), - delay: Duration::from_millis(500), - }; - let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(auto_refresh_with_token( - &dir, - &base_url, - make_token("fresh-token", 3600, true), - )); - - let start = Instant::now(); - let mut handles = Vec::with_capacity(CONCURRENCY); - for _ in 0..CONCURRENCY { - let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); - } - - let results: Vec<_> = { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push(handle.await.unwrap()); - } - results - }; - let elapsed = start.elapsed(); - - for token in &results { - assert_eq!( - token.as_str(), - "fresh-token", - "all callers should receive the fresh token" - ); - } - - assert!( - elapsed < Duration::from_millis(200), - "expected < 200ms for fresh tokens, got {:?}", - elapsed - ); - assert_eq!(stats.total(), 0, "no refresh requests should be made"); - } - } - - mod given_expiring_but_usable_token { - use super::*; - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn non_blocking_reads_during_refresh() { - let counting = CountingState::new(); - let state = DelayedRefreshState { - counting: counting.clone(), - delay: Duration::from_millis(500), - }; - let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(auto_refresh_with_token( - &dir, - &base_url, - make_token("still-usable", 30, true), - )); - - let start = Instant::now(); - let mut handles = Vec::with_capacity(CONCURRENCY); - for _ in 0..CONCURRENCY { - let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { - let call_start = Instant::now(); - let token = s.get_token().await.unwrap(); - (token, call_start.elapsed()) - })); - } - - let results: Vec<_> = { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push(handle.await.unwrap()); - } - results - }; - let elapsed = start.elapsed(); - - for (token, _) in &results { - assert!( - token.as_str() == "still-usable" || token.as_str() == "refreshed-token", - "unexpected token: {}", - token.as_str() - ); - } - - let fast_callers = results - .iter() - .filter(|(_, dur)| *dur < Duration::from_millis(100)) - .count(); - assert!( - fast_callers >= CONCURRENCY - 1, - "expected at least {} fast callers, got {} (total elapsed: {:?})", - CONCURRENCY - 1, - fast_callers, - elapsed - ); - - assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint"); - assert_eq!(stats.total(), 1, "total refresh requests"); - } - - /// Reproduces the race condition where a token crosses real expiry during - /// an in-flight non-blocking refresh. Before the fix, late-arriving callers - /// would see `refresh_in_progress = true` + `!is_usable()` and return - /// `Err(Expired)` instead of waiting for the refresh to complete. - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn waiters_receive_token_when_expiry_crosses() { - // Token with 1s until real expiry (minimum granularity since - // expires_at is in seconds). is_expired() = true (within 90s leeway), - // is_usable() = true (1s remaining). Refresh takes 1.5s so the token - // crosses real expiry mid-refresh. - let refresh_delay = Duration::from_millis(1500); - let counting = CountingState::new(); - let state = DelayedRefreshState { - counting: counting.clone(), - delay: refresh_delay, - }; - let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(auto_refresh_with_token( - &dir, - &base_url, - make_token("expiring-soon", 1, true), - )); - - // First caller triggers the non-blocking refresh and gets the old token. - let first = strategy.get_token().await.unwrap(); - assert_eq!( - first.as_str(), - "expiring-soon", - "first caller should receive the expiring token" - ); - - // Wait for the token to cross real expiry (but refresh is still in-flight). - tokio::time::sleep(Duration::from_millis(1100)).await; - - // Launch 50 concurrent callers. Without the fix, these would all get - // Err(Expired) because refresh_in_progress = true and !is_usable(). - let mut handles = Vec::with_capacity(CONCURRENCY); - for _ in 0..CONCURRENCY { - let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { s.get_token().await })); - } - - let results: Vec<_> = { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push(handle.await.unwrap()); - } - results - }; - - // All callers must succeed — none should get Expired. - for (i, result) in results.iter().enumerate() { - assert!( - result.is_ok(), - "caller {i} got Err({:?}), expected Ok", - result.as_ref().unwrap_err() - ); - assert_eq!( - result.as_ref().unwrap().as_str(), - "refreshed-token", - "caller {i} should receive the refreshed token" - ); - } - - assert_eq!(stats.total(), 1, "only one refresh request should be made"); - } - } - - mod given_fully_expired_token { - use super::*; - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn all_callers_block_until_refresh() { - let refresh_delay = Duration::from_millis(200); - let counting = CountingState::new(); - let state = DelayedRefreshState { - counting: counting.clone(), - delay: refresh_delay, - }; - let (base_url, stats) = start_axum_server(delayed_refresh_handler, state).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(auto_refresh_with_token( - &dir, - &base_url, - make_token("expired-token", 0, true), - )); - - let start = Instant::now(); - let mut handles = Vec::with_capacity(CONCURRENCY); - for _ in 0..CONCURRENCY { - let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); - } - - let results: Vec<_> = { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push(handle.await.unwrap()); - } - results - }; - let elapsed = start.elapsed(); - - for token in &results { - assert_eq!( - token.as_str(), - "refreshed-token", - "all callers should receive refreshed token" - ); - } - - assert!( - elapsed < refresh_delay + Duration::from_millis(200), - "expected < {:?} for blocked callers, got {:?}", - refresh_delay + Duration::from_millis(200), - elapsed - ); - - assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint"); - assert_eq!(stats.total(), 1, "total refresh requests"); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn all_callers_receive_expired_on_failure() { - let counting = CountingState::new(); - let state = DelayedRefreshState { - counting: counting.clone(), - delay: Duration::from_millis(10), - }; - let (base_url, stats) = start_axum_server(delayed_error_handler, state).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(auto_refresh_with_token( - &dir, - &base_url, - make_token("expired-token", 0, true), - )); - - let mut handles = Vec::with_capacity(CONCURRENCY); - for _ in 0..CONCURRENCY { - let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { s.get_token().await })); - } - - let results: Vec<_> = { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push(handle.await.unwrap()); - } - results - }; - - for result in &results { - assert!(result.is_err(), "expected Expired error, got Ok"); - let err = result.as_ref().unwrap_err(); - assert!( - matches!(err, AutoRefreshError::Expired), - "expected Expired, got: {err:?}" - ); - } - - let state = strategy.state.lock().await; - assert!( - state.token.as_ref().unwrap().refresh_token().is_some(), - "refresh token should be restored after failed refresh" - ); - drop(state); - - assert_eq!(stats.peak(), 1, "peak concurrency to refresh endpoint"); - assert!( - stats.total() >= 1, - "at least one refresh attempt should be made" - ); - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn retry_succeeds_after_failure() { - // Phase 1: Server returns errors. - let counting1 = CountingState::new(); - let state1 = DelayedRefreshState { - counting: counting1.clone(), - delay: Duration::from_millis(50), - }; - let (base_url, _) = start_axum_server(delayed_error_handler, state1).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(auto_refresh_with_token( - &dir, - &base_url, - make_token("expired-token", 0, true), - )); - - let mut handles = Vec::with_capacity(CONCURRENCY); - for _ in 0..CONCURRENCY { - let s = Arc::clone(&strategy); - handles.push(tokio::spawn(async move { s.get_token().await })); - } - - let results: Vec<_> = { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push(handle.await.unwrap()); - } - results - }; - - for result in &results { - assert!( - result.is_err(), - "first wave: expected Expired, got Ok({})", - result.as_ref().unwrap().as_str() - ); - } - - // Phase 2: New server that returns success. - let counting2 = CountingState::new(); - let state2 = DelayedRefreshState { - counting: counting2.clone(), - delay: Duration::from_millis(50), - }; - let (base_url2, stats2) = start_axum_server(delayed_refresh_handler, state2).await; - - let strategy2 = Arc::new(auto_refresh_with_token( - &dir, - &base_url2, - make_token("expired-token", 0, true), - )); - - let mut handles = Vec::with_capacity(CONCURRENCY); - for _ in 0..CONCURRENCY { - let s = Arc::clone(&strategy2); - handles.push(tokio::spawn(async move { s.get_token().await.unwrap() })); - } - - let results: Vec<_> = { - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push(handle.await.unwrap()); - } - results - }; - - for token in &results { - assert_eq!( - token.as_str(), - "refreshed-token", - "retry callers should receive refreshed token" - ); - } - - assert_eq!(stats2.total(), 1, "only one retry refresh should be made"); - } - } - - mod given_cancelled_refresh { - use super::*; - - /// If a blocking refresh (fully expired token) is cancelled mid-flight, - /// the `CancelGuard` must reset `refresh_in_progress` and notify waiters - /// so the next caller doesn't hang in `wait_for_in_flight_refresh`. - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn blocked_callers_recover_after_cancellation() { - let counting = CountingState::new(); - let state = DelayedRefreshState { - counting: counting.clone(), - delay: Duration::from_secs(10), // Very slow — will be cancelled - }; - let (base_url, _) = start_axum_server(delayed_refresh_handler, state).await; - let dir = tempfile::tempdir().unwrap(); - let strategy = Arc::new(auto_refresh_with_token( - &dir, - &base_url, - make_token("expired-token", 0, true), - )); - - // Spawn get_token and let the blocking refresh start. - let s = Arc::clone(&strategy); - let handle = tokio::spawn(async move { s.get_token().await }); - tokio::time::sleep(Duration::from_millis(100)).await; - - // Cancel the refresh mid-flight. - handle.abort(); - let _ = handle.await; - - // The next caller must not hang. The credential is lost (refresh - // token was taken before the HTTP call), so the result is Expired, - // but the important thing is that it completes promptly. - let s = Arc::clone(&strategy); - let result = tokio::time::timeout(Duration::from_secs(2), s.get_token()).await; - - assert!( - result.is_ok(), - "get_token() should not hang after cancelled blocking refresh" - ); - } - - /// If a non-blocking refresh (expiring-but-usable token) is cancelled - /// mid-flight, the `CancelGuard` must reset `refresh_in_progress` and - /// notify waiters so they don't hang once the token crosses real expiry. - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn non_blocking_callers_recover_after_cancellation() { - let counting = CountingState::new(); - let state = DelayedRefreshState { - counting: counting.clone(), - delay: Duration::from_secs(10), // Very slow — will be cancelled - }; - let (base_url, _) = start_axum_server(delayed_refresh_handler, state).await; - let dir = tempfile::tempdir().unwrap(); - // Token expires in 30s — is_expired() = true, is_usable() = true. - let strategy = Arc::new(auto_refresh_with_token( - &dir, - &base_url, - make_token("still-usable", 30, true), - )); - - // Spawn get_token — triggers non-blocking refresh, drops lock, then - // blocks on the slow HTTP call. - let s = Arc::clone(&strategy); - let handle = tokio::spawn(async move { s.get_token().await }); - tokio::time::sleep(Duration::from_millis(100)).await; - - // Cancel the refresh mid-flight. - handle.abort(); - let _ = handle.await; - - // The next caller must not hang. The token is still usable so it - // should be returned even though the refresh was cancelled. - let s = Arc::clone(&strategy); - let result = tokio::time::timeout(Duration::from_secs(2), s.get_token()).await; - - assert!( - result.is_ok(), - "get_token() should not hang after cancelled non-blocking refresh" - ); - let result = result.unwrap(); - assert!( - result.is_ok(), - "expected Ok with still-usable token, got: {:?}", - result.unwrap_err() - ); - } - } -} - -/// Regression test for CIP-3159 (backported into this vendored crate by Proxy). -/// -/// A `get_token()` future cancelled in the post-HTTP, pre-install window of -/// [`AutoRefresh::refresh_non_blocking`] must NOT strand -/// `refresh_in_progress = true`. The pre-fix code called `guard.defuse()` -/// before re-acquiring the state lock, so a cancellation landing on that -/// `state.lock().await` left the flag set with no `notify_waiters()` — wedging -/// every later refresh. Once the cached token crossed its real expiry, callers -/// then hung forever in [`AutoRefresh::wait_for_in_flight_refresh`], surfacing -/// in Proxy as `ZeroKMS error: Request not authorized` ~15 min (the access-token -/// lifetime) after startup. -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod regression_cip_3159 { - use super::*; - use crate::access_key_refresher::AccessKeyRefresher; - use crate::SecretToken; - use std::sync::atomic::Ordering; - use std::sync::Arc; - use std::time::{Duration, SystemTime, UNIX_EPOCH}; - - /// `/api/authorise` handler that sleeps `delay` before returning a valid - /// access-key token response, giving the test a window to cancel in. - async fn delayed_authorise_handler( - axum::extract::State(delay): axum::extract::State, - ) -> axum::Json { - tokio::time::sleep(delay).await; - axum::Json(serde_json::json!({ - "accessToken": "refreshed-token", - "expiry": 3600 - })) - } - - async fn start_authorise_server(delay: Duration) -> url::Url { - let app = axum::Router::new() - .route( - "/api/authorise", - axum::routing::post(delayed_authorise_handler), - ) - .with_state(delay); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve(listener, app).await.unwrap(); - }); - url::Url::parse(&format!("http://{addr}")).unwrap() - } - - /// is_expired() == true (within the 90s leeway, so `get_token` refreshes), - /// but is_usable() == true for `secs_until_expiry` (so it takes the - /// non-blocking path). - fn expiring_but_usable_token(access: &str, secs_until_expiry: u64) -> Token { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - Token { - access_token: SecretToken::new(access), - token_type: "Bearer".to_string(), - expires_at: now + secs_until_expiry, - refresh_token: None, - region: None, - client_id: None, - device_instance_id: None, - } - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 4)] - async fn cancellation_in_relock_window_does_not_strand_refresh() { - let http_delay = Duration::from_millis(400); - let base_url = start_authorise_server(http_delay).await; - - let strategy = Arc::new(AutoRefresh::with_token( - AccessKeyRefresher::new( - SecretToken::new("CSAKtestKeyId.testKeySecret"), - base_url, - None, - ), - expiring_but_usable_token("old-usable", 2), - )); - - // Caller A drives the refresh: it locks state, sets the in-progress - // flag, drops the lock, then awaits the (slow) HTTP authorise call. - let a = Arc::clone(&strategy); - let handle = tokio::spawn(async move { a.get_token().await }); - - // Let A reach the HTTP await, then take the state lock so that when A's - // request completes it parks on its post-HTTP `state.lock().await` - // instead of installing the new token. - tokio::time::sleep(Duration::from_millis(100)).await; - let held = strategy.state.lock().await; - - // A's HTTP completes (~400ms) and blocks on the lock we hold. - tokio::time::sleep(http_delay + Duration::from_millis(200)).await; - assert!( - strategy.refresh_in_progress.load(Ordering::Acquire), - "precondition: a refresh should be in flight while caller A is parked", - ); - - // Cancel A precisely in the post-HTTP, pre-install window. - handle.abort(); - let _ = handle.await; - drop(held); - - // The CancelGuard's Drop must have cleared the flag on cancellation. - // Pre-fix, defuse() ran before the re-lock, so this stays `true`. - assert!( - !strategy.refresh_in_progress.load(Ordering::Acquire), - "refresh_in_progress stranded `true` after cancellation in the re-lock window (CIP-3159)", - ); - - // End-to-end: once the cached token crosses real expiry, a stranded flag - // would route the next caller into wait_for_in_flight_refresh and hang on - // a notify that never comes. With the fix, the caller re-authenticates. - tokio::time::sleep(Duration::from_millis(2100)).await; - let b = Arc::clone(&strategy); - let result = - tokio::time::timeout(Duration::from_secs(3), async move { b.get_token().await }).await; - assert!( - matches!(result, Ok(Ok(_))), - "get_token() hung or failed after cancellation — refresh wedged (CIP-3159): {result:?}", - ); - } -} diff --git a/vendor/stack-auth/src/auto_strategy.rs b/vendor/stack-auth/src/auto_strategy.rs deleted file mode 100644 index 55f92ef9..00000000 --- a/vendor/stack-auth/src/auto_strategy.rs +++ /dev/null @@ -1,389 +0,0 @@ -use cts_common::Crn; - -use crate::access_key_strategy::AccessKeyStrategy; -use crate::oauth_strategy::OAuthStrategy; -use stack_profile::ProfileStore; - -use crate::{AuthError, AuthStrategy, ServiceToken, Token}; - -/// An [`AuthStrategy`] that automatically detects available credentials -/// and delegates to the appropriate inner strategy. -/// -/// # Detection order -/// -/// 1. If the `CS_CLIENT_ACCESS_KEY` environment variable is set, an -/// [`AccessKeyStrategy`] is created. The region is extracted from the -/// `CS_WORKSPACE_CRN` environment variable. -/// 2. If a token store file exists at the default location -/// (`~/.cipherstash/auth.json`), an [`OAuthStrategy`] is created from it. -/// 3. Otherwise, [`AuthError::NotAuthenticated`] is returned. -/// -/// # Examples -/// -/// ```no_run -/// use stack_auth::{AuthStrategy, AutoStrategy}; -/// -/// # async fn run() -> Result<(), Box> { -/// // Auto-detect from env vars + profile store -/// let strategy = AutoStrategy::detect()?; -/// let token = (&strategy).get_token().await?; -/// println!("Authenticated! token={:?}", token); -/// # Ok(()) -/// # } -/// ``` -/// -/// ```no_run -/// use stack_auth::AutoStrategy; -/// -/// # fn run() -> Result<(), Box> { -/// // Provide explicit values with env/profile fallback -/// let strategy = AutoStrategy::builder() -/// .with_access_key("CSAK...") -/// .detect()?; -/// # Ok(()) -/// # } -/// ``` -pub enum AutoStrategy { - /// Authenticated via a static access key. - AccessKey(AccessKeyStrategy), - /// Authenticated via OAuth tokens persisted on disk. - OAuth(OAuthStrategy), -} - -impl AutoStrategy { - /// Create a builder for configuring credential resolution. - /// - /// The builder lets callers provide explicit values (access key, workspace CRN) - /// that take precedence over environment variables and the profile store. - /// - /// # Example - /// - /// ```no_run - /// use stack_auth::AutoStrategy; - /// use cts_common::Crn; - /// - /// # fn run() -> Result<(), Box> { - /// let crn: Crn = "crn:ap-southeast-2.aws:workspace-id".parse()?; - /// let strategy = AutoStrategy::builder() - /// .with_access_key("CSAKmyKeyId.myKeySecret") - /// .with_workspace_crn(crn) - /// .detect()?; - /// # Ok(()) - /// # } - /// ``` - pub fn builder() -> AutoStrategyBuilder { - AutoStrategyBuilder { - access_key: None, - crn: None, - } - } - - /// Detect credentials from environment variables and profile store. - /// - /// Equivalent to `AutoStrategy::builder().detect()`. - /// - /// Resolution order: - /// 1. `CS_CLIENT_ACCESS_KEY` env var → [`AccessKeyStrategy`] - /// 2. `~/.cipherstash/auth.json` → [`OAuthStrategy`] - /// 3. [`AuthError::NotAuthenticated`] - pub fn detect() -> Result { - Self::builder().detect() - } - - /// Core detection logic, separated for testability. - /// - /// Takes pre-resolved inputs rather than reading from the environment - /// or filesystem directly. - fn detect_inner( - access_key: Option, - crn: Option, - store: Option, - ) -> Result { - // 1. Access key from environment - if let Some(access_key) = access_key { - let region = crn - .map(|c| c.region) - .ok_or(AuthError::MissingWorkspaceCrn)?; - let key: crate::AccessKey = access_key.parse()?; - let strategy = AccessKeyStrategy::new(region, key)?; - return Ok(Self::AccessKey(strategy)); - } - - // 2. OAuth token from disk (in the current workspace directory) - if let Some(store) = store { - let has_token = store - .current_workspace_store() - .map(|ws| ws.exists_profile::()) - .unwrap_or(false); - if has_token { - let strategy = OAuthStrategy::with_profile(store).build()?; - return Ok(Self::OAuth(strategy)); - } - } - - // 3. No credentials found - Err(AuthError::NotAuthenticated) - } -} - -/// Builder for configuring credential resolution before calling [`detect()`](AutoStrategyBuilder::detect). -/// -/// Explicit values provided via builder methods take precedence over environment variables. -/// Environment variables take precedence over the profile store. -/// -/// # Example -/// -/// ```no_run -/// use stack_auth::AutoStrategy; -/// -/// # fn run() -> Result<(), Box> { -/// // Provide access key explicitly, region from CS_WORKSPACE_CRN env var -/// let strategy = AutoStrategy::builder() -/// .with_access_key("CSAKmyKeyId.myKeySecret") -/// .detect()?; -/// # Ok(()) -/// # } -/// ``` -pub struct AutoStrategyBuilder { - access_key: Option, - crn: Option, -} - -impl AutoStrategyBuilder { - /// Provide an explicit access key. Takes precedence over env vars. - pub fn with_access_key(mut self, access_key: impl Into) -> Self { - self.access_key = Some(access_key.into()); - self - } - - /// Provide an explicit workspace CRN. Takes precedence over env vars. - pub fn with_workspace_crn(mut self, crn: Crn) -> Self { - self.crn = Some(crn); - self - } - - /// Resolve the auth strategy. - /// - /// Resolution order: - /// 1. Explicit values provided via builder methods - /// 2. Environment variables (`CS_CLIENT_ACCESS_KEY`, `CS_WORKSPACE_CRN`) - /// 3. Profile store (`~/.cipherstash/auth.json` for OAuth) - /// 4. [`AuthError::NotAuthenticated`] - pub fn detect(self) -> Result { - // Merge explicit values with env vars (explicit wins) - let access_key = self - .access_key - .or_else(|| std::env::var("CS_CLIENT_ACCESS_KEY").ok()); - - let crn = match self.crn { - Some(crn) => Some(crn), - None => std::env::var("CS_WORKSPACE_CRN") - .ok() - .map(|s| s.parse::().map_err(AuthError::InvalidCrn)) - .transpose()?, - }; - - // Resolve errors (e.g. missing profile directory) are intentionally - // swallowed here so that env-var-only setups don't need a profile dir. - // If no credentials are found at all, NotAuthenticated is returned. - let store = ProfileStore::resolve(None).ok(); - - AutoStrategy::detect_inner(access_key, crn, store) - } -} - -impl AuthStrategy for &AutoStrategy { - async fn get_token(self) -> Result { - match self { - AutoStrategy::AccessKey(inner) => inner.get_token().await, - AutoStrategy::OAuth(inner) => inner.get_token().await, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::{SecretToken, Token}; - use std::time::{SystemTime, UNIX_EPOCH}; - - const VALID_CRN: &str = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY"; - - fn valid_crn() -> Crn { - VALID_CRN.parse().unwrap() - } - - fn make_oauth_token() -> Token { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - let claims = serde_json::json!({ - "iss": "https://cts.example.com/", - "sub": "CS|test-user", - "aud": "test-audience", - "iat": now, - "exp": now + 3600, - "workspace": "ZVATKW3VHMFG27DY", - "scope": "", - }); - - let key = jsonwebtoken::EncodingKey::from_secret(b"test-secret"); - let jwt = jsonwebtoken::encode(&jsonwebtoken::Header::default(), &claims, &key).unwrap(); - - Token { - access_token: SecretToken::new(jwt), - token_type: "Bearer".to_string(), - expires_at: now + 3600, - refresh_token: Some(SecretToken::new("test-refresh-token")), - region: Some("ap-southeast-2.aws".to_string()), - client_id: Some("test-client-id".to_string()), - device_instance_id: None, - } - } - - fn write_token_store(dir: &std::path::Path) -> ProfileStore { - let store = ProfileStore::new(dir); - store.init_workspace("ZVATKW3VHMFG27DY").unwrap(); - let ws_store = store.current_workspace_store().unwrap(); - ws_store.save_profile(&make_oauth_token()).unwrap(); - store - } - - mod detect_inner { - use super::*; - - #[test] - fn access_key_with_valid_crn() { - let result = AutoStrategy::detect_inner( - Some("CSAKtestKeyId.testKeySecret".into()), - Some(valid_crn()), - None, - ); - - assert!(result.is_ok()); - assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_))); - } - - #[test] - fn access_key_without_crn_returns_missing_workspace_crn() { - let result = - AutoStrategy::detect_inner(Some("CSAKtestKeyId.testKeySecret".into()), None, None); - - assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn))); - } - - #[test] - fn invalid_access_key_format_returns_invalid_access_key() { - let result = - AutoStrategy::detect_inner(Some("not-a-valid-key".into()), Some(valid_crn()), None); - - assert!(matches!(result, Err(AuthError::InvalidAccessKey(_)))); - } - - #[test] - fn oauth_store_with_valid_token() { - let dir = tempfile::tempdir().unwrap(); - let store = write_token_store(dir.path()); - - let result = AutoStrategy::detect_inner(None, None, Some(store)); - - assert!(result.is_ok()); - assert!(matches!(result.unwrap(), AutoStrategy::OAuth(_))); - } - - #[test] - fn oauth_store_without_token_file_returns_not_authenticated() { - let dir = tempfile::tempdir().unwrap(); - let store = ProfileStore::new(dir.path()); - - let result = AutoStrategy::detect_inner(None, None, Some(store)); - - assert!(matches!(result, Err(AuthError::NotAuthenticated))); - } - - #[test] - fn no_credentials_returns_not_authenticated() { - let result = AutoStrategy::detect_inner(None, None, None); - - assert!(matches!(result, Err(AuthError::NotAuthenticated))); - } - - #[test] - fn access_key_takes_priority_over_oauth_store() { - let dir = tempfile::tempdir().unwrap(); - let store = write_token_store(dir.path()); - - let result = AutoStrategy::detect_inner( - Some("CSAKtestKeyId.testKeySecret".into()), - Some(valid_crn()), - Some(store), - ); - - assert!(result.is_ok()); - assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_))); - } - } - - mod builder { - use super::*; - - #[test] - fn explicit_access_key_and_crn() { - let result = AutoStrategy::builder() - .with_access_key("CSAKtestKeyId.testKeySecret") - .with_workspace_crn(valid_crn()) - .detect(); - - assert!(result.is_ok()); - assert!(matches!(result.unwrap(), AutoStrategy::AccessKey(_))); - } - - #[test] - fn explicit_access_key_without_crn_and_no_env_returns_missing_workspace_crn() { - // Save and clear env to ensure no fallback - let saved_crn = std::env::var("CS_WORKSPACE_CRN").ok(); - std::env::remove_var("CS_WORKSPACE_CRN"); - - let result = AutoStrategy::builder() - .with_access_key("CSAKtestKeyId.testKeySecret") - .detect(); - - // Restore env - if let Some(val) = saved_crn { - std::env::set_var("CS_WORKSPACE_CRN", val); - } - - assert!(matches!(result, Err(AuthError::MissingWorkspaceCrn))); - } - - #[test] - fn invalid_crn_env_var_returns_invalid_crn() { - let saved_crn = std::env::var("CS_WORKSPACE_CRN").ok(); - std::env::set_var("CS_WORKSPACE_CRN", "not-a-crn"); - - let result = AutoStrategy::builder() - .with_access_key("CSAKtestKeyId.testKeySecret") - .detect(); - - // Restore env - match saved_crn { - Some(val) => std::env::set_var("CS_WORKSPACE_CRN", val), - None => std::env::remove_var("CS_WORKSPACE_CRN"), - } - - assert!(matches!(result, Err(AuthError::InvalidCrn(_)))); - } - - #[test] - fn invalid_explicit_access_key_returns_invalid_access_key() { - let result = AutoStrategy::builder() - .with_access_key("not-a-valid-key") - .with_workspace_crn(valid_crn()) - .detect(); - - assert!(matches!(result, Err(AuthError::InvalidAccessKey(_)))); - } - } -} diff --git a/vendor/stack-auth/src/device_client.rs b/vendor/stack-auth/src/device_client.rs deleted file mode 100644 index b32c7047..00000000 --- a/vendor/stack-auth/src/device_client.rs +++ /dev/null @@ -1,318 +0,0 @@ -//! Post-login device client provisioning. -//! -//! After a device-code login, the caller must create a client in ZeroKMS and -//! persist the resulting secret key to disk. This module provides the -//! orchestration logic so that any consumer (not just the CLI) can perform -//! this step. - -use stack_profile::{DeviceIdentity, ProfileStore}; -use uuid::Uuid; -use zerokms_protocol::{CreateClientRequest, CreateClientResponse, ViturKeyMaterial, ViturRequest}; - -use crate::{ensure_trailing_slash, http_client, ServiceToken, Token}; - -fn user_agent() -> String { - format!( - "stack-auth/{} ({} {})", - env!("CARGO_PKG_VERSION"), - std::env::consts::OS, - std::env::consts::ARCH, - ) -} - -// --------------------------------------------------------------------------- -// Secret key file (output) -// --------------------------------------------------------------------------- - -const SECRET_KEY_FILENAME: &str = "secretkey.json"; -const SECRET_KEY_MODE: u32 = 0o600; - -/// The on-disk shape of `secretkey.json`. -/// -/// Must stay in sync with `cipherstash_client::zerokms::SecretKey` which -/// deserializes this file. If that type moves to a shared crate, replace -/// this with a re-export. -#[derive(serde::Serialize)] -struct SecretKeyFile { - client_id: Uuid, - client_key: ViturKeyMaterial, -} - -// --------------------------------------------------------------------------- -// Error type -// --------------------------------------------------------------------------- - -/// Errors that can occur during device client provisioning. -#[derive(Debug, thiserror::Error)] -pub enum DeviceClientError { - /// The profile store could not load or create required data. - #[error("Profile error: {0}")] - Profile(#[from] stack_profile::ProfileError), - - /// Authentication token could not be loaded or decoded. - #[error("Auth error: {0}")] - Auth(#[from] crate::AuthError), - - /// The HTTP request to ZeroKMS failed. - #[error("ZeroKMS request failed: {0}")] - Request(#[from] reqwest::Error), - - /// ZeroKMS returned a non-success, non-conflict status. - #[error("ZeroKMS returned {status}: {body}")] - Server { status: u16, body: String }, - - /// Failed to construct the ZeroKMS endpoint URL. - #[error("Invalid ZeroKMS URL: {0}")] - InvalidUrl(#[from] url::ParseError), -} - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/// Provision a device client after login. -/// -/// Loads the auth token and device identity from disk, creates a client in -/// ZeroKMS (on the workspace's default keyset), and persists the resulting -/// secret key to the profile store. -/// -/// If the secret key already exists on disk, or the server returns 409 -/// (conflict), this is a no-op. -pub async fn bind_client_device(store: &ProfileStore) -> Result<(), DeviceClientError> { - let ws_store = store.current_workspace_store()?; - - if ws_store.exists(SECRET_KEY_FILENAME) { - tracing::debug!("secret key already exists, skipping provisioning"); - return Ok(()); - } - - let token: Token = ws_store.load_profile()?; - let service_token = ServiceToken::new(token.access_token().clone()); - let zerokms_url = ensure_trailing_slash(service_token.zerokms_url()?); - - // DeviceIdentity is NOT workspace-scoped, so this reads from the root. - let identity = DeviceIdentity::load_or_create(store)?; - - let request = CreateClientRequest { - keyset_id: None, - name: (&identity.device_name).into(), - description: (&identity.device_name).into(), - }; - - let url = zerokms_url.join(CreateClientRequest::ENDPOINT)?; - - let response = http_client() - .post(url) - .header(reqwest::header::USER_AGENT, user_agent()) - .bearer_auth(service_token.as_str()) - .json(&request) - .send() - .await?; - - let status = response.status(); - - if status == reqwest::StatusCode::CONFLICT { - // Another client was already provisioned server-side. - tracing::debug!("device client already exists, skipping"); - return Ok(()); - } - - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(DeviceClientError::Server { - status: status.as_u16(), - body, - }); - } - - let created: CreateClientResponse = response.json().await?; - - let secret_key = SecretKeyFile { - client_id: created.id, - client_key: created.client_key, - }; - - ws_store.save_with_mode(SECRET_KEY_FILENAME, &secret_key, SECRET_KEY_MODE)?; - - Ok(()) -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use crate::SecretToken; - use mocktail::prelude::*; - use tempfile::TempDir; - - fn make_test_jwt(zerokms_url: impl std::fmt::Display) -> String { - use jsonwebtoken::{encode, EncodingKey, Header}; - use std::time::{SystemTime, UNIX_EPOCH}; - - let zerokms_url = zerokms_url.to_string(); - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - let claims = serde_json::json!({ - "iss": "https://cts.example.com/", - "sub": "CS|test-user", - "aud": "legacy-aud-value", - "iat": now, - "exp": now + 3600, - "workspace": "ZVATKW3VHMFG27DY", - "scope": "", - "services": { - "zerokms": zerokms_url, - }, - }); - - encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(b"test-secret"), - ) - .unwrap() - } - - const TEST_WORKSPACE_ID: &str = "ZVATKW3VHMFG27DY"; - - fn save_test_token(store: &ProfileStore, access_token: &str) { - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - let token = Token { - access_token: SecretToken::new(access_token), - refresh_token: None, - token_type: "Bearer".into(), - expires_at: now + 3600, - region: None, - client_id: None, - device_instance_id: None, - }; - store.init_workspace(TEST_WORKSPACE_ID).unwrap(); - let ws_store = store.current_workspace_store().unwrap(); - ws_store.save_profile(&token).unwrap(); - } - - fn client_response_json() -> serde_json::Value { - serde_json::json!({ - "id": "00000000-0000-0000-0000-000000000001", - "dataset_id": "00000000-0000-0000-0000-000000000099", - "name": "test-device", - "description": "test-device", - "client_key": "dGVzdC1rZXktbWF0ZXJpYWw=" - }) - } - - async fn start_server(mocks: MockSet) -> MockServer { - let server = MockServer::new_http("device-client-test").with_mocks(mocks); - server.start().await.unwrap(); - server - } - - #[tokio::test] - async fn provisions_and_saves_secret_key() { - let dir = TempDir::new().unwrap(); - let store = ProfileStore::new(dir.path()); - - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/create-client"); - then.json(client_response_json()); - }); - let server = start_server(mocks).await; - - let jwt = make_test_jwt(server.url("/")); - save_test_token(&store, &jwt); - - bind_client_device(&store).await.unwrap(); - - let ws_store = store.workspace_store(TEST_WORKSPACE_ID).unwrap(); - let saved: serde_json::Value = ws_store.load(SECRET_KEY_FILENAME).unwrap(); - assert_eq!(saved["client_id"], "00000000-0000-0000-0000-000000000001"); - assert_eq!(saved["client_key"], "dGVzdC1rZXktbWF0ZXJpYWw="); - } - - #[tokio::test] - async fn skips_when_secret_key_exists() { - let dir = TempDir::new().unwrap(); - let store = ProfileStore::new(dir.path()); - store.init_workspace(TEST_WORKSPACE_ID).unwrap(); - - // Pre-populate secretkey.json in the workspace directory - let ws_store = store.workspace_store(TEST_WORKSPACE_ID).unwrap(); - ws_store - .save_with_mode( - SECRET_KEY_FILENAME, - &serde_json::json!({"client_id": "old", "client_key": "old"}), - SECRET_KEY_MODE, - ) - .unwrap(); - - // No mock server needed — the HTTP call should never happen. - bind_client_device(&store).await.unwrap(); - - let saved: serde_json::Value = ws_store.load(SECRET_KEY_FILENAME).unwrap(); - assert_eq!( - saved["client_id"], "old", - "should not overwrite existing key" - ); - } - - #[tokio::test] - async fn no_op_on_conflict() { - let dir = TempDir::new().unwrap(); - let store = ProfileStore::new(dir.path()); - - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/create-client"); - then.status(reqwest::StatusCode::CONFLICT) - .json(serde_json::json!({"error": "conflict"})); - }); - let server = start_server(mocks).await; - - let jwt = make_test_jwt(server.url("/")); - save_test_token(&store, &jwt); - - bind_client_device(&store).await.unwrap(); - - let ws_store = store.workspace_store(TEST_WORKSPACE_ID).unwrap(); - assert!( - !ws_store.exists(SECRET_KEY_FILENAME), - "should not write secret key on conflict" - ); - } - - #[tokio::test] - async fn returns_error_on_server_failure() { - let dir = TempDir::new().unwrap(); - let store = ProfileStore::new(dir.path()); - - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/create-client"); - then.status(reqwest::StatusCode::INTERNAL_SERVER_ERROR) - .json(serde_json::json!({"error": "internal error"})); - }); - let server = start_server(mocks).await; - - let jwt = make_test_jwt(server.url("/")); - save_test_token(&store, &jwt); - - let err = bind_client_device(&store).await.unwrap_err(); - assert!( - matches!(err, DeviceClientError::Server { status: 500, .. }), - "expected Server error, got: {err:?}" - ); - } -} diff --git a/vendor/stack-auth/src/device_code/mod.rs b/vendor/stack-auth/src/device_code/mod.rs deleted file mode 100644 index d4daf3a7..00000000 --- a/vendor/stack-auth/src/device_code/mod.rs +++ /dev/null @@ -1,375 +0,0 @@ -mod protocol; - -use cts_common::{CtsServiceDiscovery, Region, ServiceDiscovery}; -use url::Url; - -use std::time::{SystemTime, UNIX_EPOCH}; - -use std::path::PathBuf; - -use stack_profile::ProfileStore; - -use crate::{ensure_trailing_slash, http_client, AuthError, DeviceIdentity, Token}; -use protocol::{ - DeviceCode, DeviceCodeRequest, DeviceCodeResponse, ErrorResponse, TokenRequest, TokenResponse, -}; - -#[cfg(test)] -mod tests; - -/// Authenticates with CipherStash using the -/// [device code flow (RFC 8628)](https://datatracker.ietf.org/doc/html/rfc8628). -/// -/// This is the primary entry point for CLI and browserless authentication. -/// Create a strategy with [`DeviceCodeStrategy::new`], then call -/// [`begin`](DeviceCodeStrategy::begin) to start the flow. -/// -/// # Example -/// -/// ``` -/// use stack_auth::DeviceCodeStrategy; -/// use cts_common::Region; -/// -/// let region = Region::aws("ap-southeast-2").unwrap(); -/// let strategy = DeviceCodeStrategy::new(region, "my-client-id").unwrap(); -/// ``` -pub struct DeviceCodeStrategy { - region: Region, - base_url: Url, - client_id: String, - profile_dir: Option, - device_identity: Option, -} - -impl DeviceCodeStrategy { - /// Create a new strategy for the given CipherStash region and OAuth client ID. - /// - /// The auth endpoint is resolved automatically via service discovery. - /// - /// # Example - /// - /// ``` - /// use stack_auth::DeviceCodeStrategy; - /// use cts_common::Region; - /// - /// let strategy = DeviceCodeStrategy::new( - /// Region::aws("ap-southeast-2").unwrap(), - /// "my-client-id", - /// ).unwrap(); - /// ``` - pub fn new(region: Region, client_id: impl Into) -> Result { - Self::builder(region, client_id).build() - } - - /// Return a builder for configuring a `DeviceCodeStrategy` before construction. - pub fn builder(region: Region, client_id: impl Into) -> DeviceCodeStrategyBuilder { - DeviceCodeStrategyBuilder { - region, - client_id: client_id.into(), - base_url_override: None, - profile_dir: None, - device_identity: None, - } - } - - /// Start the device code flow. - /// - /// Requests a device code from the CipherStash auth server and returns a - /// [`PendingDeviceCode`] with the user-facing codes and URIs. Show these - /// to the user, then call [`PendingDeviceCode::poll_for_token`] to wait - /// for authorization. - /// - /// # Errors - /// - /// Returns [`AuthError::InvalidClient`] if the client ID is not recognized, - /// or [`AuthError::Request`] if the server is unreachable. - pub async fn begin(&self) -> Result { - let client = http_client(); - - let code_url = self.base_url.join("oauth/device/code")?; - - tracing::debug!(url = %code_url, client_id = %self.client_id, "requesting device code"); - - let device_instance_id = self - .device_identity - .as_ref() - .map(|d| d.device_instance_id.to_string()); - - let code_resp = client - .post(code_url) - .form(&DeviceCodeRequest { - client_id: &self.client_id, - device_instance_id: device_instance_id.as_deref(), - device_name: self - .device_identity - .as_ref() - .map(|d| d.device_name.as_str()), - }) - .send() - .await?; - - if !code_resp.status().is_success() { - let err: ErrorResponse = code_resp.json().await?; - tracing::debug!(error = %err.error, "device code request failed"); - return Err(match err.error.as_str() { - "invalid_client" => AuthError::InvalidClient, - _ => AuthError::Server(err.error_description), - }); - } - - let code: DeviceCodeResponse = code_resp.json().await?; - - let token_url = self.base_url.join("oauth/device/token")?; - - tracing::debug!( - user_code = %code.user_code, - expires_in = code.expires_in, - "device code received" - ); - - Ok(PendingDeviceCode { - token_url, - region: self.region, - client_id: self.client_id.clone(), - device_code: code.device_code, - user_code: code.user_code, - verification_uri: code.verification_uri, - verification_uri_complete: code.verification_uri_complete, - expires_in: code.expires_in, - profile_dir: self.profile_dir.clone(), - device_identity: self.device_identity.clone(), - }) - } -} - -/// Builder for [`DeviceCodeStrategy`]. -/// -/// Created via [`DeviceCodeStrategy::builder`]. -pub struct DeviceCodeStrategyBuilder { - region: Region, - client_id: String, - base_url_override: Option, - profile_dir: Option, - device_identity: Option, -} - -impl DeviceCodeStrategyBuilder { - /// Override the base URL resolved by service discovery. - /// - /// Useful for pointing at a local or mock CTS instance during testing. - #[cfg(any(test, feature = "test-utils"))] - pub fn base_url(mut self, url: Url) -> Self { - self.base_url_override = Some(url); - self - } - - /// Override the profile directory used to persist the token. - /// - /// By default tokens are saved to `~/.cipherstash/auth.json`. Use this in - /// tests to redirect writes to a temporary directory. - #[cfg(any(test, feature = "test-utils"))] - pub fn profile_dir(mut self, dir: impl Into) -> Self { - self.profile_dir = Some(dir.into()); - self - } - - /// Set the device identity for this strategy. - /// - /// When set, the device instance ID and name are sent to the auth server - /// during the device code flow and persisted in the token. - pub fn device_identity(mut self, identity: DeviceIdentity) -> Self { - self.device_identity = Some(identity); - self - } - - /// Build the [`DeviceCodeStrategy`]. - /// - /// Resolves the base URL via service discovery unless overridden with - /// `base_url` (available when the `test-utils` feature is enabled). - pub fn build(self) -> Result { - let base_url = match self.base_url_override { - Some(url) => url, - None => crate::cts_base_url_from_env()? - .unwrap_or(CtsServiceDiscovery::endpoint(self.region)?), - }; - Ok(DeviceCodeStrategy { - region: self.region, - base_url: ensure_trailing_slash(base_url), - client_id: self.client_id, - profile_dir: self.profile_dir, - device_identity: self.device_identity, - }) - } -} - -/// A device code flow that is waiting for the user to authorize. -/// -/// Returned by [`DeviceCodeStrategy::begin`]. Display the -/// [`user_code`](Self::user_code) and -/// [`verification_uri_complete`](Self::verification_uri_complete) to the user -/// (or call [`open_in_browser`](Self::open_in_browser)), then call -/// [`poll_for_token`](Self::poll_for_token) to wait for authorization. -/// -/// # Example -/// -/// ```no_run -/// # use stack_auth::DeviceCodeStrategy; -/// # use cts_common::Region; -/// # async fn run() -> Result<(), Box> { -/// # let strategy = DeviceCodeStrategy::new(Region::aws("ap-southeast-2")?, "cli")?; -/// let pending = strategy.begin().await?; -/// -/// println!("Go to: {}", pending.verification_uri_complete()); -/// println!("Enter code: {}", pending.user_code()); -/// -/// let token = pending.poll_for_token().await?; -/// # Ok(()) -/// # } -/// ``` -#[derive(Debug)] -pub struct PendingDeviceCode { - token_url: Url, - region: Region, - client_id: String, - device_code: DeviceCode, - /// The short code the user must enter to authorize this device. - user_code: String, - /// The base verification URI (without the user code embedded). - verification_uri: String, - /// The full verification URI with the user code pre-filled. - verification_uri_complete: String, - /// How many seconds the device code remains valid. - expires_in: u64, - /// Profile directory override. Falls back to `~/.cipherstash`. - profile_dir: Option, - /// Device identity to associate with the token. - device_identity: Option, -} - -impl PendingDeviceCode { - /// The short code the user must enter to authorize this device. - pub fn user_code(&self) -> &str { - &self.user_code - } - - /// The base verification URI (without the user code embedded). - pub fn verification_uri(&self) -> &str { - &self.verification_uri - } - - /// The full verification URI with the user code pre-filled. - pub fn verification_uri_complete(&self) -> &str { - &self.verification_uri_complete - } - - /// How many seconds the device code remains valid. - pub fn expires_in(&self) -> u64 { - self.expires_in - } - - /// Open the verification URI in the user's default browser. - /// - /// Returns `true` if the browser was opened successfully. - pub fn open_in_browser(&self) -> bool { - open::that(&self.verification_uri_complete).is_ok() - } - - /// Poll the auth server until the user authorizes (or the code expires). - /// - /// This method consumes `self` and blocks asynchronously, polling at a - /// server-controlled interval (starting at 5 seconds). It returns a - /// [`Token`] on success. - /// - /// # Errors - /// - /// - [`AuthError::AccessDenied`] — the user rejected the request. - /// - [`AuthError::TokenExpired`] — the device code expired before the user - /// authorized. - /// - [`AuthError::Request`] — a network error occurred while polling. - pub async fn poll_for_token(self) -> Result { - let client = http_client(); - let mut interval = tokio::time::Duration::from_secs(5); - let deadline = - tokio::time::Instant::now() + tokio::time::Duration::from_secs(self.expires_in); - - tracing::debug!( - url = %self.token_url, - expires_in = self.expires_in, - "polling for token" - ); - - loop { - if tokio::time::Instant::now() >= deadline { - tracing::debug!("device code expired while polling"); - return Err(AuthError::TokenExpired); - } - - let resp = client - .post(self.token_url.clone()) - .form(&TokenRequest { - client_id: &self.client_id, - device_code: &self.device_code, - grant_type: "urn:ietf:params:oauth:grant-type:device_code", - }) - .send() - .await?; - - if resp.status().is_success() { - tracing::debug!("token received"); - let token_resp: TokenResponse = resp.json().await?; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let mut token = Token { - access_token: token_resp.access_token, - token_type: token_resp.token_type, - expires_at: now + token_resp.expires_in, - refresh_token: token_resp.refresh_token, - region: None, - client_id: None, - device_instance_id: None, - }; - token.set_region(self.region.identifier()); - token.set_client_id(&self.client_id); - if let Some(ref identity) = self.device_identity { - token.set_device_instance_id(identity.device_instance_id.to_string()); - } - - let store = match &self.profile_dir { - Some(dir) => ProfileStore::new(dir), - None => ProfileStore::resolve(None)?, - }; - let workspace_id = token.workspace_id()?; - store.init_workspace(workspace_id.as_str())?; - store - .workspace_store(workspace_id.as_str())? - .save_profile(&token)?; - tracing::debug!( - workspace = workspace_id.as_str(), - "token saved to workspace directory" - ); - - return Ok(token); - } - - let err: ErrorResponse = resp.json().await?; - match err.error.as_str() { - "authorization_pending" => { - tracing::debug!("authorization pending, retrying"); - } - "slow_down" => { - interval += tokio::time::Duration::from_secs(5); - tracing::debug!(interval_secs = interval.as_secs(), "slowing down"); - } - "expired_token" => return Err(AuthError::TokenExpired), - "access_denied" => return Err(AuthError::AccessDenied), - "invalid_grant" => return Err(AuthError::InvalidGrant), - "invalid_client" => return Err(AuthError::InvalidClient), - _ => return Err(AuthError::Server(err.error_description)), - } - - tokio::time::sleep(interval).await; - } - } -} diff --git a/vendor/stack-auth/src/device_code/protocol.rs b/vendor/stack-auth/src/device_code/protocol.rs deleted file mode 100644 index dff03322..00000000 --- a/vendor/stack-auth/src/device_code/protocol.rs +++ /dev/null @@ -1,52 +0,0 @@ -use serde::{Deserialize, Serialize}; -use vitaminc::protected::OpaqueDebug; -use zeroize::ZeroizeOnDrop; - -use crate::SecretToken; - -/// A device code issued by the auth server, exchanged for an access token -/// once the user authorizes. -#[derive(OpaqueDebug, ZeroizeOnDrop, Deserialize, Serialize)] -#[serde(transparent)] -pub(super) struct DeviceCode(String); - -#[derive(Deserialize)] -pub(super) struct DeviceCodeResponse { - pub device_code: DeviceCode, - pub user_code: String, - pub verification_uri: String, - pub verification_uri_complete: String, - pub expires_in: u64, -} - -#[derive(Deserialize)] -pub(super) struct TokenResponse { - pub access_token: SecretToken, - pub token_type: String, - pub expires_in: u64, - #[serde(default)] - pub refresh_token: Option, -} - -#[derive(Deserialize)] -pub(super) struct ErrorResponse { - pub error: String, - #[serde(default)] - pub error_description: String, -} - -#[derive(Serialize)] -pub(super) struct DeviceCodeRequest<'a> { - pub client_id: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - pub device_instance_id: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - pub device_name: Option<&'a str>, -} - -#[derive(Serialize)] -pub(super) struct TokenRequest<'a> { - pub client_id: &'a str, - pub device_code: &'a DeviceCode, - pub grant_type: &'a str, -} diff --git a/vendor/stack-auth/src/device_code/tests.rs b/vendor/stack-auth/src/device_code/tests.rs deleted file mode 100644 index 357c5e31..00000000 --- a/vendor/stack-auth/src/device_code/tests.rs +++ /dev/null @@ -1,423 +0,0 @@ -use super::*; -use cts_common::Region; -use mocktail::prelude::*; -use tempfile::TempDir; - -fn device_code_json() -> serde_json::Value { - serde_json::json!({ - "device_code": "test_device_code", - "user_code": "ABCD-EFGH", - "verification_uri": "http://example.com/activate", - "verification_uri_complete": "http://example.com/activate?user_code=ABCD-EFGH", - "expires_in": 900 - }) -} - -/// Build a valid JWT access token containing a workspace claim. -fn test_access_token() -> String { - use jsonwebtoken::{encode, EncodingKey, Header}; - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - let claims = serde_json::json!({ - "iss": "https://cts.example.com/", - "sub": "CS|test-user", - "aud": "test-audience", - "iat": now, - "exp": now + 3600, - "workspace": "ZVATKW3VHMFG27DY", - "scope": "", - }); - - encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(b"test-secret"), - ) - .unwrap() -} - -fn token_json() -> serde_json::Value { - serde_json::json!({ - "access_token": test_access_token(), - "token_type": "Bearer", - "expires_in": 3600 - }) -} - -fn error_json(error: &str) -> serde_json::Value { - serde_json::json!({ - "error": error, - "error_description": format!("{error} occurred") - }) -} - -fn mock_code_endpoint(mocks: &mut MockSet) { - mocks.mock(|when, then| { - when.post().path("/oauth/device/code"); - then.json(device_code_json()); - }); -} - -async fn start_server(mocks: MockSet) -> MockServer { - let server = MockServer::new_http("stack-auth-test").with_mocks(mocks); - server.start().await.unwrap(); - server -} - -fn strategy_for(server: &MockServer, dir: &TempDir) -> DeviceCodeStrategy { - DeviceCodeStrategy::builder(Region::aws("ap-southeast-2").unwrap(), "cli") - .base_url(server.url("")) - .profile_dir(dir.path()) - .build() - .unwrap() -} - -// ---- begin() tests ---- - -#[tokio::test] -async fn test_begin_returns_pending_device_code() { - let dir = TempDir::new().unwrap(); - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - let server = start_server(mocks).await; - - let pending = strategy_for(&server, &dir).begin().await.unwrap(); - - assert_eq!(pending.user_code(), "ABCD-EFGH"); - assert_eq!(pending.verification_uri(), "http://example.com/activate"); - assert_eq!( - pending.verification_uri_complete(), - "http://example.com/activate?user_code=ABCD-EFGH" - ); - assert_eq!(pending.expires_in(), 900); -} - -#[tokio::test] -async fn test_begin_invalid_client() { - let dir = TempDir::new().unwrap(); - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/device/code"); - then.bad_request().json(error_json("invalid_client")); - }); - let server = start_server(mocks).await; - - let err = strategy_for(&server, &dir).begin().await.unwrap_err(); - - assert!(matches!(err, AuthError::InvalidClient)); -} - -#[tokio::test] -async fn test_begin_server_error() { - let dir = TempDir::new().unwrap(); - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/device/code"); - then.bad_request().json(error_json("server_error")); - }); - let server = start_server(mocks).await; - - let err = strategy_for(&server, &dir).begin().await.unwrap_err(); - - assert!(matches!(&err, AuthError::Server(desc) if desc == "server_error occurred")); -} - -// ---- poll_for_token() tests ---- - -/// Helper: calls begin() against a server that already has the code mock, -/// then returns the PendingDeviceCode ready for polling. -async fn begin_pending(server: &MockServer, dir: &TempDir) -> PendingDeviceCode { - strategy_for(server, dir).begin().await.unwrap() -} - -#[tokio::test(start_paused = true)] -async fn test_poll_for_token_success() { - let dir = TempDir::new().unwrap(); - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.json(token_json()); - }); - let server = start_server(mocks).await; - - let token = begin_pending(&server, &dir) - .await - .poll_for_token() - .await - .unwrap(); - - assert_eq!(token.token_type(), "Bearer"); - assert!(!token.is_expired()); - assert!((3598..=3600).contains(&token.expires_in())); - assert_eq!( - token.workspace_id().unwrap().as_str(), - "ZVATKW3VHMFG27DY", - "workspace ID should be extracted from the JWT" - ); - - // Verify the token was persisted to the workspace directory - let store = ProfileStore::new(dir.path()); - assert_eq!( - store.current_workspace().unwrap(), - "ZVATKW3VHMFG27DY", - "current workspace should be set after poll_for_token" - ); -} - -#[tokio::test(start_paused = true)] -async fn test_poll_for_token_access_denied() { - let dir = TempDir::new().unwrap(); - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.bad_request().json(error_json("access_denied")); - }); - let server = start_server(mocks).await; - - let err = begin_pending(&server, &dir) - .await - .poll_for_token() - .await - .unwrap_err(); - - assert!(matches!(err, AuthError::AccessDenied)); -} - -#[tokio::test(start_paused = true)] -async fn test_poll_for_token_expired_token() { - let dir = TempDir::new().unwrap(); - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.bad_request().json(error_json("expired_token")); - }); - let server = start_server(mocks).await; - - let err = begin_pending(&server, &dir) - .await - .poll_for_token() - .await - .unwrap_err(); - - assert!(matches!(err, AuthError::TokenExpired)); -} - -#[tokio::test(start_paused = true)] -async fn test_poll_for_token_invalid_grant() { - let dir = TempDir::new().unwrap(); - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.bad_request().json(error_json("invalid_grant")); - }); - let server = start_server(mocks).await; - - let err = begin_pending(&server, &dir) - .await - .poll_for_token() - .await - .unwrap_err(); - - assert!(matches!(err, AuthError::InvalidGrant)); -} - -#[tokio::test(start_paused = true)] -async fn test_poll_for_token_invalid_client() { - let dir = TempDir::new().unwrap(); - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.bad_request().json(error_json("invalid_client")); - }); - let server = start_server(mocks).await; - - let err = begin_pending(&server, &dir) - .await - .poll_for_token() - .await - .unwrap_err(); - - assert!(matches!(err, AuthError::InvalidClient)); -} - -#[tokio::test(start_paused = true)] -async fn test_poll_for_token_unknown_error() { - let dir = TempDir::new().unwrap(); - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.bad_request().json(error_json("something_unexpected")); - }); - let server = start_server(mocks).await; - - let err = begin_pending(&server, &dir) - .await - .poll_for_token() - .await - .unwrap_err(); - - assert!(matches!(&err, AuthError::Server(desc) if desc == "something_unexpected occurred")); -} - -#[tokio::test(start_paused = true)] -async fn test_poll_for_token_authorization_pending_then_success() { - let dir = TempDir::new().unwrap(); - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.bad_request().json(error_json("authorization_pending")); - }); - let server = start_server(mocks).await; - let pending = begin_pending(&server, &dir).await; - - // Use tokio::join! so the swap future can borrow server.mocks() directly - // (the shared RwLock) rather than cloning the MockSet. - // First poll at T=5s returns "authorization_pending". - // At T=6s the mock is swapped. Second poll at T=10s returns success. - let (result, _) = tokio::join!(pending.poll_for_token(), async { - tokio::time::sleep(tokio::time::Duration::from_secs(6)).await; - server.mocks().clear(); - server.mocks().mock(|when, then| { - when.post().path("/oauth/device/token"); - then.json(token_json()); - }); - }); - - let token = result.unwrap(); - assert_eq!(token.token_type(), "Bearer"); - assert!( - token.workspace_id().is_ok(), - "token should contain a valid workspace claim" - ); -} - -#[tokio::test(start_paused = true)] -async fn test_poll_for_token_slow_down_then_success() { - let dir = TempDir::new().unwrap(); - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.bad_request().json(error_json("slow_down")); - }); - let server = start_server(mocks).await; - let pending = begin_pending(&server, &dir).await; - - // First poll returns "slow_down", interval increases to 10s. - // Swap the mock to return success before the second poll. - let (result, _) = tokio::join!(pending.poll_for_token(), async { - tokio::time::sleep(tokio::time::Duration::from_secs(6)).await; - server.mocks().clear(); - server.mocks().mock(|when, then| { - when.post().path("/oauth/device/token"); - then.json(token_json()); - }); - }); - - let token = result.unwrap(); - assert_eq!(token.token_type(), "Bearer"); - assert!( - token.workspace_id().is_ok(), - "token should contain a valid workspace claim" - ); -} - -/// Proves that `slow_down` increases the poll interval: with a short -/// `expires_in`, the increased interval pushes the next poll past the -/// deadline, causing a `TokenExpired` error. -#[tokio::test(start_paused = true)] -async fn test_poll_for_token_slow_down_increases_interval() { - let dir = TempDir::new().unwrap(); - let mut mocks = MockSet::new(); - // expires_in = 12: without slow_down, second poll at T=10 is within - // the deadline. With slow_down, interval becomes 10s, so second poll - // at T=15 exceeds the 12s deadline. - mocks.mock(|when, then| { - when.post().path("/oauth/device/code"); - then.json(serde_json::json!({ - "device_code": "test_device_code", - "user_code": "ABCD-EFGH", - "verification_uri": "http://example.com/activate", - "verification_uri_complete": "http://example.com/activate?user_code=ABCD-EFGH", - "expires_in": 12 - })); - }); - mocks.mock(|when, then| { - when.post().path("/oauth/device/token"); - then.bad_request().json(error_json("slow_down")); - }); - let server = start_server(mocks).await; - let pending = begin_pending(&server, &dir).await; - - let err = pending.poll_for_token().await.unwrap_err(); - - assert!(matches!(err, AuthError::TokenExpired)); -} - -// ---- ensure_trailing_slash / URL join tests ---- - -#[test] -fn test_ensure_trailing_slash_adds_slash() { - let url = Url::parse("http://localhost:3001").unwrap(); - let result = ensure_trailing_slash(url); - assert_eq!(result.as_str(), "http://localhost:3001/"); -} - -#[test] -fn test_ensure_trailing_slash_preserves_existing() { - let url = Url::parse("http://localhost:3001/").unwrap(); - let result = ensure_trailing_slash(url); - assert_eq!(result.as_str(), "http://localhost:3001/"); -} - -#[test] -fn test_ensure_trailing_slash_with_path() { - let url = Url::parse("http://localhost:3001/api/v1").unwrap(); - let result = ensure_trailing_slash(url); - assert_eq!(result.as_str(), "http://localhost:3001/api/v1/"); -} - -#[test] -fn test_relative_join_preserves_base_path() { - let base = ensure_trailing_slash(Url::parse("http://localhost:3001/api/v1").unwrap()); - let joined = base.join("oauth/device/code").unwrap(); - assert_eq!( - joined.as_str(), - "http://localhost:3001/api/v1/oauth/device/code" - ); -} - -#[test] -fn test_relative_join_on_root_url() { - let base = ensure_trailing_slash(Url::parse("http://localhost:3001").unwrap()); - let joined = base.join("oauth/device/code").unwrap(); - assert_eq!(joined.as_str(), "http://localhost:3001/oauth/device/code"); -} - -#[tokio::test] -async fn test_pending_device_code_debug_does_not_leak() { - let dir = TempDir::new().unwrap(); - let mut mocks = MockSet::new(); - mock_code_endpoint(&mut mocks); - let server = start_server(mocks).await; - - let pending = begin_pending(&server, &dir).await; - let debug = format!("{:?}", pending); - - assert!( - !debug.contains("test_device_code"), - "PendingDeviceCode Debug should not contain the device code, got: {debug}" - ); -} diff --git a/vendor/stack-auth/src/lib.rs b/vendor/stack-auth/src/lib.rs deleted file mode 100644 index 7dd91d2e..00000000 --- a/vendor/stack-auth/src/lib.rs +++ /dev/null @@ -1,273 +0,0 @@ -#![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")] -#![doc = include_str!("../README.md")] -// Security lints -#![deny(unsafe_code)] -#![warn(clippy::unwrap_used)] -#![warn(clippy::expect_used)] -#![warn(clippy::panic)] -// Prevent mem::forget from bypassing ZeroizeOnDrop -#![warn(clippy::mem_forget)] -// Prevent accidental data leaks via output -#![warn(clippy::print_stdout)] -#![warn(clippy::print_stderr)] -#![warn(clippy::dbg_macro)] -// Code quality -#![warn(unreachable_pub)] -#![warn(unused_results)] -#![warn(clippy::todo)] -#![warn(clippy::unimplemented)] -// Relax in tests -#![cfg_attr(test, allow(clippy::unwrap_used))] -#![cfg_attr(test, allow(clippy::expect_used))] -#![cfg_attr(test, allow(clippy::panic))] -#![cfg_attr(test, allow(unused_results))] - -use std::convert::Infallible; -use std::future::Future; -#[cfg(not(any(test, feature = "test-utils")))] -use std::time::Duration; - -use vitaminc::protected::OpaqueDebug; -use zeroize::ZeroizeOnDrop; - -mod access_key; -mod access_key_refresher; -mod access_key_strategy; -mod auto_refresh; -mod auto_strategy; -mod device_client; -mod device_code; -mod oauth_refresher; -mod oauth_strategy; -mod refresher; -mod service_token; -mod token; - -#[cfg(any(test, feature = "test-utils"))] -mod static_token_strategy; - -pub use access_key::{AccessKey, InvalidAccessKey}; -pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder}; -pub use auto_strategy::{AutoStrategy, AutoStrategyBuilder}; -pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode}; -pub use oauth_strategy::{OAuthStrategy, OAuthStrategyBuilder}; -pub use service_token::ServiceToken; -#[cfg(any(test, feature = "test-utils"))] -pub use static_token_strategy::StaticTokenStrategy; -pub use token::Token; - -pub use device_client::{bind_client_device, DeviceClientError}; - -// Re-exports from stack-profile for backward compatibility. -pub use stack_profile::DeviceIdentity; - -/// A strategy for obtaining access tokens. -/// -/// Implementations handle all details of authentication, token caching, and -/// refresh. Callers just call [`get_token`](AuthStrategy::get_token) whenever -/// they need a valid token. -/// -/// The trait is designed to be implemented for `&T`, so that callers can use -/// shared references (e.g. `&OAuthStrategy`) without consuming the strategy. -/// -/// # Token refresh -/// -/// All strategies that cache tokens ([`AccessKeyStrategy`], [`OAuthStrategy`], -/// [`AutoStrategy`]) share the same internal refresh engine. Understanding the -/// refresh model helps predict how [`get_token`](AuthStrategy::get_token) -/// behaves under concurrent access. -/// -/// ## Expiry vs usability -/// -/// A token has two time thresholds: -/// -/// - **Expired** — the token is within **90 seconds** of its `expires_at` -/// timestamp. This triggers a preemptive refresh attempt. -/// - **Usable** — the token has **not yet reached** its `expires_at` timestamp. -/// A token can be "expired" (in the preemptive sense) but still "usable" -/// (the server will still accept it). -/// -/// ## Concurrent refresh strategies -/// -/// The gap between "expired" and "unusable" enables two refresh modes: -/// -/// 1. **Expiring but still usable** — The first caller triggers a background -/// refresh. Concurrent callers receive the current (still-valid) token -/// immediately without blocking. -/// 2. **Fully expired** — The first caller blocks while refreshing. Concurrent -/// callers wait until the refresh completes, then all receive the new token. -/// -/// Only one refresh runs at a time, regardless of how many callers request a -/// token concurrently. -/// -/// ## Flow diagram -/// -/// ```mermaid -/// flowchart TD -/// Start["get_token()"] --> Lock["Acquire lock"] -/// Lock --> Cached{Token cached?} -/// Cached -- No --> InitAuth["Authenticate -/// (lock held)"] -/// InitAuth -- OK --> ReturnNew["Return new token"] -/// InitAuth -- NotFound --> ErrNotFound["NotAuthenticated"] -/// InitAuth -- Err --> ErrAuth["Return error"] -/// Cached -- Yes --> CheckRefresh{Expired?} -/// -/// CheckRefresh -- "No (fresh)" --> ReturnOk["Return cached token"] -/// -/// CheckRefresh -- "Yes (needs refresh)" --> InProgress{Refresh in progress?} -/// InProgress -- Yes --> WaitOrReturn["Return token if usable, -/// else wait for refresh"] -/// WaitOrReturn -- OK --> ReturnOk -/// WaitOrReturn -- "refresh failed" --> ErrExpired["TokenExpired"] -/// -/// InProgress -- No --> HasCred{Refresh credential?} -/// HasCred -- None --> CheckUsable["Return token if usable, -/// else TokenExpired"] -/// -/// HasCred -- Yes --> Usable{Still usable?} -/// -/// Usable -- "Yes (preemptive)" --> NonBlocking["Refresh in background -/// (lock released)"] -/// NonBlocking --> ReturnOld["Return current token"] -/// -/// Usable -- "No (fully expired)" --> Blocking["Refresh -/// (lock held)"] -/// Blocking -- OK --> ReturnNew2["Return new token"] -/// Blocking -- Err --> ErrExpired["TokenExpired"] -/// ``` -#[cfg_attr(doc, aquamarine::aquamarine)] -pub trait AuthStrategy: Send { - /// Retrieve a valid access token, refreshing or re-authenticating as needed. - fn get_token(self) -> impl Future> + Send; -} - -/// A sensitive token string that is zeroized on drop and hidden from debug output. -/// -/// `SecretToken` wraps a `String` and enforces two invariants: -/// -/// - **Zeroized on drop**: the backing memory is overwritten with zeros when -/// the token goes out of scope, preventing it from lingering in memory. -/// - **Opaque debug**: the [`Debug`] implementation prints `"***"` instead of -/// the actual value, so tokens won't leak into logs or error messages. -/// -/// Use [`SecretToken::new`] to wrap a string value (e.g. an access key -/// loaded from configuration or an environment variable). -#[derive(Clone, OpaqueDebug, ZeroizeOnDrop, serde::Deserialize, serde::Serialize)] -#[serde(transparent)] -pub struct SecretToken(String); - -impl SecretToken { - /// Create a new `SecretToken` from a string value. - pub fn new(value: impl Into) -> Self { - Self(value.into()) - } - - /// Expose the inner token string for FFI boundaries. - pub fn as_str(&self) -> &str { - &self.0 - } -} - -/// Errors that can occur during an authentication flow. -#[derive(Debug, thiserror::Error, miette::Diagnostic)] -#[non_exhaustive] -pub enum AuthError { - /// The HTTP request to the auth server failed (network error, timeout, etc.). - #[error("HTTP request failed: {0}")] - Request(#[from] reqwest::Error), - /// The user denied the authorization request. - #[error("Authorization was denied")] - AccessDenied, - /// The grant type was rejected by the server. - #[error("Invalid grant")] - InvalidGrant, - /// The client ID is not recognized. - #[error("Invalid client")] - InvalidClient, - /// A URL could not be parsed. - #[error("Invalid URL: {0}")] - InvalidUrl(#[from] url::ParseError), - /// The requested region is not supported. - #[error("Unsupported region: {0}")] - Region(#[from] cts_common::RegionError), - /// The workspace CRN could not be parsed. - #[error("Invalid workspace CRN: {0}")] - InvalidCrn(cts_common::InvalidCrn), - /// An access key was provided but the workspace CRN is missing. - /// - /// Set the `CS_WORKSPACE_CRN` environment variable or call - /// [`AutoStrategyBuilder::with_workspace_crn`](crate::AutoStrategyBuilder::with_workspace_crn). - #[error("Workspace CRN is required when using an access key — set CS_WORKSPACE_CRN or call AutoStrategyBuilder::with_workspace_crn")] - MissingWorkspaceCrn, - /// No credentials are available (e.g. not logged in, no access key configured). - #[error("Not authenticated")] - NotAuthenticated, - /// A token (access token or device code) has expired. - #[error("Token expired")] - TokenExpired, - /// The access key string is malformed (e.g. missing `CSAK` prefix or `.` separator). - #[error("Invalid access key: {0}")] - InvalidAccessKey(#[from] access_key::InvalidAccessKey), - /// The JWT could not be decoded or its claims are malformed. - #[error("Invalid token: {0}")] - InvalidToken(String), - /// An unexpected error was returned by the auth server. - #[error("Server error: {0}")] - Server(String), - /// A token store operation failed. - #[error("Token store error: {0}")] - Store(#[from] stack_profile::ProfileError), -} - -impl From for AuthError { - fn from(never: Infallible) -> Self { - match never {} - } -} - -/// Read the `CS_CTS_HOST` environment variable and parse it as a URL. -/// -/// Returns `Ok(None)` if the variable is not set or empty. -/// Returns `Ok(Some(url))` if the variable is set and valid. -/// Returns `Err(_)` if the variable is set but not a valid URL. -pub(crate) fn cts_base_url_from_env() -> Result, AuthError> { - match std::env::var("CS_CTS_HOST") { - Ok(val) if !val.is_empty() => Ok(Some(val.parse()?)), - _ => Ok(None), - } -} - -/// Ensure a URL has a trailing slash so that `Url::join` with relative paths -/// appends to the path rather than replacing the last segment. -pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url { - if !url.path().ends_with('/') { - url.set_path(&format!("{}/", url.path())); - } - url -} - -/// Create a [`reqwest::Client`] with standard timeouts. -/// -/// In test builds, timeouts are omitted so that `tokio::test(start_paused = true)` -/// does not auto-advance time past the connect timeout before the mock server -/// can respond. -pub(crate) fn http_client() -> reqwest::Client { - #[cfg(any(test, feature = "test-utils"))] - { - reqwest::Client::builder() - .pool_max_idle_per_host(10) - .build() - .unwrap_or_else(|_| reqwest::Client::new()) - } - #[cfg(not(any(test, feature = "test-utils")))] - { - reqwest::Client::builder() - .connect_timeout(Duration::from_secs(10)) - .timeout(Duration::from_secs(30)) - .pool_idle_timeout(Duration::from_secs(5)) - .pool_max_idle_per_host(10) - .build() - .unwrap_or_else(|_| reqwest::Client::new()) - } -} diff --git a/vendor/stack-auth/src/oauth_refresher.rs b/vendor/stack-auth/src/oauth_refresher.rs deleted file mode 100644 index 23425b03..00000000 --- a/vendor/stack-auth/src/oauth_refresher.rs +++ /dev/null @@ -1,73 +0,0 @@ -use url::Url; - -use stack_profile::ProfileStore; - -use crate::refresher::Refresher; -use crate::{AuthError, SecretToken, Token}; - -/// Implements [`Refresher`] using OAuth refresh tokens. -/// -/// Optionally owns a [`ProfileStore`] for persisting refreshed tokens to disk. -/// When the store is `None`, tokens are cached in memory only. -pub(crate) struct OAuthRefresher { - store: Option, - base_url: Url, - client_id: String, - region: String, - device_instance_id: Option, -} - -impl OAuthRefresher { - pub(crate) fn new( - store: Option, - base_url: Url, - client_id: impl Into, - region: impl Into, - device_instance_id: Option, - ) -> Self { - Self { - store, - base_url, - client_id: client_id.into(), - region: region.into(), - device_instance_id, - } - } -} - -impl Refresher for OAuthRefresher { - type Credential = SecretToken; - - fn save(&self, token: &Token) { - if let Some(store) = &self.store { - match store.save_profile(token) { - Ok(()) => tracing::debug!("refreshed token saved to disk"), - Err(err) => tracing::warn!(%err, "failed to save refreshed token to disk"), - } - } - } - - fn try_credential(&self, token: Option<&mut Token>) -> Option { - token.and_then(|t| t.take_refresh_token()) - } - - fn restore(&self, token: &mut Token, credential: Self::Credential) { - token.refresh_token = Some(credential); - } - - async fn refresh(&self, credential: &Self::Credential) -> Result { - let mut token = Token::refresh( - credential, - &self.base_url, - &self.client_id, - self.device_instance_id.as_deref(), - ) - .await?; - token.set_region(&self.region); - token.set_client_id(&self.client_id); - if let Some(ref id) = self.device_instance_id { - token.set_device_instance_id(id); - } - Ok(token) - } -} diff --git a/vendor/stack-auth/src/oauth_strategy.rs b/vendor/stack-auth/src/oauth_strategy.rs deleted file mode 100644 index 4b28e44c..00000000 --- a/vendor/stack-auth/src/oauth_strategy.rs +++ /dev/null @@ -1,196 +0,0 @@ -use cts_common::{Crn, CtsServiceDiscovery, Region, ServiceDiscovery}; -use tracing::warn; - -use stack_profile::ProfileStore; - -use crate::auto_refresh::AutoRefresh; -use crate::oauth_refresher::OAuthRefresher; -use crate::{ensure_trailing_slash, AuthError, AuthStrategy, ServiceToken, Token}; - -/// An [`AuthStrategy`] that uses OAuth refresh tokens to maintain a valid access token. -/// -/// # Construction -/// -/// Use [`OAuthStrategy::with_token`] with a token obtained from a device code flow -/// (or any other OAuth flow) for in-memory caching only. Use -/// [`OAuthStrategy::with_profile`] to load a token from disk and persist -/// refreshed tokens back to the store. -/// -/// # Example -/// -/// ```no_run -/// use stack_auth::{OAuthStrategy, Token}; -/// use cts_common::Region; -/// -/// # fn run(token: Token) -> Result<(), Box> { -/// let region = Region::aws("ap-southeast-2")?; -/// let strategy = OAuthStrategy::with_token(region, "my-client-id", token).build()?; -/// # Ok(()) -/// # } -/// ``` -pub struct OAuthStrategy { - crn: Option, - inner: AutoRefresh, -} - -impl OAuthStrategy { - /// Return a builder for configuring an `OAuthStrategy` from a token. - /// - /// The token's `region` and `client_id` fields are set before caching. - /// No token store is used — tokens are not persisted to disk. - pub fn with_token( - region: Region, - client_id: impl Into, - token: Token, - ) -> OAuthStrategyBuilder { - OAuthStrategyBuilder { - source: OAuthTokenSource::Token { - region, - client_id: client_id.into(), - token, - }, - base_url_override: None, - } - } - - /// Return a builder for configuring an `OAuthStrategy` from a profile store. - /// - /// The token is loaded from the store when [`OAuthStrategyBuilder::build`] is called. - /// The builder allows further configuration (e.g. overriding the base URL) before building. - /// - /// The token must have `region` and `client_id` set (as saved by - /// [`DeviceCodeStrategy`](crate::DeviceCodeStrategy) or a prior - /// `OAuthStrategy`). The store is used for persisting refreshed tokens. - pub fn with_profile(store: ProfileStore) -> OAuthStrategyBuilder { - OAuthStrategyBuilder { - source: OAuthTokenSource::Store(store), - base_url_override: None, - } - } - - /// Return the workspace CRN, if one was extracted from the token at build time. - pub fn workspace_crn(&self) -> Option<&Crn> { - self.crn.as_ref() - } -} - -impl AuthStrategy for &OAuthStrategy { - async fn get_token(self) -> Result { - Ok(self.inner.get_token().await?) - } -} - -/// Where the initial OAuth token comes from. -enum OAuthTokenSource { - /// A token provided directly (in-memory only, no store). - Token { - region: Region, - client_id: String, - token: Token, - }, - /// A token loaded from a persistent store. - Store(ProfileStore), -} - -/// Builder for [`OAuthStrategy`]. -/// -/// Created via [`OAuthStrategy::with_token`] or [`OAuthStrategy::with_profile`]. -pub struct OAuthStrategyBuilder { - source: OAuthTokenSource, - base_url_override: Option, -} - -impl OAuthStrategyBuilder { - /// Override the base URL resolved by service discovery. - /// - /// Useful for pointing at a local or mock auth server during testing. - #[cfg(any(test, feature = "test-utils"))] - pub fn base_url(mut self, url: url::Url) -> Self { - self.base_url_override = Some(url); - self - } - - /// Build the [`OAuthStrategy`]. - /// - /// Resolves the base URL via service discovery unless overridden with - /// `base_url` (available when the `test-utils` feature is enabled). - pub fn build(self) -> Result { - match self.source { - OAuthTokenSource::Token { - region, - client_id, - mut token, - } => { - let base_url = match self.base_url_override { - Some(url) => url, - None => crate::cts_base_url_from_env()? - .unwrap_or(CtsServiceDiscovery::endpoint(region)?), - }; - // Derive CRN from the explicit region parameter and the token's - // workspace claim. We can't use token.workspace_crn() here - // because set_region() hasn't been called on the token yet. - let crn = token - .workspace_id() - .map(|ws| Crn::new(region, ws)) - .map_err(|e| { - warn!("Could not extract workspace CRN from token: {e}"); - e - }) - .ok(); - let region_id = region.identifier(); - let device_instance_id = token.device_instance_id().map(String::from); - token.set_region(®ion_id); - token.set_client_id(&client_id); - let refresher = OAuthRefresher::new( - None, - ensure_trailing_slash(base_url), - &client_id, - ®ion_id, - device_instance_id, - ); - Ok(OAuthStrategy { - crn, - inner: AutoRefresh::with_token(refresher, token), - }) - } - OAuthTokenSource::Store(store) => { - let ws_store = store.current_workspace_store()?; - let token: Token = ws_store.load_profile()?; - - let region_str = token - .region() - .ok_or(AuthError::NotAuthenticated)? - .to_string(); - let client_id = token - .client_id() - .ok_or(AuthError::NotAuthenticated)? - .to_string(); - let crn = token - .workspace_crn() - .map_err(|e| { - warn!("Could not extract workspace CRN from token: {e}"); - e - }) - .ok(); - let device_instance_id = token.device_instance_id().map(String::from); - - let base_url = match self.base_url_override { - Some(url) => url, - None => crate::cts_base_url_from_env()?.unwrap_or(token.issuer()?), - }; - - let refresher = OAuthRefresher::new( - Some(ws_store), - ensure_trailing_slash(base_url), - &client_id, - ®ion_str, - device_instance_id, - ); - Ok(OAuthStrategy { - crn, - inner: AutoRefresh::with_token(refresher, token), - }) - } - } - } -} diff --git a/vendor/stack-auth/src/refresher.rs b/vendor/stack-auth/src/refresher.rs deleted file mode 100644 index 576e11a4..00000000 --- a/vendor/stack-auth/src/refresher.rs +++ /dev/null @@ -1,34 +0,0 @@ -use std::future::Future; - -use crate::{AuthError, Token}; - -/// Internal trait defining how to refresh or re-authenticate to obtain a new [`Token`]. -/// -/// [`AutoRefresh`](crate::auto_refresh::AutoRefresh) delegates the type-specific -/// parts of token refresh to the `Refresher` implementation while handling the -/// concurrency orchestration (cascade prevention, two-tier locking) generically. -pub(crate) trait Refresher: Send + Sync { - /// The credential extracted from the current token before a refresh attempt. - type Credential: Send; - - /// Persist a token after a successful refresh. Best-effort — implementations - /// should log on failure rather than returning an error. - fn save(&self, token: &Token); - - /// Extract a credential for refreshing. - /// - /// `token` is `None` on cold start (no cached token). Returns `None` if - /// this refresher can't produce a token without a prior one (e.g. OAuth - /// needs a refresh token). - fn try_credential(&self, token: Option<&mut Token>) -> Option; - - /// Restore state after a failed refresh attempt (e.g. put the refresh token - /// back so the next caller can retry). - fn restore(&self, token: &mut Token, credential: Self::Credential); - - /// Perform the HTTP refresh or authentication call. - fn refresh( - &self, - credential: &Self::Credential, - ) -> impl Future> + Send; -} diff --git a/vendor/stack-auth/src/service_token.rs b/vendor/stack-auth/src/service_token.rs deleted file mode 100644 index 90a6273d..00000000 --- a/vendor/stack-auth/src/service_token.rs +++ /dev/null @@ -1,378 +0,0 @@ -use cts_common::claims::{ServiceType, Services}; -use cts_common::WorkspaceId; -use url::Url; -use vitaminc::protected::OpaqueDebug; -use zeroize::ZeroizeOnDrop; - -use crate::{AuthError, SecretToken}; - -/// A CipherStash service token returned by an [`AuthStrategy`](crate::AuthStrategy). -/// -/// Wraps a bearer credential ([`SecretToken`]) together with eagerly decoded -/// JWT claims that are used for service discovery. The JWT is decoded (but -/// **not** signature-verified) using [`cts_common::claims::Claims`], so only -/// CipherStash-issued service tokens (from CTS or the access-key exchange) -/// will have their claims resolved. -/// -/// # Decoded claims -/// -/// * `subject()` — the `sub` claim (e.g. `"CS|auth0|user123"`). -/// * `workspace_id()` — the workspace identifier from the token. -/// * `issuer()` — the `iss` URL, i.e. the CTS host for this workspace. -/// * `zerokms_url()` — the ZeroKMS endpoint from the `services` claim. -/// -/// For non-JWT tokens (e.g. static test tokens) or JWTs that don't match -/// the CipherStash claims schema, these methods return -/// `Err(AuthError::InvalidToken)`. -/// -/// # Security -/// -/// Like [`SecretToken`], this is zeroized on drop and hidden from [`Debug`] -/// output. -#[derive(Clone, OpaqueDebug, ZeroizeOnDrop)] -pub struct ServiceToken { - secret: SecretToken, - #[zeroize(skip)] - decoded: Result, -} - -#[derive(Clone, Debug)] -struct DecodedClaims { - subject: String, - workspace: WorkspaceId, - issuer: Url, - services: Services, -} - -impl ServiceToken { - /// Create a `ServiceToken` from a [`SecretToken`]. - /// - /// If the token string is a valid JWT with `iss` and `services` claims, - /// they are decoded eagerly. If decoding fails (not a JWT, missing claims, - /// etc.) the token is still usable as a bearer credential — `issuer()` and - /// `zerokms_url()` will simply return an error. - pub fn new(secret: SecretToken) -> Self { - let decoded = Self::try_decode(&secret); - Self { secret, decoded } - } - - /// Expose the inner token string for use as a bearer credential. - pub fn as_str(&self) -> &str { - self.secret.as_str() - } - - /// Return the `sub` (subject) claim from the JWT. - /// - /// In CipherStash tokens the subject encodes the principal identity, - /// e.g. `"CS|auth0|user123"` for a user or `"CS|CSAKkeyId"` for an - /// access key. - /// - /// # Errors - /// - /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or - /// the claims could not be decoded. - pub fn subject(&self) -> Result<&str, AuthError> { - self.decoded - .as_ref() - .map(|d| d.subject.as_str()) - .map_err(|reason| AuthError::InvalidToken(reason.clone())) - } - - /// Return the workspace identifier from the JWT claims. - /// - /// # Errors - /// - /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or - /// the claims could not be decoded. - pub fn workspace_id(&self) -> Result<&WorkspaceId, AuthError> { - self.decoded - .as_ref() - .map(|d| &d.workspace) - .map_err(|reason| AuthError::InvalidToken(reason.clone())) - } - - /// Return the `iss` (issuer) URL from the JWT claims. - /// - /// In CipherStash tokens the issuer is the CTS host URL for the workspace. - /// - /// # Errors - /// - /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or - /// the `iss` claim could not be parsed as a URL. - pub fn issuer(&self) -> Result<&Url, AuthError> { - self.decoded - .as_ref() - .map(|d| &d.issuer) - .map_err(|reason| AuthError::InvalidToken(reason.clone())) - } - - /// Return the decoded services map from the JWT claims. - /// - /// # Errors - /// - /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or - /// the claims could not be decoded. - pub fn services(&self) -> Result<&Services, AuthError> { - self.decoded - .as_ref() - .map(|d| &d.services) - .map_err(|reason| AuthError::InvalidToken(reason.clone())) - } - - /// Return the ZeroKMS endpoint URL from the `services` claim. - /// - /// CTS-issued JWTs include a `services` claim containing a map of service - /// type to endpoint URL. This method looks up the `zerokms` entry. - /// - /// # Errors - /// - /// Returns [`AuthError::InvalidToken`] if the token is not a valid JWT or - /// the `services` claim does not include a ZeroKMS endpoint. - pub fn zerokms_url(&self) -> Result { - self.services()? - .get(ServiceType::ZeroKms) - .cloned() - .ok_or_else(|| { - AuthError::InvalidToken( - "Token does not include a ZeroKMS endpoint in the services claim".into(), - ) - }) - } - - /// Attempt to decode the JWT claims from the token string. - /// - /// NOTE: This does not verify the token signature or validate any claims, - /// it only decodes the claims if the token is a well-formed JWT. - fn try_decode(secret: &SecretToken) -> Result { - use jsonwebtoken::{decode, decode_header, DecodingKey, Validation}; - use std::collections::HashSet; - - let token_str = secret.as_str(); - let header = - decode_header(token_str).map_err(|e| format!("failed to decode JWT header: {e}"))?; - - let dummy_key = DecodingKey::from_secret(&[]); - let mut validation = Validation::new(header.alg); - validation.validate_exp = false; - validation.validate_aud = false; - validation.required_spec_claims = HashSet::new(); - validation.insecure_disable_signature_validation(); - - let data: jsonwebtoken::TokenData = - decode(token_str, &dummy_key, &validation) - .map_err(|e| format!("failed to decode JWT claims: {e}"))?; - - let issuer: Url = data - .claims - .iss - .parse() - .map_err(|e| format!("iss claim is not a valid URL: {e}"))?; - - Ok(DecodedClaims { - subject: data.claims.sub, - workspace: data.claims.workspace, - issuer, - services: data.claims.services, - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::BTreeMap; - - fn make_jwt(iss: &str, services: Option>) -> String { - use jsonwebtoken::{encode, EncodingKey, Header}; - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - let mut claims = serde_json::json!({ - "iss": iss, - "sub": "CS|test-user", - "aud": "legacy-aud-value", - "iat": now, - "exp": now + 3600, - "workspace": "ZVATKW3VHMFG27DY", - "scope": "", - }); - - if let Some(svc) = services { - claims["services"] = serde_json::to_value(svc).unwrap(); - } - - encode( - &Header::default(), - &claims, - &EncodingKey::from_secret(b"test-secret"), - ) - .unwrap() - } - - fn services_with_zerokms(url: &str) -> Option> { - Some(BTreeMap::from([("zerokms", url)])) - } - - #[test] - fn jwt_token_provides_issuer() { - let jwt = make_jwt( - "https://cts.example.com/", - services_with_zerokms("https://zerokms.example.com/"), - ); - let token = ServiceToken::new(SecretToken::new(jwt.clone())); - - assert_eq!(token.as_str(), jwt); - assert_eq!(token.issuer().unwrap().as_str(), "https://cts.example.com/"); - } - - #[test] - fn non_jwt_token_returns_errors_with_reason() { - let token = ServiceToken::new(SecretToken::new("not-a-jwt")); - - assert_eq!(token.as_str(), "not-a-jwt"); - - let err = token.issuer().unwrap_err().to_string(); - assert!( - err.contains("failed to decode JWT header"), - "expected specific decode error, got: {err}" - ); - } - - #[test] - fn zerokms_url_from_services_claim() { - let jwt = make_jwt( - "https://cts.example.com/", - services_with_zerokms("https://zerokms.example.com/"), - ); - let token = ServiceToken::new(SecretToken::new(jwt)); - assert_eq!( - token.zerokms_url().unwrap().as_str(), - "https://zerokms.example.com/" - ); - } - - #[test] - fn zerokms_url_from_services_claim_localhost() { - let jwt = make_jwt( - "https://cts.example.com/", - services_with_zerokms("http://localhost:3002/"), - ); - let token = ServiceToken::new(SecretToken::new(jwt)); - assert_eq!( - token.zerokms_url().unwrap().as_str(), - "http://localhost:3002/" - ); - } - - #[test] - fn zerokms_url_errors_when_services_claim_missing() { - let jwt = make_jwt("https://cts.example.com/", None); - let token = ServiceToken::new(SecretToken::new(jwt)); - let err = token.zerokms_url().unwrap_err().to_string(); - assert!( - err.contains("services claim"), - "expected services claim error, got: {err}" - ); - } - - #[test] - fn zerokms_url_errors_for_non_jwt() { - let token = ServiceToken::new(SecretToken::new("not-a-jwt")); - assert!(token.zerokms_url().is_err()); - } - - #[test] - fn services_returns_map_for_valid_jwt() { - let jwt = make_jwt( - "https://cts.example.com/", - services_with_zerokms("https://zerokms.example.com/"), - ); - let token = ServiceToken::new(SecretToken::new(jwt)); - let services = token.services().unwrap(); - assert_eq!( - services - .get(cts_common::claims::ServiceType::ZeroKms) - .map(|u| u.as_str()), - Some("https://zerokms.example.com/") - ); - } - - #[test] - fn services_returns_empty_map_when_claim_missing() { - let jwt = make_jwt("https://cts.example.com/", None); - let token = ServiceToken::new(SecretToken::new(jwt)); - let services = token.services().unwrap(); - assert!(services.is_empty()); - } - - #[test] - fn services_errors_for_non_jwt() { - let token = ServiceToken::new(SecretToken::new("not-a-jwt")); - let err = token.services().unwrap_err().to_string(); - assert!( - err.contains("failed to decode JWT header"), - "expected specific decode error, got: {err}" - ); - } - - #[test] - fn subject_from_valid_jwt() { - let jwt = make_jwt( - "https://cts.example.com/", - services_with_zerokms("https://zerokms.example.com/"), - ); - let token = ServiceToken::new(SecretToken::new(jwt)); - assert_eq!( - token.subject().unwrap(), - "CS|test-user", - "subject should match JWT sub claim" - ); - } - - #[test] - fn subject_errors_for_non_jwt() { - let token = ServiceToken::new(SecretToken::new("not-a-jwt")); - assert!( - token.subject().is_err(), - "subject should error for non-JWT token" - ); - } - - #[test] - fn workspace_id_from_valid_jwt() { - let jwt = make_jwt( - "https://cts.example.com/", - services_with_zerokms("https://zerokms.example.com/"), - ); - let token = ServiceToken::new(SecretToken::new(jwt)); - assert_eq!( - token.workspace_id().unwrap().to_string(), - "ZVATKW3VHMFG27DY", - "workspace_id should match JWT workspace claim" - ); - } - - #[test] - fn workspace_id_errors_for_non_jwt() { - let token = ServiceToken::new(SecretToken::new("not-a-jwt")); - assert!( - token.workspace_id().is_err(), - "workspace_id should error for non-JWT token" - ); - } - - #[test] - fn debug_does_not_leak_secret() { - let jwt = make_jwt( - "https://cts.example.com/", - services_with_zerokms("https://zerokms.example.com/"), - ); - let token = ServiceToken::new(SecretToken::new(jwt.clone())); - let debug = format!("{:?}", token); - assert!(!debug.contains(&jwt)); - } -} diff --git a/vendor/stack-auth/src/static_token_strategy.rs b/vendor/stack-auth/src/static_token_strategy.rs deleted file mode 100644 index 66b86f69..00000000 --- a/vendor/stack-auth/src/static_token_strategy.rs +++ /dev/null @@ -1,30 +0,0 @@ -use crate::{AuthError, AuthStrategy, SecretToken, ServiceToken}; - -/// A simple [`AuthStrategy`] that always returns a fixed token. -/// -/// Useful in tests where a token has already been obtained (e.g. from a mock auth -/// server or via federation) and just needs to be presented as-is. -/// -/// ``` -/// use stack_auth::{StaticTokenStrategy, AuthStrategy}; -/// -/// # async fn example() { -/// let strategy = StaticTokenStrategy::new("my-token"); -/// let token = (&strategy).get_token().await.unwrap(); -/// assert_eq!(token.as_str(), "my-token"); -/// # } -/// ``` -pub struct StaticTokenStrategy(SecretToken); - -impl StaticTokenStrategy { - /// Create a new `StaticTokenStrategy` wrapping the given token string. - pub fn new(token: impl Into) -> Self { - Self(SecretToken::new(token)) - } -} - -impl AuthStrategy for &StaticTokenStrategy { - async fn get_token(self) -> Result { - Ok(ServiceToken::new(self.0.clone())) - } -} diff --git a/vendor/stack-auth/src/token.rs b/vendor/stack-auth/src/token.rs deleted file mode 100644 index 0107a711..00000000 --- a/vendor/stack-auth/src/token.rs +++ /dev/null @@ -1,577 +0,0 @@ -use std::time::{SystemTime, UNIX_EPOCH}; - -use cts_common::claims::Claims; -use cts_common::{Crn, Region, WorkspaceId}; -use url::Url; - -use crate::{http_client, AuthError, SecretToken}; - -impl stack_profile::ProfileData for Token { - const FILENAME: &'static str = "auth.json"; - const MODE: Option = Some(0o600); -} - -/// How many seconds before expiry [`Token::is_expired`] returns `true`. -/// -/// This leeway triggers preemptive refresh well before the token becomes -/// unusable, giving the HTTP refresh call time to complete while concurrent -/// callers can still use the current token. -const EXPIRY_LEEWAY_SECS: u64 = 90; - -/// An access token returned by a successful authentication flow. -/// -/// The token contains a [`SecretToken`] (the bearer credential), a token type -/// (typically `"Bearer"`), and an absolute expiry timestamp. -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct Token { - pub(crate) access_token: SecretToken, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) refresh_token: Option, - pub(crate) token_type: String, - pub(crate) expires_at: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) region: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) client_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub(crate) device_instance_id: Option, -} - -impl Token { - /// Returns a reference to the access token credential. - /// - /// The returned [`SecretToken`] is opaque — its [`Debug`] output is masked. - /// Pass it to API clients that need the raw bearer token. - pub fn access_token(&self) -> &SecretToken { - &self.access_token - } - - /// The token type (e.g. `"Bearer"`). - pub fn token_type(&self) -> &str { - &self.token_type - } - - /// The absolute epoch timestamp when the token expires. - pub fn expires_at(&self) -> u64 { - self.expires_at - } - - /// How many seconds until the token expires (computed from the current time). - pub fn expires_in(&self) -> u64 { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - self.expires_at.saturating_sub(now) - } - - /// Returns `true` if the token has expired (with 90 seconds of leeway). - /// - /// The 90-second leeway triggers preemptive refresh well before the token - /// becomes unusable, giving the HTTP refresh call plenty of time to complete - /// while the current token is still valid for concurrent callers. - /// - /// For checking whether the token is still usable as a bearer credential, - /// use [`is_usable`](Self::is_usable) instead. - pub fn is_expired(&self) -> bool { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - now + EXPIRY_LEEWAY_SECS >= self.expires_at - } - - /// Returns `true` if the token is still usable (before the actual expiry timestamp). - /// - /// Unlike [`is_expired`](Self::is_expired) which includes 90s leeway for preemptive - /// refresh, this only returns `false` when the token has genuinely expired. - pub fn is_usable(&self) -> bool { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - now < self.expires_at - } - - /// Returns a reference to the refresh token, if one was provided. - pub fn refresh_token(&self) -> Option<&SecretToken> { - self.refresh_token.as_ref() - } - - /// Takes the refresh token out, leaving `None` in its place. - pub fn take_refresh_token(&mut self) -> Option { - self.refresh_token.take() - } - - /// Returns the stored region identifier, if any. - pub fn region(&self) -> Option<&str> { - self.region.as_deref() - } - - /// Returns the stored client ID, if any. - pub fn client_id(&self) -> Option<&str> { - self.client_id.as_deref() - } - - /// Set the region identifier on this token. - pub(crate) fn set_region(&mut self, region: impl Into) { - self.region = Some(region.into()); - } - - /// Set the client ID on this token. - pub(crate) fn set_client_id(&mut self, client_id: impl Into) { - self.client_id = Some(client_id.into()); - } - - /// Returns the stored device instance ID, if any. - pub fn device_instance_id(&self) -> Option<&str> { - self.device_instance_id.as_deref() - } - - /// Set the device instance ID on this token. - pub(crate) fn set_device_instance_id(&mut self, id: impl Into) { - self.device_instance_id = Some(id.into()); - } - - /// Returns the workspace ID from the JWT claims. - /// - /// The access token is decoded (without signature verification) to extract - /// the `workspace` claim. - pub fn workspace_id(&self) -> Result { - self.decode_claims().map(|c| c.workspace) - } - - /// Returns the workspace CRN derived from the token's region and workspace ID. - /// - /// The region is set during the device code flow, and the workspace ID is - /// extracted from the JWT `workspace` claim. - pub fn workspace_crn(&self) -> Result { - let workspace_id = self.workspace_id()?; - let region: Region = self - .region() - .ok_or(AuthError::NotAuthenticated)? - .parse() - .map_err(|e: cts_common::RegionError| AuthError::Server(e.to_string()))?; - Ok(Crn::new(region, workspace_id)) - } - - /// Returns the issuer URL from the JWT claims. - /// - /// The `iss` claim in CipherStash tokens is the CTS host URL for the - /// workspace, so this can be used directly as the CTS base URL. - pub fn issuer(&self) -> Result { - let claims = self.decode_claims()?; - claims.iss.parse().map_err(AuthError::from) - } - - /// Decode the JWT payload into [`Claims`] without verifying the signature. - /// - /// This is safe because we already possess the token — we just need to read - /// the claims it contains. - fn decode_claims(&self) -> Result { - use jsonwebtoken::{decode, decode_header, DecodingKey, Validation}; - use std::collections::HashSet; - - let token_str = self.access_token.as_str(); - let header = decode_header(token_str) - .map_err(|e| AuthError::InvalidToken(format!("invalid JWT header: {e}")))?; - - let dummy_key = DecodingKey::from_secret(&[]); - let mut validation = Validation::new(header.alg); - validation.validate_exp = false; - validation.validate_aud = false; - validation.required_spec_claims = HashSet::new(); - validation.insecure_disable_signature_validation(); - - decode(token_str, &dummy_key, &validation) - .map(|data| data.claims) - .map_err(|e| AuthError::InvalidToken(format!("failed to decode JWT claims: {e}"))) - } - - /// Exchange a refresh token for a new [`Token`] via the `/oauth/token` - /// endpoint. - /// - /// This is a static constructor — it takes a bare [`SecretToken`] (the - /// refresh token) rather than operating on an existing `Token`. This - /// allows callers to manage the refresh token lifecycle independently - /// (e.g. taking it out of a cached token for cascade prevention and - /// restoring it on failure). - /// - /// # Errors - /// - /// - [`AuthError::InvalidGrant`] — the refresh token was revoked or expired. - /// - [`AuthError::InvalidClient`] — the client ID is not recognized. - /// - [`AuthError::Request`] — a network error occurred. - pub async fn refresh( - refresh_token: &SecretToken, - base_url: &Url, - client_id: &str, - device_instance_id: Option<&str>, - ) -> Result { - let token_url = base_url.join("oauth/token")?; - - tracing::debug!(url = %token_url, "refreshing token"); - - let resp = http_client() - .post(token_url) - .form(&RefreshRequest { - grant_type: "refresh_token", - client_id, - refresh_token: refresh_token.as_str(), - device_instance_id, - }) - .send() - .await?; - - if !resp.status().is_success() { - let err: RefreshErrorResponse = resp.json().await?; - tracing::debug!(error = %err.error, "token refresh failed"); - return Err(match err.error.as_str() { - "invalid_grant" => AuthError::InvalidGrant, - "invalid_client" => AuthError::InvalidClient, - "access_denied" => AuthError::AccessDenied, - _ => AuthError::Server(err.error_description), - }); - } - - let token_resp: RefreshResponse = resp.json().await?; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - Ok(Token { - access_token: token_resp.access_token, - token_type: token_resp.token_type, - expires_at: now + token_resp.expires_in, - refresh_token: token_resp.refresh_token, - region: None, - client_id: None, - // TODO(CIP-2793): The server should include device_instance_id in the - // refresh response. Until then, callers (e.g. OAuthRefresher) must - // re-attach it manually after refresh. - device_instance_id: None, - }) - } -} - -#[derive(serde::Serialize)] -struct RefreshRequest<'a> { - grant_type: &'a str, - client_id: &'a str, - refresh_token: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - device_instance_id: Option<&'a str>, -} - -#[derive(serde::Deserialize)] -struct RefreshResponse { - access_token: SecretToken, - token_type: String, - expires_in: u64, - #[serde(default)] - refresh_token: Option, -} - -#[derive(serde::Deserialize)] -struct RefreshErrorResponse { - error: String, - #[serde(default)] - error_description: String, -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::AuthError; - use mocktail::prelude::*; - - fn make_token(expires_in: u64, refresh: bool) -> Token { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - Token { - access_token: SecretToken::new("test-access-token"), - token_type: "Bearer".to_string(), - expires_at: now + expires_in, - refresh_token: if refresh { - Some(SecretToken::new("test-refresh-token")) - } else { - None - }, - region: None, - client_id: None, - device_instance_id: None, - } - } - - fn refresh_response_json() -> serde_json::Value { - serde_json::json!({ - "access_token": "new-access-token", - "token_type": "Bearer", - "expires_in": 3600, - "refresh_token": "new-refresh-token" - }) - } - - fn error_json(error: &str) -> serde_json::Value { - serde_json::json!({ - "error": error, - "error_description": format!("{error} occurred") - }) - } - - async fn start_server(mocks: MockSet) -> MockServer { - let server = MockServer::new_http("token-refresh-test").with_mocks(mocks); - server.start().await.unwrap(); - server - } - - #[test] - fn test_secret_token_debug_does_not_leak() { - let token = SecretToken("super_secret_value".to_string()); - let debug = format!("{:?}", token); - assert!( - !debug.contains("super_secret_value"), - "SecretToken Debug should not contain the secret, got: {debug}" - ); - } - - // ---- refresh() tests ---- - - #[tokio::test] - async fn test_refresh_success() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.json(refresh_response_json()); - }); - let server = start_server(mocks).await; - let base_url = server.url(""); - - let refresh_token = SecretToken::new("test-refresh-token"); - let refreshed = Token::refresh(&refresh_token, &base_url, "cli", None) - .await - .unwrap(); - - assert_eq!(refreshed.access_token().as_str(), "new-access-token"); - assert_eq!(refreshed.token_type(), "Bearer"); - assert_eq!( - refreshed.refresh_token().unwrap().as_str(), - "new-refresh-token" - ); - assert!(!refreshed.is_expired()); - assert!((3598..=3600).contains(&refreshed.expires_in())); - } - - #[tokio::test] - async fn test_refresh_invalid_grant() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.bad_request().json(error_json("invalid_grant")); - }); - let server = start_server(mocks).await; - let base_url = server.url(""); - - let refresh_token = SecretToken::new("test-refresh-token"); - let err = Token::refresh(&refresh_token, &base_url, "cli", None) - .await - .unwrap_err(); - - assert!(matches!(err, AuthError::InvalidGrant)); - } - - #[tokio::test] - async fn test_refresh_invalid_client() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.bad_request().json(error_json("invalid_client")); - }); - let server = start_server(mocks).await; - let base_url = server.url(""); - - let refresh_token = SecretToken::new("test-refresh-token"); - let err = Token::refresh(&refresh_token, &base_url, "cli", None) - .await - .unwrap_err(); - - assert!(matches!(err, AuthError::InvalidClient)); - } - - #[tokio::test] - async fn test_refresh_access_denied() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.bad_request().json(error_json("access_denied")); - }); - let server = start_server(mocks).await; - let base_url = server.url(""); - - let refresh_token = SecretToken::new("test-refresh-token"); - let err = Token::refresh(&refresh_token, &base_url, "cli", None) - .await - .unwrap_err(); - - assert!(matches!(err, AuthError::AccessDenied)); - } - - #[tokio::test] - async fn test_refresh_unknown_error() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.bad_request().json(error_json("something_unexpected")); - }); - let server = start_server(mocks).await; - let base_url = server.url(""); - - let refresh_token = SecretToken::new("test-refresh-token"); - let err = Token::refresh(&refresh_token, &base_url, "cli", None) - .await - .unwrap_err(); - - assert!(matches!(&err, AuthError::Server(desc) if desc == "something_unexpected occurred")); - } - - #[tokio::test] - async fn test_refresh_response_without_new_refresh_token() { - let mut mocks = MockSet::new(); - mocks.mock(|when, then| { - when.post().path("/oauth/token"); - then.json(serde_json::json!({ - "access_token": "new-access-token", - "token_type": "Bearer", - "expires_in": 3600 - })); - }); - let server = start_server(mocks).await; - let base_url = server.url(""); - - let refresh_token = SecretToken::new("test-refresh-token"); - let refreshed = Token::refresh(&refresh_token, &base_url, "cli", None) - .await - .unwrap(); - - assert_eq!(refreshed.access_token().as_str(), "new-access-token"); - assert!(refreshed.refresh_token().is_none()); - } - - #[tokio::test] - async fn test_refresh_debug_does_not_leak_tokens() { - let token = make_token(3600, true); - let debug = format!("{:?}", token); - assert!( - !debug.contains("test-access-token"), - "Debug output should not contain access token, got: {debug}" - ); - assert!( - !debug.contains("test-refresh-token"), - "Debug output should not contain refresh token, got: {debug}" - ); - } - - // ---- decode_claims / workspace_id / issuer tests ---- - - /// Build a Token whose access_token is a real (unsigned) JWT containing the - /// given claims JSON. - fn make_jwt_token(claims_json: serde_json::Value) -> Token { - use jsonwebtoken::{encode, EncodingKey, Header}; - let jwt = encode( - &Header::default(), - &claims_json, - &EncodingKey::from_secret(b"test-secret"), - ) - .expect("failed to encode JWT"); - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs(); - - Token { - access_token: SecretToken::new(jwt), - token_type: "Bearer".to_string(), - expires_at: now + 3600, - refresh_token: None, - region: None, - client_id: None, - device_instance_id: None, - } - } - - fn valid_claims_json() -> serde_json::Value { - serde_json::json!({ - "workspace": "7366ITCXSAPCH5TN", - "iss": "https://cts.example.com", - "sub": "user-123", - "aud": "https://cts.example.com", - "iat": 1700000000u64, - "exp": 1700003600u64, - "scope": "dataset:create" - }) - } - - #[test] - fn test_workspace_id_extracts_from_jwt() { - let token = make_jwt_token(valid_claims_json()); - let ws = token.workspace_id().expect("should extract workspace ID"); - assert_eq!(ws.to_string(), "7366ITCXSAPCH5TN"); - } - - #[test] - fn test_issuer_extracts_url_from_jwt() { - let token = make_jwt_token(valid_claims_json()); - let issuer = token.issuer().expect("should extract issuer"); - assert_eq!(issuer.as_str(), "https://cts.example.com/"); - } - - #[test] - fn test_workspace_id_fails_on_invalid_jwt() { - let token = Token { - access_token: SecretToken::new("not-a-jwt"), - token_type: "Bearer".to_string(), - expires_at: 0, - refresh_token: None, - region: None, - client_id: None, - device_instance_id: None, - }; - let err = token.workspace_id().unwrap_err(); - assert!(matches!(err, AuthError::InvalidToken(_))); - } - - #[test] - fn test_issuer_fails_on_missing_claims() { - let token = make_jwt_token(serde_json::json!({"sub": "user-123"})); - let err = token.issuer().unwrap_err(); - assert!(matches!(err, AuthError::InvalidToken(_))); - } - - #[test] - fn test_workspace_crn_derives_from_region_and_workspace() { - let mut token = make_jwt_token(valid_claims_json()); - token.set_region("ap-southeast-2.aws"); - let crn = token.workspace_crn().expect("should derive workspace CRN"); - assert_eq!(crn.to_string(), "crn:ap-southeast-2.aws:7366ITCXSAPCH5TN"); - } - - #[test] - fn test_workspace_crn_fails_without_region() { - let token = make_jwt_token(valid_claims_json()); - let err = token.workspace_crn().unwrap_err(); - assert!(matches!(err, AuthError::NotAuthenticated)); - } - - #[test] - fn test_workspace_crn_fails_with_invalid_region() { - let mut token = make_jwt_token(valid_claims_json()); - token.set_region("invalid-region"); - let err = token.workspace_crn().unwrap_err(); - assert!(matches!(err, AuthError::Server(_))); - } -}