diff --git a/Cargo.lock b/Cargo.lock index 0ec301f..6428aad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,112 +2,1649 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[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 = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[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 = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "hickory-net" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "084e7bd6a377435d568f652153e571b50970d7ccc1d1eeec0519f834632287e1" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni", + "rand", + "thiserror", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e2da0694c15b44c6f68a6b05e0233617008c54080e31d6eb848d858a9c5b38d" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand", + "ring", + "thiserror", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4f9f4603319422d482738f3f6fe5aac03157fdbfed1cd85a3ff45adb09072f" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand", + "resolv-conf", + "smallvec", + "system-configuration", + "thiserror", + "tracing", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +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 = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +dependencies = [ + "serde", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[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 = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "permissionsync" +version = "0.1.0" + +[[package]] +name = "permissionsync-core" +version = "0.1.0" +dependencies = [ + "serde_json", +] + +[[package]] +name = "permissionsync-provider-generic-rest" +version = "0.1.0" +dependencies = [ + "bytes", + "hickory-net", + "hickory-resolver", + "http-body-util", + "hyper", + "hyper-util", + "mime", + "native-tls", + "openssl", + "permissionsync-core", + "serde", + "serde_json", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "permissionsync-routing" +version = "0.1.0" +dependencies = [ + "permissionsync-core", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[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", + "windows-sys 0.52.0", +] + +[[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 = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +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.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] -name = "memchr" -version = "2.8.3" +name = "syn" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] [[package]] -name = "permissionsync" -version = "0.1.0" +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] [[package]] -name = "permissionsync-core" -version = "0.1.0" +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ - "serde_json", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "permissionsync-routing" -version = "0.1.0" +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "permissionsync-core", + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", ] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "system-configuration-sys" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" dependencies = [ - "unicode-ident", + "core-foundation-sys", + "libc", ] [[package]] -name = "quote" -version = "1.0.47" +name = "tagptr" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", + "quote", + "syn 3.0.5", ] [[package]] -name = "serde" -version = "1.0.229" +name = "tinystr" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ - "serde_core", + "displaydoc", + "zerovec", ] [[package]] -name = "serde_core" -version = "1.0.229" +name = "tinyvec" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" dependencies = [ - "serde_derive", + "tinyvec_macros", ] [[package]] -name = "serde_derive" -version = "1.0.229" +name = "tinyvec_macros" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.5", ] [[package]] -name = "serde_json" -version = "1.0.151" +name = "tokio-native-tls" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" dependencies = [ - "itoa", - "memchr", + "native-tls", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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_core", - "zmij", ] [[package]] -name = "syn" -version = "3.0.4" +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[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 = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", "proc-macro2", "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ "unicode-ident", ] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "widestring" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[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 = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[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.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index fabc44d..5f88357 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "crates/permissionsync-core", + "crates/permissionsync-provider-generic-rest", "crates/permissionsync-routing", ] resolver = "3" diff --git a/README.md b/README.md index 9076e86..0e2bd6c 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ PermissionSync is an architecture-first service for synchronizing a user's desired permissions with a selected target. The active constraints are recorded in the [ADR index](docs/adr/README.md). -The Rust workspace currently contains target-neutral Core domain contracts and -deterministic runtime target routing. It intentionally contains no -synchronization, HTTP, authentication, Permission Provider implementation, or -concrete target-adapter behavior yet. +The Rust workspace currently contains target-neutral Core domain contracts, +deterministic runtime target routing, and a concrete Generic REST Permission +Provider. Inbound HTTP, synchronization orchestration, authentication, and +concrete target-adapter behavior remain unimplemented. ## Development diff --git a/crates/permissionsync-provider-generic-rest/Cargo.toml b/crates/permissionsync-provider-generic-rest/Cargo.toml new file mode 100644 index 0000000..3377ff6 --- /dev/null +++ b/crates/permissionsync-provider-generic-rest/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "permissionsync-provider-generic-rest" +description = "Concrete Generic REST Permission Provider for PermissionSync" +edition.workspace = true +license.workspace = true +publish.workspace = true +repository.workspace = true +version.workspace = true + +[dependencies] +bytes = { version = "=1.11.1", default-features = false } +hickory-net = { version = "=0.26.2", default-features = false, features = ["tokio"] } +hickory-resolver = { version = "=0.26.2", default-features = false, features = ["system-config"] } +http-body-util = { version = "=0.1.5", default-features = false } +hyper = { version = "=1.11.1", default-features = false, features = ["client", "http1"] } +hyper-util = { version = "=0.1.20", default-features = false, features = ["tokio"] } +mime = { version = "=0.3.17", default-features = false } +native-tls = { version = "=0.2.18", default-features = false } +permissionsync-core = { path = "../permissionsync-core" } +serde = { version = "=1.0.229", default-features = false, features = ["derive", "std"] } +serde_json = { version = "=1.0.151", default-features = false, features = ["raw_value", "std"] } +tokio = { version = "=1.52.3", default-features = false, features = ["net", "rt", "time"] } +tokio-native-tls = { version = "=0.3.1", default-features = false } + +[dev-dependencies] +openssl = { version = "=0.10.81", default-features = false } +tokio = { version = "=1.52.3", default-features = false, features = ["io-util", "macros", "sync", "test-util"] } + +[lints] +workspace = true diff --git a/crates/permissionsync-provider-generic-rest/src/client.rs b/crates/permissionsync-provider-generic-rest/src/client.rs new file mode 100644 index 0000000..ada6fa3 --- /dev/null +++ b/crates/permissionsync-provider-generic-rest/src/client.rs @@ -0,0 +1,1053 @@ +use std::{ + future::Future, + net::{IpAddr, SocketAddr}, + str::FromStr, + task::Poll, + time::{Duration, Instant}, +}; + +use bytes::Bytes; +use hickory_net::{ + NetError, + proto::{ + op::{DnsRequest, DnsRequestOptions, DnsResponse, Query, ResponseCode}, + rr::{Name, RData, RecordType}, + }, + runtime::TokioRuntimeProvider, + udp::UdpClientStream, + xfer::{DnsRequestSender, FirstAnswer}, +}; +use hickory_resolver::{config::ProtocolConfig, system_conf::read_system_conf}; +use http_body_util::{BodyExt, Full}; +use hyper::{ + Method, Request, StatusCode, Uri, Version, + body::Incoming, + header::{ACCEPT, ACCEPT_ENCODING, AUTHORIZATION, CONTENT_TYPE, HOST, HeaderValue}, + http::uri::Scheme, +}; +use hyper_util::rt::TokioIo; +use native_tls::{Certificate, Protocol, TlsConnector as NativeTlsConnector}; +use permissionsync_core::{ + DesiredStateEnvelope, PermissionProviderRequest, SynchronizationContext, +}; +use tokio::{ + net::TcpStream, + time::{Instant as TokioInstant, timeout_at}, +}; +pub(crate) use tokio_native_tls::TlsConnector; + +use crate::{ + error::{GenericRestPermissionProviderConfigError, ProviderFailure}, + wire, +}; + +/// The absolute product-owned Provider response-body safety ceiling required +/// by [ADR +/// 0008](../../../docs/adr/0008-generic-rest-permission-provider-wire-and-transport-contract.md). +/// +/// Rationale for exactly 1 MiB: +/// +/// - Desired-state documents are adapter-specific permission payloads and +/// are expected to be materially smaller than this ceiling in normal +/// operation. +/// - 1 MiB leaves substantial headroom above that expected size without +/// approaching a platform-maximum or effectively unbounded value. +/// - It bounds per-request buffering: the client never buffers more than +/// this many bytes for one response, regardless of how a misbehaving +/// Provider responds. +/// - It bounds JSON parser work and memory amplification, since the fully +/// buffered body is handed to a single-pass parser only after this +/// ceiling has already been enforced. +/// - It reduces the impact of a defective or malicious Provider response +/// that attempts to exhaust memory or CPU through an oversized payload. +/// +/// This is the absolute ceiling: a future deployment-specific limit under +/// [ADR +/// 0006](../../../docs/adr/0006-runtime-configuration-oci-and-observability.md) +/// may only lower the effective limit, never raise it above this value. +/// This constant is intentionally private; this crate exposes no +/// response-body-limit configuration. +const RESPONSE_BODY_LIMIT_BYTES: usize = 1_048_576; + +pub(crate) fn parse_endpoint( + endpoint: &str, +) -> Result { + if endpoint.contains(['{', '}', '#']) { + return Err(GenericRestPermissionProviderConfigError::new()); + } + + let endpoint = endpoint + .parse::() + .map_err(|_| GenericRestPermissionProviderConfigError::new())?; + + if endpoint.scheme() != Some(&Scheme::HTTPS) + || endpoint.host().is_none_or(str::is_empty) + || endpoint.authority().is_none() + || endpoint.query().is_some() + || endpoint + .authority() + .is_some_and(|authority| authority.as_str().contains('@')) + { + return Err(GenericRestPermissionProviderConfigError::new()); + } + + if endpoint.path().is_empty() { + let mut parts = endpoint.into_parts(); + parts.path_and_query = Some("/".parse().expect("slash is a valid URI path")); + Uri::from_parts(parts).map_err(|_| GenericRestPermissionProviderConfigError::new()) + } else { + Ok(endpoint) + } +} + +/// Immutable endpoint-address state selected while Provider configuration is +/// validated. Hostnames retain only the first configured UDP system nameserver; +/// literals retain no DNS state and never query the system resolver. +pub(crate) struct EndpointHost { + port: u16, + kind: EndpointHostKind, +} + +enum EndpointHostKind { + Literal(IpAddr), + Hostname { + name: Name, + name_server: UdpNameServer, + }, +} + +#[derive(Clone, Copy)] +struct UdpNameServer { + address: SocketAddr, + bind_addr: Option, +} + +pub(crate) fn classify_endpoint_host( + endpoint: &Uri, +) -> Result { + let host = bare_host(endpoint); + let port = endpoint.port_u16().unwrap_or(443); + + if let Ok(address) = host.parse::() { + return Ok(EndpointHost { + port, + kind: EndpointHostKind::Literal(address), + }); + } + + let name = endpoint_dns_name(host)?; + let name_server = configured_udp_name_server()?; + + Ok(EndpointHost { + port, + kind: EndpointHostKind::Hostname { name, name_server }, + }) +} + +fn endpoint_dns_name(host: &str) -> Result { + let mut name = + Name::from_str(host).map_err(|_| GenericRestPermissionProviderConfigError::new())?; + name.set_fqdn(true); + Ok(name) +} + +fn configured_udp_name_server() -> Result { + let (config, _) = + read_system_conf().map_err(|_| GenericRestPermissionProviderConfigError::new())?; + + config + .name_servers() + .iter() + .flat_map(|name_server| { + name_server + .connections + .iter() + .filter_map(move |connection| { + if !matches!(&connection.protocol, ProtocolConfig::Udp) + || connection.port == 0 + || connection.bind_addr.is_some_and(|bind_addr| { + bind_addr.is_ipv4() != name_server.ip.is_ipv4() + }) + { + return None; + } + + Some(UdpNameServer { + address: SocketAddr::new(name_server.ip, connection.port), + bind_addr: connection.bind_addr, + }) + }) + }) + .next() + .ok_or_else(GenericRestPermissionProviderConfigError::new) +} + +/// Builds the reusable TLS connector for this Provider instance. +/// +/// System trust roots remain enabled. `additional_trust_anchors_pem` may be +/// empty and, when present, each element is parsed as a PEM certificate +/// bundle that may contain one or more concatenated X.509 certificates; every +/// certificate found is added as an additional private trust anchor. A PEM +/// blob that is malformed or that yields no usable certificate fails +/// construction. This connector carries no connection pool, executor, or +/// resolver: each Provider operation performs its own single-attempt DNS +/// resolution, TCP connect, and TLS and HTTP/1.1 handshake, as required by +/// ADR 0008. +pub(crate) fn build_tls_connector( + additional_trust_anchors_pem: &[Vec], +) -> Result { + let mut tls_builder = NativeTlsConnector::builder(); + tls_builder + .min_protocol_version(Some(Protocol::Tlsv12)) + .danger_accept_invalid_certs(false) + .danger_accept_invalid_hostnames(false) + .use_sni(true); + + for trust_anchor_pem in additional_trust_anchors_pem { + let certificates = Certificate::stack_from_pem(trust_anchor_pem) + .map_err(|_| GenericRestPermissionProviderConfigError::new())?; + if certificates.is_empty() { + return Err(GenericRestPermissionProviderConfigError::new()); + } + + for certificate in certificates { + tls_builder.add_root_certificate(certificate); + } + } + + let tls = tls_builder + .build() + .map_err(|_| GenericRestPermissionProviderConfigError::new())?; + + Ok(TlsConnector::from(tls)) +} + +pub(crate) async fn resolve( + tls_connector: &TlsConnector, + endpoint: &Uri, + endpoint_host: &EndpointHost, + operation_timeout: Duration, + request: PermissionProviderRequest<'_>, +) -> Result { + let effective_deadline = effective_deadline(request.context(), operation_timeout)?; + if tokio::runtime::Handle::try_current().is_err() { + return Err(ProviderFailure::RuntimeUnavailable); + } + let timeout_deadline = TokioInstant::from_std(effective_deadline); + + timeout_at( + timeout_deadline, + resolve_within_deadline( + tls_connector, + endpoint, + endpoint_host, + request, + effective_deadline, + ), + ) + .await + .map_err(|_| ProviderFailure::DeadlineExceeded)? +} + +/// Performs one bounded, single-attempt Provider operation. +/// +/// DNS resolution, the TCP connect, the TLS handshake, the HTTP/1.1 +/// handshake, the request write, and response reading all happen inside this +/// one future so that timing it out or dropping it also drops the +/// connection-driving future below; there is no detached connection driver +/// task and no automatic retry or reconnection. +async fn resolve_within_deadline( + tls_connector: &TlsConnector, + endpoint: &Uri, + endpoint_host: &EndpointHost, + request: PermissionProviderRequest<'_>, + effective_deadline: Instant, +) -> Result { + let context = request.context(); + check_context(context, effective_deadline)?; + + let request_body = wire::serialize_request_body(request.identity())?; + let authorization = authorization_header(request.technical_caller_bearer_token().as_str())?; + let outbound_request = build_request(endpoint, request_body, authorization)?; + + check_context(context, effective_deadline)?; + let address = resolve_one_address(endpoint_host, effective_deadline).await?; + + check_context(context, effective_deadline)?; + let tcp_stream = TcpStream::connect(address) + .await + .map_err(|_| ProviderFailure::Transport)?; + + check_context(context, effective_deadline)?; + let domain = bare_host(endpoint); + let tls_stream = tls_connector + .connect(domain, tcp_stream) + .await + .map_err(|_| ProviderFailure::Transport)?; + + check_context(context, effective_deadline)?; + let io = TokioIo::new(tls_stream); + let (mut sender, connection) = hyper::client::conn::http1::handshake(io) + .await + .map_err(|_| ProviderFailure::Transport)?; + + // Drive the connection and the response future together, inside this + // same bounded future. The connection is polled first on every wake so + // that any response data it delivers this tick is immediately visible + // to the response future in the same tick; this avoids a race where a + // fully-buffered response could otherwise be dropped in favor of a + // simultaneously completing connection. Once the connection has ended, + // an outcome future that is still pending can only mean the exchange + // did not complete, which is a transport failure with no retry. The + // connection is dropped as soon as this future resolves, closing the + // socket without pooling or reuse. + drive_connection_and_response( + connection, + send_and_read_response(&mut sender, outbound_request, context, effective_deadline), + ) + .await +} + +async fn send_and_read_response( + sender: &mut hyper::client::conn::http1::SendRequest>, + outbound_request: Request>, + context: &SynchronizationContext<'_>, + effective_deadline: Instant, +) -> Result { + check_context(context, effective_deadline)?; + let response = sender + .send_request(outbound_request) + .await + .map_err(|_| ProviderFailure::Transport)?; + + check_context(context, effective_deadline)?; + if response.status() != StatusCode::OK { + return Err(ProviderFailure::UnexpectedStatus); + } + + let (parts, body) = response.into_parts(); + let declared_length = + wire::validate_success_headers(&parts.headers, RESPONSE_BODY_LIMIT_BYTES)?; + let response_body = read_body( + body, + declared_length, + RESPONSE_BODY_LIMIT_BYTES, + context, + effective_deadline, + ) + .await?; + + check_context(context, effective_deadline)?; + let envelope = wire::parse_success_envelope(&response_body)?; + check_context(context, effective_deadline)?; + + Ok(envelope) +} + +/// Polls a HTTP/1.1 connection driver and a dependent response future +/// together until the response future completes or the connection ends. +/// +/// The connection is always polled first on every wake, so any progress it +/// makes (including fully delivering a response) is visible to `response` +/// within that same poll; this is what makes it safe to drive both futures +/// without a background task while still resolving deterministically +/// instead of racing them as two independent top-level futures. +async fn drive_connection_and_response( + connection: C, + response: F, +) -> Result +where + C: Future>, + F: Future>, +{ + let mut connection = std::pin::pin!(connection); + let mut response = std::pin::pin!(response); + let mut connection_done = false; + + std::future::poll_fn(move |task_context| { + if !connection_done && connection.as_mut().poll(task_context).is_ready() { + connection_done = true; + } + + match response.as_mut().poll(task_context) { + Poll::Ready(outcome) => Poll::Ready(outcome), + Poll::Pending if connection_done => Poll::Ready(Err(ProviderFailure::Transport)), + Poll::Pending => Poll::Pending, + } + }) + .await +} + +/// Resolves the endpoint host to exactly one socket address and returns it. +/// +/// A literal address returns directly and never loads or queries DNS. A +/// hostname sends exactly one A query and one AAAA query to the single UDP +/// nameserver captured at construction. The two queries compose one logical +/// resolution: neither is retried, fails over, or uses Happy Eyeballs. Both +/// request futures are owned by this operation and are dropped together with +/// it. If both families are ready with usable data in the same poll, IPv4 is +/// preferred; otherwise the first usable family supplies the one connection +/// address without waiting for an unavailable family. +async fn resolve_one_address( + endpoint_host: &EndpointHost, + effective_deadline: Instant, +) -> Result { + match &endpoint_host.kind { + EndpointHostKind::Literal(address) => Ok(SocketAddr::new(*address, endpoint_host.port)), + EndpointHostKind::Hostname { name, name_server } => { + let remaining = effective_deadline + .checked_duration_since(Instant::now()) + .filter(|duration| !duration.is_zero()) + .ok_or(ProviderFailure::DeadlineExceeded)?; + let mut ipv4 = std::pin::pin!(resolve_record( + name.clone(), + RecordType::A, + *name_server, + remaining, + )); + let mut ipv6 = std::pin::pin!(resolve_record( + name.clone(), + RecordType::AAAA, + *name_server, + remaining, + )); + let mut ipv4_done = false; + let mut ipv6_done = false; + let mut ipv4_address = None; + let mut ipv6_address = None; + let mut ipv4_timed_out = false; + let mut ipv6_timed_out = false; + + std::future::poll_fn(move |task_context| { + if !ipv4_done { + match ipv4.as_mut().poll(task_context) { + Poll::Ready(outcome) => { + ipv4_done = true; + ipv4_address = outcome.address; + ipv4_timed_out = outcome.timed_out; + } + Poll::Pending => {} + } + } + + if !ipv6_done { + match ipv6.as_mut().poll(task_context) { + Poll::Ready(outcome) => { + ipv6_done = true; + ipv6_address = outcome.address; + ipv6_timed_out = outcome.timed_out; + } + Poll::Pending => {} + } + } + + // Poll both futures before selecting an address so the A and + // AAAA queries are always started as one logical resolution. + if let Some(address) = ipv4_address.or(ipv6_address) { + return Poll::Ready(Ok(address)); + } + + if ipv4_done && ipv6_done { + // Neither family produced a usable address. A DNS + // timeout is reported as DeadlineExceeded when at least + // one family ended because of timeout exhaustion. Only + // an aggregate containing no timeout is a Transport + // failure. The underlying Hickory diagnostics are + // discarded either way. + Poll::Ready(Err(classify_dns_no_address(ipv4_timed_out, ipv6_timed_out))) + } else { + Poll::Pending + } + }) + .await + .map(|address| SocketAddr::new(address, endpoint_host.port)) + } + } +} + +/// Outcome of one operation-owned A or AAAA query: either a usable address, +/// or a flag recording whether the query ended because of Hickory timeout +/// exhaustion. The original Hickory error is never retained or formatted. +struct DnsQueryOutcome { + address: Option, + timed_out: bool, +} + +async fn resolve_record( + name: Name, + record_type: RecordType, + name_server: UdpNameServer, + timeout: Duration, +) -> DnsQueryOutcome { + let mut stream = UdpClientStream::builder(name_server.address, TokioRuntimeProvider::new()) + .with_bind_addr(name_server.bind_addr) + .with_timeout(Some(timeout)) + // Hickory counts the immediately-started request as task one, so one + // is exactly one datagram and no retransmission. + .with_max_retries(1) + .build(); + let request = DnsRequest::from_query( + Query::query(name.clone(), record_type), + DnsRequestOptions::default(), + ); + + match stream.send_message(request).first_answer().await { + Ok(response) => DnsQueryOutcome { + address: response_address(&response, &name, record_type), + timed_out: false, + }, + Err(error) => DnsQueryOutcome { + address: None, + timed_out: is_hickory_timeout(&error), + }, + } +} + +/// Classifies a Hickory DNS transport error as a timeout or not, without +/// retaining, formatting, or otherwise inspecting its diagnostic content. +fn is_hickory_timeout(error: &NetError) -> bool { + matches!(error, NetError::Timeout) +} + +/// Classifies a completed A/AAAA resolution in which neither family produced +/// a usable address. Any genuine Hickory timeout determines the aggregate +/// category; otherwise the aggregate is a transport failure. +fn classify_dns_no_address(ipv4_timed_out: bool, ipv6_timed_out: bool) -> ProviderFailure { + if ipv4_timed_out || ipv6_timed_out { + ProviderFailure::DeadlineExceeded + } else { + ProviderFailure::Transport + } +} + +fn response_address( + response: &DnsResponse, + name: &Name, + record_type: RecordType, +) -> Option { + if response.metadata.response_code != ResponseCode::NoError || response.metadata.truncation { + return None; + } + + // A response can include a CNAME chain and its final A/AAAA record in one + // DNS exchange. Follow only that in-response chain; never send a second + // query for a canonical name. + let mut accepted_names = vec![name.clone()]; + loop { + let known_count = accepted_names.len(); + for record in &response.answers { + if !accepted_names + .iter() + .any(|accepted_name| accepted_name == &record.name) + { + continue; + } + + let RData::CNAME(canonical_name) = &record.data else { + continue; + }; + if !accepted_names + .iter() + .any(|accepted_name| accepted_name == &canonical_name.0) + { + accepted_names.push(canonical_name.0.clone()); + } + } + + if accepted_names.len() == known_count { + break; + } + } + + response.answers.iter().find_map(|record| { + if !accepted_names + .iter() + .any(|accepted_name| accepted_name == &record.name) + || record.record_type() != record_type + { + return None; + } + + match (record_type, &record.data) { + (RecordType::A, RData::A(address)) => Some(IpAddr::V4((*address).into())), + (RecordType::AAAA, RData::AAAA(address)) => Some(IpAddr::V6((*address).into())), + _ => None, + } + }) +} + +/// Returns the endpoint host without the `[...]` literal delimiter that +/// IPv6 authorities use, for DNS resolution and TLS domain verification. +/// `endpoint` was already validated at construction to always have a host. +fn bare_host(endpoint: &Uri) -> &str { + endpoint + .host() + .expect("validated endpoint always has a host") + .trim_start_matches('[') + .trim_end_matches(']') +} + +fn effective_deadline( + context: &SynchronizationContext<'_>, + operation_timeout: Duration, +) -> Result { + if context.cancellation().is_cancelled() { + return Err(ProviderFailure::Cancelled); + } + + let now = Instant::now(); + if context.deadline() <= now { + return Err(ProviderFailure::DeadlineExceeded); + } + + let provider_deadline = now + .checked_add(operation_timeout) + .unwrap_or(context.deadline()); + Ok(context.deadline().min(provider_deadline)) +} + +fn check_context( + context: &SynchronizationContext<'_>, + effective_deadline: Instant, +) -> Result<(), ProviderFailure> { + if context.cancellation().is_cancelled() { + return Err(ProviderFailure::Cancelled); + } + + if Instant::now() >= effective_deadline { + return Err(ProviderFailure::DeadlineExceeded); + } + + Ok(()) +} + +fn authorization_header(token: &str) -> Result { + let mut header = HeaderValue::from_str(&format!("Bearer {token}")) + .map_err(|_| ProviderFailure::InvalidAuthorization)?; + header.set_sensitive(true); + Ok(header) +} + +fn build_request( + endpoint: &Uri, + request_body: Vec, + authorization: HeaderValue, +) -> Result>, ProviderFailure> { + let authority = endpoint + .authority() + .expect("validated endpoint always has an authority"); + let host_header = HeaderValue::from_str(authority.as_str()) + .map_err(|_| ProviderFailure::RequestConstruction)?; + + Request::builder() + .method(Method::POST) + .version(Version::HTTP_11) + .uri(endpoint.path()) + .header(HOST, host_header) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .header(ACCEPT, HeaderValue::from_static("application/json")) + .header(ACCEPT_ENCODING, HeaderValue::from_static("")) + .header(AUTHORIZATION, authorization) + .body(Full::new(Bytes::from(request_body))) + .map_err(|_| ProviderFailure::RequestConstruction) +} + +async fn read_body( + mut body: Incoming, + declared_length: Option, + limit: usize, + context: &SynchronizationContext<'_>, + effective_deadline: Instant, +) -> Result, ProviderFailure> { + let mut bytes = Vec::with_capacity(declared_length.unwrap_or_default()); + + while let Some(frame) = body.frame().await { + check_context(context, effective_deadline)?; + let frame = frame.map_err(|_| ProviderFailure::ResponseBody)?; + + if let Ok(data) = frame.into_data() { + accumulate_within_limit(bytes.len(), data.len(), limit)?; + bytes.extend_from_slice(&data); + } + } + + check_context(context, effective_deadline)?; + Ok(bytes) +} + +/// Adds `chunk_len` unencoded response-body bytes to `total` and enforces +/// `limit`, returning the new running total while still within bounds. +/// +/// Extracted so the exact accept/reject boundary, including the real +/// production ceiling, can be tested directly and cheaply, without +/// buffering an actual oversized HTTP response body in tests. +fn accumulate_within_limit( + total: usize, + chunk_len: usize, + limit: usize, +) -> Result { + let next_total = total + .checked_add(chunk_len) + .ok_or(ProviderFailure::ResponseTooLarge)?; + + if next_total > limit { + return Err(ProviderFailure::ResponseTooLarge); + } + + Ok(next_total) +} + +#[cfg(test)] +mod tests { + use std::{net::SocketAddr, str::FromStr, time::Duration}; + + use hickory_net::proto::{ + op::Message, + rr::{ + RData, Record, RecordType, + rdata::{A, AAAA, CNAME}, + }, + }; + use tokio::{net::UdpSocket, time::timeout}; + + use hickory_net::NetError; + + use super::{ + EndpointHost, EndpointHostKind, RESPONSE_BODY_LIMIT_BYTES, UdpNameServer, + accumulate_within_limit, authorization_header, classify_dns_no_address, endpoint_dns_name, + is_hickory_timeout, parse_endpoint, resolve_one_address, + }; + use crate::error::ProviderFailure; + + const DNS_QUERY_TIMEOUT: Duration = Duration::from_secs(2); + + fn hostname_endpoint(name: &str, name_server: SocketAddr) -> EndpointHost { + EndpointHost { + port: 443, + kind: EndpointHostKind::Hostname { + name: endpoint_dns_name(name).expect("test hostname must parse"), + name_server: UdpNameServer { + address: name_server, + bind_addr: None, + }, + }, + } + } + + fn dns_response(request: &[u8]) -> (RecordType, Vec) { + let request = Message::from_vec(request).expect("received DNS request must parse"); + let query = request + .queries + .first() + .expect("DNS request must contain one query"); + let record_type = query.query_type; + let mut response = Message::response(request.metadata.id, request.metadata.op_code); + response.queries = request.queries.clone(); + let canonical_name = hickory_net::proto::rr::Name::from_str("canonical.provider.test.") + .expect("canonical test name must parse"); + response.add_answer(Record::from_rdata( + query.name.clone(), + 0, + RData::CNAME(CNAME(canonical_name.clone())), + )); + let answer = match record_type { + RecordType::A => Record::from_rdata(canonical_name, 0, RData::A(A::new(192, 0, 2, 44))), + RecordType::AAAA => Record::from_rdata( + canonical_name, + 0, + RData::AAAA(AAAA::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 44)), + ), + record_type => panic!("unexpected DNS record type: {record_type:?}"), + }; + response.add_answer(answer); + ( + record_type, + response.to_vec().expect("test DNS response must encode"), + ) + } + + async fn answer_a_then_require_aaaa_and_assert_quiet(socket: UdpSocket) { + let mut buffer = [0; 512]; + let mut sent_a_response = false; + let mut aaaa_response = None; + + while !sent_a_response || aaaa_response.is_none() { + let (length, peer) = timeout(DNS_QUERY_TIMEOUT, socket.recv_from(&mut buffer)) + .await + .expect("local DNS endpoint did not receive one query per family") + .expect("local DNS endpoint must receive a query"); + let (record_type, response) = dns_response(&buffer[..length]); + + match record_type { + RecordType::A => { + assert!( + !sent_a_response, + "DNS resolution sent more than one A query" + ); + socket + .send_to(&response, peer) + .await + .expect("local DNS endpoint must send the A response"); + sent_a_response = true; + } + RecordType::AAAA => { + assert!( + aaaa_response.is_none(), + "DNS resolution sent more than one AAAA query" + ); + aaaa_response = Some((response, peer)); + } + record_type => panic!("unexpected DNS record type: {record_type:?}"), + } + } + + let (response, peer) = aaaa_response.expect("the AAAA query must be received"); + socket + .send_to(&response, peer) + .await + .expect("local DNS endpoint must send the AAAA response"); + + assert!( + timeout(DNS_QUERY_TIMEOUT, socket.recv_from(&mut buffer)) + .await + .is_err(), + "DNS resolution sent a retransmission or residual datagram" + ); + } + + async fn receive_two_queries_then_assert_quiet(socket: UdpSocket) { + let mut buffer = [0; 512]; + let mut received_a = false; + let mut received_aaaa = false; + for _ in 0..2 { + let (length, _) = timeout(DNS_QUERY_TIMEOUT, socket.recv_from(&mut buffer)) + .await + .expect("local DNS endpoint did not receive one query per family") + .expect("local DNS endpoint must receive one query per family"); + match dns_response(&buffer[..length]).0 { + RecordType::A => received_a = true, + RecordType::AAAA => received_aaaa = true, + record_type => panic!("unexpected DNS record type: {record_type:?}"), + } + } + assert!(received_a, "DNS resolution did not send an A query"); + assert!(received_aaaa, "DNS resolution did not send an AAAA query"); + assert!( + timeout(DNS_QUERY_TIMEOUT, socket.recv_from(&mut buffer)) + .await + .is_err(), + "DNS resolution sent a retransmission or residual datagram" + ); + } + + #[test] + fn authorization_header_is_sensitive_and_forwards_the_exact_bearer_value() { + let header = + authorization_header("sentinel-fake-bearer-token").expect("valid header value"); + + assert!(header.is_sensitive()); + assert_eq!(header.as_bytes(), b"Bearer sentinel-fake-bearer-token"); + } + + #[test] + fn accepts_exactly_the_limit_and_rejects_one_byte_more() { + let limit = 3; + + assert!(accumulate_within_limit(0, limit, limit).is_ok()); + assert!(accumulate_within_limit(0, limit + 1, limit).is_err()); + } + + #[test] + fn accepts_the_limit_when_reached_across_multiple_chunks() { + let limit = 3; + + let total = accumulate_within_limit(0, 1, limit).expect("within limit"); + let total = accumulate_within_limit(total, 2, limit).expect("reaches exact limit"); + assert_eq!(total, limit); + + assert!(accumulate_within_limit(total, 1, limit).is_err()); + } + + #[test] + fn production_response_body_limit_is_exactly_one_mebibyte() { + assert_eq!(RESPONSE_BODY_LIMIT_BYTES, 1_048_576); + + assert!( + accumulate_within_limit(0, RESPONSE_BODY_LIMIT_BYTES, RESPONSE_BODY_LIMIT_BYTES) + .is_ok() + ); + assert!( + accumulate_within_limit(0, RESPONSE_BODY_LIMIT_BYTES + 1, RESPONSE_BODY_LIMIT_BYTES) + .is_err() + ); + } + + #[test] + fn hostname_queries_are_always_fully_qualified() { + assert!( + endpoint_dns_name("provider.test") + .expect("hostname must parse") + .is_fqdn() + ); + } + + #[tokio::test] + async fn hostname_resolution_starts_both_queries_and_deterministically_prefers_ipv4() { + let socket = UdpSocket::bind("127.0.0.1:0") + .await + .expect("local DNS endpoint must bind"); + let endpoint = hostname_endpoint("provider.test", socket.local_addr().expect("address")); + let deadline = std::time::Instant::now() + DNS_QUERY_TIMEOUT; + + let (address, ()) = tokio::join!( + resolve_one_address(&endpoint, deadline), + answer_a_then_require_aaaa_and_assert_quiet(socket) + ); + + assert_eq!( + address.expect("A response must resolve"), + "192.0.2.44:443".parse().expect("socket address") + ); + } + + #[tokio::test] + async fn literal_endpoint_returns_without_dns_state_or_query() { + let endpoint = parse_endpoint("https://[2001:db8::44]:8443/permissions") + .and_then(|endpoint| super::classify_endpoint_host(&endpoint)) + .expect("IP literal endpoint must not need system DNS configuration"); + + assert_eq!( + resolve_one_address(&endpoint, std::time::Instant::now()) + .await + .expect("literal endpoint must resolve directly"), + "[2001:db8::44]:8443".parse().expect("socket address") + ); + } + + #[tokio::test(start_paused = true)] + async fn dns_timeout_has_no_retransmit_or_residual_worker_datagram() { + let socket = UdpSocket::bind("127.0.0.1:0") + .await + .expect("local DNS endpoint must bind"); + let endpoint = hostname_endpoint("provider.test", socket.local_addr().expect("address")); + let deadline = std::time::Instant::now() + DNS_QUERY_TIMEOUT; + + let (result, ()) = tokio::join!( + resolve_one_address(&endpoint, deadline), + receive_two_queries_then_assert_quiet(socket), + ); + + assert!( + matches!(result, Err(ProviderFailure::DeadlineExceeded)), + "a Hickory DNS timeout must classify as DeadlineExceeded, not Transport" + ); + } + + #[test] + fn only_a_hickory_timeout_error_classifies_as_timeout() { + assert!(is_hickory_timeout(&NetError::Timeout)); + assert!(!is_hickory_timeout(&NetError::Message( + "sentinel-non-timeout-dns-failure" + ))); + } + + #[test] + fn no_address_with_one_timeout_and_one_non_timeout_failure_is_deadline_exceeded() { + let timed_out = is_hickory_timeout(&NetError::Timeout); + let non_timeout = + is_hickory_timeout(&NetError::Message("sentinel-non-timeout-dns-failure")); + + assert!(matches!( + classify_dns_no_address(timed_out, non_timeout), + ProviderFailure::DeadlineExceeded + )); + } + + #[test] + fn no_address_with_only_non_timeout_failures_is_transport() { + let non_timeout = + is_hickory_timeout(&NetError::Message("sentinel-non-timeout-dns-failure")); + + assert!(matches!( + classify_dns_no_address(non_timeout, non_timeout), + ProviderFailure::Transport + )); + } + + #[tokio::test] + async fn a_usable_address_from_one_family_succeeds_despite_the_other_familys_dns_failure() { + let socket = UdpSocket::bind("127.0.0.1:0") + .await + .expect("local DNS endpoint must bind"); + let endpoint = hostname_endpoint("provider.test", socket.local_addr().expect("address")); + let deadline = std::time::Instant::now() + DNS_QUERY_TIMEOUT; + + // Answer only the A query with a valid response and let the AAAA + // query time out: the operation must still succeed using the usable + // IPv4 address rather than failing because the other family failed. + let server = async move { + let mut buffer = [0; 512]; + loop { + let (length, peer) = socket + .recv_from(&mut buffer) + .await + .expect("local DNS endpoint must receive a query"); + let (record_type, response) = dns_response(&buffer[..length]); + if record_type == RecordType::A { + socket + .send_to(&response, peer) + .await + .expect("local DNS endpoint must send the A response"); + break; + } + } + }; + + let (result, ()) = tokio::join!(resolve_one_address(&endpoint, deadline), server); + + assert_eq!( + result.expect("the successful A response must resolve despite the AAAA failure"), + "192.0.2.44:443".parse().expect("socket address") + ); + } + + #[tokio::test] + async fn dropping_dns_resolution_cancels_both_operation_owned_queries() { + let socket = UdpSocket::bind("127.0.0.1:0") + .await + .expect("local DNS endpoint must bind"); + let endpoint = hostname_endpoint("provider.test", socket.local_addr().expect("address")); + let deadline = std::time::Instant::now() + DNS_QUERY_TIMEOUT; + let mut buffer = [0; 512]; + let mut received_a = false; + let mut received_aaaa = false; + + { + let resolution = resolve_one_address(&endpoint, deadline); + tokio::pin!(resolution); + for _ in 0..2 { + tokio::select! { + outcome = &mut resolution => panic!("DNS unexpectedly completed: {outcome:?}"), + received = socket.recv_from(&mut buffer) => { + let (length, _) = received.expect("local DNS endpoint must receive one query per family"); + match dns_response(&buffer[..length]).0 { + RecordType::A => received_a = true, + RecordType::AAAA => received_aaaa = true, + record_type => panic!("unexpected DNS record type: {record_type:?}"), + } + } + } + } + } + + assert!(received_a, "DNS resolution did not send an A query"); + assert!(received_aaaa, "DNS resolution did not send an AAAA query"); + + assert!( + timeout(DNS_QUERY_TIMEOUT, socket.recv_from(&mut buffer)) + .await + .is_err(), + "dropped DNS resolution sent a residual datagram" + ); + } +} diff --git a/crates/permissionsync-provider-generic-rest/src/error.rs b/crates/permissionsync-provider-generic-rest/src/error.rs new file mode 100644 index 0000000..2d941d5 --- /dev/null +++ b/crates/permissionsync-provider-generic-rest/src/error.rs @@ -0,0 +1,104 @@ +use std::{error::Error, fmt}; + +/// A safe, non-diagnostic category for a Generic REST Provider failure. +/// +/// Transport and wire details can include sensitive request or response data, so +/// this type intentionally retains neither those details nor an error source. +#[derive(Clone, Copy)] +pub(crate) enum ProviderFailure { + RuntimeUnavailable, + Cancelled, + DeadlineExceeded, + InvalidAuthorization, + RequestConstruction, + RequestSerialization, + Transport, + UnexpectedStatus, + ResponseMetadata, + ResponseBody, + ResponseTooLarge, + ResponseEnvelope, +} + +impl fmt::Debug for ProviderFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ProviderFailure") + } +} + +impl fmt::Display for ProviderFailure { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("generic REST provider failure") + } +} + +impl Error for ProviderFailure { + fn source(&self) -> Option<&(dyn Error + 'static)> { + None + } +} + +/// A safe, non-diagnostic error for invalid Generic REST Provider configuration. +/// +/// Configuration values can include sensitive trust material or endpoint +/// information, so this type intentionally retains neither those details nor an +/// error source. +pub struct GenericRestPermissionProviderConfigError { + _private: (), +} + +impl GenericRestPermissionProviderConfigError { + /// Creates a non-diagnostic invalid-configuration category. + pub(crate) const fn new() -> Self { + Self { _private: () } + } +} + +impl fmt::Debug for GenericRestPermissionProviderConfigError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("GenericRestPermissionProviderConfigError") + } +} + +impl fmt::Display for GenericRestPermissionProviderConfigError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("invalid generic REST provider configuration") + } +} + +impl Error for GenericRestPermissionProviderConfigError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + None + } +} + +#[cfg(test)] +mod tests { + use std::error::Error; + + use super::{GenericRestPermissionProviderConfigError, ProviderFailure}; + + #[test] + fn provider_failure_has_only_fixed_safe_formatting() { + let error = ProviderFailure::ResponseEnvelope; + + assert_eq!(format!("{error:?}"), "ProviderFailure"); + assert_eq!(error.to_string(), "generic REST provider failure"); + assert!(error.source().is_none()); + } + + #[test] + fn config_error_has_only_fixed_safe_formatting() { + let error = GenericRestPermissionProviderConfigError::new(); + + assert_eq!( + format!("{error:?}"), + "GenericRestPermissionProviderConfigError" + ); + assert_eq!( + error.to_string(), + "invalid generic REST provider configuration" + ); + assert!(error.source().is_none()); + } +} diff --git a/crates/permissionsync-provider-generic-rest/src/lib.rs b/crates/permissionsync-provider-generic-rest/src/lib.rs new file mode 100644 index 0000000..59f44a1 --- /dev/null +++ b/crates/permissionsync-provider-generic-rest/src/lib.rs @@ -0,0 +1,105 @@ +//! Concrete HTTPS Generic REST Permission Provider for PermissionSync. +//! +//! The Provider forwards the authenticated technical-caller bearer credential +//! unchanged to the configured remote Provider resource server. The remote +//! Provider owns independent JWT validation and target derivation. Every +//! Provider response body is bounded by a private, non-configurable 1 MiB +//! absolute safety ceiling; see that constant's documentation in the +//! transport module for rationale. +//! +//! For hostname endpoints, construction captures the first UDP nameserver from +//! platform DNS configuration. Each operation composes one A query and one +//! AAAA query into one logical DNS resolution. Neither query is retried or +//! fails over, and both operation-owned request futures are dropped together +//! when the operation ends. + +#![forbid(unsafe_code)] + +mod client; +mod error; +mod wire; + +use std::time::Duration; + +use permissionsync_core::{ + BoxFuture, DesiredStateEnvelope, PermissionProvider, PermissionProviderError, + PermissionProviderRequest, +}; + +pub use error::GenericRestPermissionProviderConfigError; + +/// Runtime values for one Generic REST Permission Provider. +/// +/// The endpoint and additional trust anchors are deployment-specific values. +/// The raw technical-caller bearer credential is deliberately not configuration: +/// it is supplied by Core for each resolution attempt. +pub struct GenericRestPermissionProviderConfig { + /// Complete HTTPS URI for the remote Provider request. + pub endpoint: String, + /// Positive upper bound for one Provider resolution attempt. + pub operation_timeout: Duration, + /// PEM-encoded private trust anchors added to the platform system roots. + /// + /// Each element is a PEM certificate bundle and may contain one or more + /// concatenated X.509 certificates. + pub additional_trust_anchors_pem: Vec>, +} + +/// A concrete HTTPS client for the Generic REST Permission Provider contract. +/// +/// The client forwards the exact technical-caller bearer supplied by Core. It +/// neither parses that credential nor derives target semantics from it. Each +/// resolution attempt performs its own single DNS resolution, TCP connect, +/// and TLS/HTTP handshake using this reusable TLS connector; there is no +/// connection pool and no background connection-driving task. +pub struct GenericRestPermissionProvider { + endpoint: hyper::Uri, + endpoint_host: client::EndpointHost, + operation_timeout: Duration, + tls_connector: client::TlsConnector, +} + +impl GenericRestPermissionProvider { + /// Validates Provider configuration and builds a reusable TLS connector. + /// + /// System trust roots remain enabled. `additional_trust_anchors_pem` may be + /// empty and, when present, only adds private trust anchors parsed from + /// PEM certificate bundles. + pub fn new( + config: GenericRestPermissionProviderConfig, + ) -> Result { + if config.operation_timeout.is_zero() { + return Err(GenericRestPermissionProviderConfigError::new()); + } + + let endpoint = client::parse_endpoint(&config.endpoint)?; + let endpoint_host = client::classify_endpoint_host(&endpoint)?; + let tls_connector = client::build_tls_connector(&config.additional_trust_anchors_pem)?; + + Ok(Self { + endpoint, + endpoint_host, + operation_timeout: config.operation_timeout, + tls_connector, + }) + } +} + +impl PermissionProvider for GenericRestPermissionProvider { + fn resolve<'a>( + &'a self, + request: PermissionProviderRequest<'a>, + ) -> BoxFuture<'a, Result> { + Box::pin(async move { + client::resolve( + &self.tls_connector, + &self.endpoint, + &self.endpoint_host, + self.operation_timeout, + request, + ) + .await + .map_err(PermissionProviderError::new) + }) + } +} diff --git a/crates/permissionsync-provider-generic-rest/src/wire.rs b/crates/permissionsync-provider-generic-rest/src/wire.rs new file mode 100644 index 0000000..67b2737 --- /dev/null +++ b/crates/permissionsync-provider-generic-rest/src/wire.rs @@ -0,0 +1,319 @@ +use hyper::{ + HeaderMap, + header::{CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE}, +}; +use permissionsync_core::{DesiredStateEnvelope, EnvelopeVersion, IdentityContext, OpaquePayload}; +use serde::{Deserialize, Serialize}; + +use crate::error::ProviderFailure; + +/// Serializes the fixed Provider request object without changing identity values. +pub(crate) fn serialize_request_body( + identity: &IdentityContext, +) -> Result, ProviderFailure> { + serde_json::to_vec(&RequestBody { + username: identity.username(), + groups: identity.groups(), + }) + .map_err(|_| ProviderFailure::RequestSerialization) +} + +/// Validates all `Content-Type` field values on a candidate successful response. +pub(crate) fn validate_success_content_type(content_types: &[&str]) -> Result<(), ProviderFailure> { + let [content_type] = content_types else { + return Err(ProviderFailure::ResponseMetadata); + }; + + let media_type = content_type + .parse::() + .map_err(|_| ProviderFailure::ResponseMetadata)?; + + if !media_type + .type_() + .as_str() + .eq_ignore_ascii_case("application") + || !media_type.subtype().as_str().eq_ignore_ascii_case("json") + { + return Err(ProviderFailure::ResponseMetadata); + } + + // `Mime::get_param` only ever returns the first matching parameter, so a + // contradictory or merely duplicated `charset` parameter would otherwise + // go unnoticed. Inspect every parameter explicitly instead. + let mut charsets = media_type + .params() + .filter(|(name, _)| *name == mime::CHARSET) + .map(|(_, value)| value); + + if let Some(charset) = charsets.next() { + if charsets.next().is_some() { + return Err(ProviderFailure::ResponseMetadata); + } + + let charset = charset.as_str(); + if !charset.eq_ignore_ascii_case("utf-8") && !charset.eq_ignore_ascii_case("utf8") { + return Err(ProviderFailure::ResponseMetadata); + } + } + + Ok(()) +} + +/// Validates all `Content-Encoding` field values on a candidate successful response. +pub(crate) fn validate_success_content_encoding( + content_encodings: &[&str], +) -> Result<(), ProviderFailure> { + for content_encoding in content_encodings { + let mut found_coding = false; + + for coding in content_encoding.split(',') { + let coding = coding.trim(); + if coding.is_empty() || !coding.eq_ignore_ascii_case("identity") { + return Err(ProviderFailure::ResponseMetadata); + } + found_coding = true; + } + + if !found_coding { + return Err(ProviderFailure::ResponseMetadata); + } + } + + Ok(()) +} + +/// Validates metadata for a candidate successful response before body buffering. +pub(crate) fn validate_success_headers( + headers: &HeaderMap, + limit: usize, +) -> Result, ProviderFailure> { + let content_types = header_values(headers, CONTENT_TYPE)?; + validate_success_content_type(&content_types)?; + + let content_encodings = header_values(headers, CONTENT_ENCODING)?; + validate_success_content_encoding(&content_encodings)?; + + let content_lengths = header_values(headers, CONTENT_LENGTH)?; + let declared_length = match content_lengths.as_slice() { + [] => None, + [content_length] => Some(parse_content_length(content_length)?), + _ => return Err(ProviderFailure::ResponseMetadata), + }; + + if declared_length.is_some_and(|content_length| content_length > limit) { + return Err(ProviderFailure::ResponseTooLarge); + } + + Ok(declared_length) +} + +/// Parses one complete, structurally valid Provider desired-state envelope. +pub(crate) fn parse_success_envelope(body: &[u8]) -> Result { + let mut deserializer = serde_json::Deserializer::from_slice(body); + let envelope = ResponseEnvelope::deserialize(&mut deserializer) + .map_err(|_| ProviderFailure::ResponseEnvelope)?; + serde_json::de::Deserializer::end(&mut deserializer) + .map_err(|_| ProviderFailure::ResponseEnvelope)?; + + let payload = OpaquePayload::try_from(envelope.payload.get().to_owned()) + .map_err(|_| ProviderFailure::ResponseEnvelope)?; + + Ok(DesiredStateEnvelope::new( + EnvelopeVersion::new(envelope.version), + payload, + )) +} + +#[derive(Serialize)] +struct RequestBody<'a> { + username: &'a str, + groups: &'a [String], +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ResponseEnvelope { + version: u64, + payload: Box, +} + +fn header_values( + headers: &HeaderMap, + name: hyper::header::HeaderName, +) -> Result, ProviderFailure> { + headers + .get_all(name) + .iter() + .map(|value| { + value + .to_str() + .map_err(|_| ProviderFailure::ResponseMetadata) + }) + .collect() +} + +fn parse_content_length(content_length: &str) -> Result { + if content_length.is_empty() || !content_length.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(ProviderFailure::ResponseMetadata); + } + + content_length + .parse() + .map_err(|_| ProviderFailure::ResponseMetadata) +} + +#[cfg(test)] +mod tests { + use permissionsync_core::IdentityContext; + + use super::{ + parse_success_envelope, serialize_request_body, validate_success_content_encoding, + validate_success_content_type, validate_success_headers, + }; + use hyper::{ + HeaderMap, + header::{CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, HeaderValue}, + }; + + #[test] + fn request_serialization_preserves_identity_values_and_member_order() { + let identity = IdentityContext::new( + " m\u{00fc}ller\t".to_owned(), + vec![ + "/staff".to_owned(), + "/staff".to_owned(), + " group ".to_owned(), + ], + ); + + let body = serialize_request_body(&identity).unwrap(); + + assert_eq!( + body, + "{\"username\":\" m\u{00fc}ller\\t\",\"groups\":[\"/staff\",\"/staff\",\" group \"]}" + .as_bytes() + ); + } + + #[test] + fn accepts_json_content_type_with_optional_utf8_compatible_charset() { + for content_type in [ + "application/json", + "application/json; charset=utf-8", + "Application/Json; charset=UTF-8", + "application/json; charset=utf8", + r#"application/json; charset="UTF-8""#, + ] { + assert!( + validate_success_content_type(&[content_type]).is_ok(), + "{content_type}" + ); + } + } + + #[test] + fn rejects_non_json_contradictory_or_malformed_content_type() { + for content_types in [ + &[][..], + &["application/json", "application/json"][..], + &["application/json", "text/plain"][..], + &["application/problem+json"][..], + &["text/json"][..], + &["application/json; charset=iso-8859-1"][..], + &["application/json; charset=utf-8; charset=iso-8859-1"][..], + &["application/json; charset=utf-8; charset=utf-8"][..], + &[""][..], + &[r#"application/json; charset="unterminated"#][..], + ] { + assert!( + validate_success_content_type(content_types).is_err(), + "{content_types:?}" + ); + } + } + + #[test] + fn content_encoding_allows_only_identity_tokens() { + for content_encodings in [&[][..], &["identity"][..], &["identity, IDENTITY"][..]] { + assert!(validate_success_content_encoding(content_encodings).is_ok()); + } + + for content_encodings in [ + &[""][..], + &["gzip"][..], + &["identity, br"][..], + &["identity", "gzip"][..], + ] { + assert!(validate_success_content_encoding(content_encodings).is_err()); + } + } + + #[test] + fn success_headers_require_json_identity_encoding_and_valid_bounded_length() { + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); + headers.insert(CONTENT_ENCODING, HeaderValue::from_static("identity")); + headers.insert(CONTENT_LENGTH, HeaderValue::from_static("3")); + + assert_eq!(validate_success_headers(&headers, 3).unwrap(), Some(3)); + + headers.insert(CONTENT_LENGTH, HeaderValue::from_static("4")); + assert!(validate_success_headers(&headers, 3).is_err()); + + headers.insert(CONTENT_LENGTH, HeaderValue::from_static("not-a-length")); + assert!(validate_success_headers(&headers, 3).is_err()); + + headers.append(CONTENT_LENGTH, HeaderValue::from_static("3")); + assert!(validate_success_headers(&headers, 3).is_err()); + + headers.insert(CONTENT_ENCODING, HeaderValue::from_static("br")); + assert!(validate_success_headers(&headers, 3).is_err()); + } + + #[test] + fn accepts_every_valid_payload_shape_and_version_boundary() { + let cases: &[(&str, u64, &str)] = &[ + (r#"{"version":0,"payload":null}"#, 0, "null"), + (r#"{"version":1,"payload":true}"#, 1, "true"), + (r#"{"version":1,"payload":42}"#, 1, "42"), + (r#"{"version":1,"payload":"value"}"#, 1, r#""value""#), + (r#"{"version":1,"payload":[]}"#, 1, "[]"), + (r#"{"version":1,"payload":{}}"#, 1, "{}"), + ( + r#"{"version":18446744073709551615,"payload":null}"#, + u64::MAX, + "null", + ), + ]; + + for (body, expected_version, expected_payload) in cases { + let envelope = parse_success_envelope(body.as_bytes()) + .unwrap_or_else(|_| panic!("expected a valid envelope: {body}")); + assert_eq!(envelope.version().get(), *expected_version, "{body}"); + assert_eq!(envelope.payload().as_json(), *expected_payload, "{body}"); + } + } + + #[test] + fn rejects_every_invalid_envelope_shape() { + for body in [ + br#"{"payload":null}"#.as_slice(), + br#"{"version":1}"#.as_slice(), + br#"{"version":1,"payload":null,"unexpected":true}"#.as_slice(), + br#"{"version":1,"version":2,"payload":null}"#.as_slice(), + br#"{"version":1,"payload":null,"payload":true}"#.as_slice(), + br#"{"version":"1","payload":null}"#.as_slice(), + br#"{"version":-1,"payload":null}"#.as_slice(), + br#"{"version":1.5,"payload":null}"#.as_slice(), + br#"{"version":18446744073709551616,"payload":null}"#.as_slice(), + br#"{"version":1,"payload":null"#.as_slice(), + br#"{"version":1,"payload":null} null"#.as_slice(), + ] { + assert!( + parse_success_envelope(body).is_err(), + "{}", + String::from_utf8_lossy(body) + ); + } + } +} diff --git a/crates/permissionsync-provider-generic-rest/tests/generic_rest_provider.rs b/crates/permissionsync-provider-generic-rest/tests/generic_rest_provider.rs new file mode 100644 index 0000000..974ab37 --- /dev/null +++ b/crates/permissionsync-provider-generic-rest/tests/generic_rest_provider.rs @@ -0,0 +1,1446 @@ +//! Hermetic behavioral tests for the Generic REST Permission Provider. +//! +//! These tests exercise only the crate's public API together with a local +//! loopback TLS server and fully deterministic in-memory Ed25519 certificates. +//! Test keys are derived at runtime from fixed public labels via SHA-256 +//! (never generated randomly, and never stored as raw private-key bytes in +//! source), and certificate validity uses a fixed absolute timestamp window +//! (never the wall clock). They never use the public Internet, committed key +//! material, arbitrary sleeps, or unseeded randomness, in line with [ADR +//! 0008](../../../docs/adr/0008-generic-rest-permission-provider-wire-and-transport-contract.md) +//! and the repository testing policy. + +use std::{ + error::Error, + future::Future, + io::ErrorKind, + sync::{ + Arc, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + task::{Context, Poll, Waker}, + time::{Duration, Instant}, +}; + +use openssl::{ + asn1::Asn1Time, + bn::BigNum, + hash::{MessageDigest, hash}, + nid::Nid, + pkey::{Id, PKey, PKeyRef, Private}, + x509::{ + X509, X509Name, X509NameRef, + extension::{BasicConstraints, KeyUsage, SubjectAlternativeName}, + }, +}; +use permissionsync_core::{ + CancellationSignal, IdentityContext, LogicalTarget, PermissionProvider, + PermissionProviderRequest, SynchronizationContext, TechnicalCallerBearerToken, +}; +use permissionsync_provider_generic_rest::{ + GenericRestPermissionProvider, GenericRestPermissionProviderConfig, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + time::Instant as TokioInstant, +}; +use tokio_native_tls::TlsAcceptor; + +const LOOPBACK_ADDRESS: &str = "127.0.0.1"; + +/// Fixed, non-secret public labels used to deterministically derive +/// ephemeral Ed25519 test keys at runtime via SHA-256 (see +/// [`ed25519_key_from_label`]). A label is not key material: it is hashed +/// with a mature, existing hash implementation to produce the 32-byte +/// Ed25519 raw private-key seed for that identity. The same label always +/// yields the same key, and distinct labels yield independent keys, which is +/// what the untrusted-root test below relies on. No private-key bytes are +/// ever written to source, and no RNG is used anywhere in this file. +const PRIMARY_ROOT_KEY_LABEL: &str = "permissionsync-test-primary-root-v1"; +const PRIMARY_LEAF_KEY_LABEL: &str = "permissionsync-test-primary-leaf-v1"; +const ALTERNATE_ROOT_KEY_LABEL: &str = "permissionsync-test-alternate-root-v1"; +const ALTERNATE_LEAF_KEY_LABEL: &str = "permissionsync-test-alternate-leaf-v1"; + +const ROOT_CERTIFICATE_SERIAL: u32 = 1; +const LEAF_CERTIFICATE_SERIAL: u32 = 2; + +/// Fixed absolute certificate validity window shared by every test fixture. +/// Both bounds are fixed Unix timestamps, never derived from the wall clock: +/// a broad multi-decade window chosen only to comfortably cover any test +/// run, not a claim that the fixture is perpetually valid. +const CERTIFICATE_NOT_BEFORE_UNIX_SECONDS: i64 = 1_700_000_000; // 2023-11-14T22:13:20Z +const CERTIFICATE_NOT_AFTER_UNIX_SECONDS: i64 = 4_100_000_000; // 2099-11-16T09:46:40Z + +// --- Configuration validation ----------------------------------------------- + +#[test] +fn rejects_non_https_endpoint() { + let error = GenericRestPermissionProvider::new(test_config("http://127.0.0.1/permissions")); + + assert!(error.is_err()); +} + +#[test] +fn rejects_endpoint_without_authority_or_host() { + for endpoint in ["https:///permissions", "https://", "not-a-uri"] { + assert!( + GenericRestPermissionProvider::new(test_config(endpoint)).is_err(), + "{endpoint}" + ); + } +} + +#[test] +fn rejects_endpoint_with_userinfo_query_fragment_or_template() { + for endpoint in [ + "https://user:pass@127.0.0.1/permissions", + "https://127.0.0.1/permissions?x=1", + "https://127.0.0.1/permissions#frag", + "https://127.0.0.1/{id}", + ] { + assert!( + GenericRestPermissionProvider::new(test_config(endpoint)).is_err(), + "{endpoint}" + ); + } +} + +#[test] +fn rejects_zero_operation_timeout() { + let mut config = test_config("https://127.0.0.1/permissions"); + config.operation_timeout = Duration::ZERO; + + assert!(GenericRestPermissionProvider::new(config).is_err()); +} + +#[test] +fn rejects_malformed_trust_anchor_pem_bytes() { + let mut config = test_config("https://127.0.0.1/permissions"); + config.additional_trust_anchors_pem = vec![vec![0, 1, 2, 3]]; + + assert!(GenericRestPermissionProvider::new(config).is_err()); +} + +#[test] +fn rejects_a_pem_blob_with_no_usable_certificate() { + let mut config = test_config("https://127.0.0.1/permissions"); + // No PEM start line at all: `Certificate::stack_from_pem` accepts this + // and returns an empty certificate list rather than an error, so it must + // still be explicitly rejected, since every configured PEM blob must + // contribute at least one certificate. + config.additional_trust_anchors_pem = vec![b"not a pem certificate\n".to_vec()]; + + assert!(GenericRestPermissionProvider::new(config).is_err()); +} + +#[test] +fn accepts_empty_additional_trust_anchors() { + let config = test_config("https://127.0.0.1/permissions"); + + assert!(GenericRestPermissionProvider::new(config).is_ok()); +} + +// --- Runtime, cancellation, and deadline short-circuits --------------------- + +#[test] +fn fails_safely_without_an_active_tokio_runtime() { + let provider = GenericRestPermissionProvider::new(test_config("https://127.0.0.1/permissions")) + .expect("valid configuration"); + let identity = IdentityContext::new("jdoe".to_owned(), vec![]); + let target = LogicalTarget::try_from("example".to_owned()).expect("valid target"); + let token = TechnicalCallerBearerToken::new("token".to_owned()); + let cancellation = NeverCancelled; + let context = SynchronizationContext::new(far_future_deadline(), &cancellation); + let request = PermissionProviderRequest::new(&identity, &target, &token, context); + + let outcome = poll_once(provider.resolve(request)); + + assert!(outcome.is_err()); +} + +#[tokio::test] +async fn fails_immediately_when_cancellation_is_already_requested() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let provider = GenericRestPermissionProvider::new(test_config(&format!( + "https://{LOOPBACK_ADDRESS}:{port}/permissions" + ))) + .expect("valid configuration"); + let identity = IdentityContext::new("jdoe".to_owned(), vec![]); + let target = LogicalTarget::try_from("example".to_owned()).expect("valid target"); + let token = TechnicalCallerBearerToken::new("token".to_owned()); + let cancellation = AlwaysCancelled; + let context = SynchronizationContext::new(far_future_deadline(), &cancellation); + let request = PermissionProviderRequest::new(&identity, &target, &token, context); + + let outcome = provider.resolve(request).await; + + assert!(outcome.is_err()); + assert_listener_received_no_connection(&listener); +} + +#[tokio::test] +async fn fails_immediately_when_the_context_deadline_has_already_passed() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let provider = GenericRestPermissionProvider::new(test_config(&format!( + "https://{LOOPBACK_ADDRESS}:{port}/permissions" + ))) + .expect("valid configuration"); + let identity = IdentityContext::new("jdoe".to_owned(), vec![]); + let target = LogicalTarget::try_from("example".to_owned()).expect("valid target"); + let token = TechnicalCallerBearerToken::new("token".to_owned()); + let cancellation = NeverCancelled; + let expired_deadline = Instant::now() + .checked_sub(Duration::from_secs(1)) + .expect("test process uptime exceeds one second"); + let context = SynchronizationContext::new(expired_deadline, &cancellation); + let request = PermissionProviderRequest::new(&identity, &target, &token, context); + + let outcome = provider.resolve(request).await; + + assert!(outcome.is_err()); + assert_listener_received_no_connection(&listener); +} + +#[tokio::test] +async fn fails_before_any_i_o_for_an_invalid_bearer_token() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let provider = GenericRestPermissionProvider::new(test_config(&format!( + "https://{LOOPBACK_ADDRESS}:{port}/permissions" + ))) + .expect("valid configuration"); + let identity = IdentityContext::new("jdoe".to_owned(), vec![]); + let target = LogicalTarget::try_from("example".to_owned()).expect("valid target"); + let token = TechnicalCallerBearerToken::new("invalid\r\nbearer".to_owned()); + let cancellation = NeverCancelled; + let context = SynchronizationContext::new(far_future_deadline(), &cancellation); + let request = PermissionProviderRequest::new(&identity, &target, &token, context); + + let outcome = provider.resolve(request).await; + + assert!(outcome.is_err()); + assert_listener_received_no_connection(&listener); +} + +#[tokio::test(start_paused = true)] +async fn fails_when_the_provider_operation_timeout_elapses() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let identity = build_primary_test_identity(LOOPBACK_ADDRESS); + let acceptor = TlsAcceptor::from(identity.acceptor); + + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.expect("accept connection"); + let mut stream = acceptor.accept(socket).await.expect("tls handshake"); + let _ = read_request(&mut stream).await; + // Sleep far longer than the configured operation timeout before + // responding, simulating an unresponsive Provider. + tokio::time::sleep(Duration::from_secs(60)).await; + let _ = stream + .write_all(&ok_response(br#"{"version":1,"payload":null}"#)) + .await; + }); + + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}/permissions")); + config.operation_timeout = Duration::from_millis(50); + config.additional_trust_anchors_pem = vec![identity.trust_anchor_pem]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let identity_context = IdentityContext::new("jdoe".to_owned(), vec![]); + let target = LogicalTarget::try_from("example".to_owned()).expect("valid target"); + let token = TechnicalCallerBearerToken::new("token".to_owned()); + let cancellation = NeverCancelled; + let context = SynchronizationContext::new(far_future_deadline(), &cancellation); + let request = PermissionProviderRequest::new(&identity_context, &target, &token, context); + + let outcome = provider.resolve(request).await; + + assert!(outcome.is_err()); + server.abort(); +} + +#[tokio::test(start_paused = true)] +async fn the_shorter_core_deadline_wins_over_a_longer_provider_timeout() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let identity = build_primary_test_identity(LOOPBACK_ADDRESS); + let acceptor = TlsAcceptor::from(identity.acceptor); + + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.expect("accept connection"); + let mut stream = acceptor.accept(socket).await.expect("tls handshake"); + let _ = read_request(&mut stream).await; + // Never respond: the Provider must be bounded by the much shorter + // Core deadline below, not by its own longer operation timeout. + std::future::pending::<()>().await + }); + + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}/permissions")); + // Deliberately much longer than the Core deadline used below, so that + // observing a fast failure proves the Core deadline, not this timeout, + // bounded the operation. + config.operation_timeout = Duration::from_secs(60); + config.additional_trust_anchors_pem = vec![identity.trust_anchor_pem]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let identity_context = IdentityContext::new("jdoe".to_owned(), vec![]); + let target = LogicalTarget::try_from("example".to_owned()).expect("valid target"); + let token = TechnicalCallerBearerToken::new("token".to_owned()); + let cancellation = NeverCancelled; + let core_deadline = Instant::now() + Duration::from_millis(50); + let context = SynchronizationContext::new(core_deadline, &cancellation); + let request = PermissionProviderRequest::new(&identity_context, &target, &token, context); + + let started = TokioInstant::now(); + let outcome = provider.resolve(request).await; + let elapsed = started.elapsed(); + + assert!(outcome.is_err()); + // Comfortably between the 50ms Core deadline and the 60s Provider + // timeout: only a bug that ignored the Core deadline could take this + // long under the paused, auto-advancing virtual clock. + assert!( + elapsed < Duration::from_secs(5), + "resolve() waited {elapsed:?}, longer than the configured Core deadline allows" + ); + + server.abort(); +} + +#[tokio::test] +async fn cancellation_after_the_request_is_sent_is_observed_before_reading_the_response() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let identity = build_primary_test_identity(LOOPBACK_ADDRESS); + let trust_anchor = identity.trust_anchor_pem.clone(); + let acceptor = TlsAcceptor::from(identity.acceptor); + + let (request_received_tx, request_received_rx) = tokio::sync::oneshot::channel::<()>(); + let (proceed_tx, proceed_rx) = tokio::sync::oneshot::channel::<()>(); + + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.expect("accept connection"); + let mut stream = acceptor.accept(socket).await.expect("tls handshake"); + let _ = read_request(&mut stream).await; + + request_received_tx + .send(()) + .expect("test still waiting for the request-received signal"); + proceed_rx.await.expect("test still coordinating"); + + let response = ok_response(br#"{"version":1,"payload":null}"#); + let _ = stream.write_all(&response).await; + let _ = stream.shutdown().await; + }); + + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}/permissions")); + config.additional_trust_anchors_pem = vec![trust_anchor]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let identity_context = IdentityContext::new("jdoe".to_owned(), vec![]); + let target = LogicalTarget::try_from("example".to_owned()).expect("valid target"); + let token = TechnicalCallerBearerToken::new("token".to_owned()); + let cancellation = FlippableCancellation::new(); + let context = SynchronizationContext::new(far_future_deadline(), &cancellation); + let request = PermissionProviderRequest::new(&identity_context, &target, &token, context); + + // The Core `CancellationSignal` contract has no wake mechanism, so + // cancellation is only ever observed at a controlled boundary. This + // coordinator flips the flag only after confirming the request was + // already sent, then lets the server respond so the client reaches its + // next check_context boundary (immediately after receiving the response, + // before the status is even inspected) with cancellation already set. + let coordinator = async { + request_received_rx + .await + .expect("server confirmed the request was received"); + cancellation.cancel(); + proceed_tx + .send(()) + .expect("server still waiting to proceed"); + }; + + let (outcome, ()) = tokio::join!(provider.resolve(request), coordinator); + + assert!(outcome.is_err()); + server.await.expect("server task completed"); +} + +// --- Public error redaction --------------------------------------------------- + +#[tokio::test] +async fn public_provider_error_redacts_sensitive_values() { + let provider = + GenericRestPermissionProvider::new(test_config("https://127.0.0.1/sentinel-endpoint-path")) + .expect("valid configuration"); + let identity = IdentityContext::new( + "sentinel-fake-username".to_owned(), + vec!["sentinel-fake-group".to_owned()], + ); + let target = LogicalTarget::try_from("example".to_owned()).expect("valid target"); + // A bearer value containing a raw CR/LF cannot become a valid header + // value, so this fails before any network I/O while still exercising the + // exact public error path returned from `resolve()`. + let token = TechnicalCallerBearerToken::new("sentinel-fake-bearer\r\ntoken".to_owned()); + let cancellation = NeverCancelled; + let context = SynchronizationContext::new(far_future_deadline(), &cancellation); + let request = PermissionProviderRequest::new(&identity, &target, &token, context); + + let error = match provider.resolve(request).await { + Ok(_) => panic!("expected an invalid bearer header value to fail"), + Err(error) => error, + }; + + let debug_output = format!("{error:?}"); + let display_output = error.to_string(); + let source_output = error.source().map(ToString::to_string); + + for sensitive in [ + "sentinel-fake-username", + "sentinel-fake-group", + "sentinel-fake-bearer", + "sentinel-endpoint-path", + ] { + assert!(!debug_output.contains(sensitive), "{debug_output}"); + assert!(!display_output.contains(sensitive), "{display_output}"); + if let Some(source_text) = &source_output { + assert!(!source_text.contains(sensitive), "{source_text}"); + } + } +} + +// --- TLS trust and hostname validation --------------------------------------- + +#[tokio::test] +async fn accepts_a_server_certificate_signed_by_a_configured_trust_anchor() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let identity = build_primary_test_identity(LOOPBACK_ADDRESS); + let acceptor = TlsAcceptor::from(identity.acceptor); + + let response = ok_response(br#"{"version":7,"payload":{"role":"operator"}}"#); + let server = tokio::spawn(serve_one_request(listener, acceptor, response)); + + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}")); + config.additional_trust_anchors_pem = vec![identity.trust_anchor_pem]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let identity_context = IdentityContext::new( + "jdoe".to_owned(), + vec!["/staff".to_owned(), "/staff".to_owned()], + ); + let target = LogicalTarget::try_from("example".to_owned()).expect("valid target"); + let token = TechnicalCallerBearerToken::new("secret-token".to_owned()); + let cancellation = NeverCancelled; + let context = SynchronizationContext::new(far_future_deadline(), &cancellation); + let request = PermissionProviderRequest::new(&identity_context, &target, &token, context); + + let envelope = provider + .resolve(request) + .await + .expect("successful resolution"); + + assert_eq!(envelope.version().get(), 7); + assert_eq!(envelope.payload().as_json(), r#"{"role":"operator"}"#); + + let received = server.await.expect("server task completed"); + assert_eq!(received.request_line, "POST / HTTP/1.1"); + assert_eq!( + received.header("authorization"), + Some("Bearer secret-token") + ); + assert_eq!(received.header("content-type"), Some("application/json")); + assert_eq!(received.header("accept"), Some("application/json")); + assert_eq!(received.header("accept-encoding"), Some("")); + assert_eq!( + received.body, + br#"{"username":"jdoe","groups":["/staff","/staff"]}"# + ); +} + +#[tokio::test] +async fn accepts_a_pem_bundle_containing_the_needed_ca_among_multiple_certificates() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let identity = build_primary_test_identity(LOOPBACK_ADDRESS); + let acceptor = TlsAcceptor::from(identity.acceptor); + + // An unrelated CA is concatenated with the CA the server chain actually + // needs, exercising a multi-certificate PEM bundle rather than a single + // certificate. + let unrelated_trust_anchor_pem = build_test_identity( + LOOPBACK_ADDRESS, + ALTERNATE_ROOT_KEY_LABEL, + ALTERNATE_LEAF_KEY_LABEL, + ) + .trust_anchor_pem; + let mut trust_anchor_bundle_pem = unrelated_trust_anchor_pem; + trust_anchor_bundle_pem.extend_from_slice(&identity.trust_anchor_pem); + + let response = ok_response(br#"{"version":1,"payload":null}"#); + let server = tokio::spawn(serve_one_request(listener, acceptor, response)); + + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}/permissions")); + config.additional_trust_anchors_pem = vec![trust_anchor_bundle_pem]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let outcome = resolve_with_defaults(&provider).await; + + assert!(outcome.is_ok()); + server.await.expect("server task completed"); +} + +#[tokio::test] +async fn rejects_a_server_certificate_signed_by_an_untrusted_root() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let untrusted_identity = build_test_identity( + LOOPBACK_ADDRESS, + PRIMARY_ROOT_KEY_LABEL, + PRIMARY_LEAF_KEY_LABEL, + ); + // A distinct, unrelated deterministic root: the server's certificate + // chain must not validate against it. + let unrelated_trust_anchor = build_test_identity( + LOOPBACK_ADDRESS, + ALTERNATE_ROOT_KEY_LABEL, + ALTERNATE_LEAF_KEY_LABEL, + ) + .trust_anchor_pem; + let acceptor = TlsAcceptor::from(untrusted_identity.acceptor); + + let server = tokio::spawn(accept_ignoring_handshake_errors(listener, acceptor)); + + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}/permissions")); + config.additional_trust_anchors_pem = vec![unrelated_trust_anchor]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let outcome = resolve_with_defaults(&provider).await; + + assert!(outcome.is_err()); + server.await.expect("server task completed"); +} + +#[tokio::test] +async fn rejects_a_server_certificate_with_a_mismatched_host() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let identity = build_primary_test_identity("192.0.2.1"); + let trust_anchor = identity.trust_anchor_pem.clone(); + let acceptor = TlsAcceptor::from(identity.acceptor); + + let server = tokio::spawn(accept_ignoring_handshake_errors(listener, acceptor)); + + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}/permissions")); + config.additional_trust_anchors_pem = vec![trust_anchor]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let outcome = resolve_with_defaults(&provider).await; + + assert!(outcome.is_err()); + server.await.expect("server task completed"); +} + +// --- Single-attempt delivery ------------------------------------------------- + +#[tokio::test] +async fn one_resolve_call_makes_at_most_one_outbound_connection_attempt() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let identity = build_primary_test_identity(LOOPBACK_ADDRESS); + let trust_anchor = identity.trust_anchor_pem.clone(); + let acceptor = TlsAcceptor::from(identity.acceptor); + let response = ok_response(br#"{"version":1,"payload":null}"#); + + let accepted_connections = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&accepted_connections); + // Keeps accepting so that a defective client making a second connection + // attempt would be observed here rather than merely hanging; the + // assertion below is what actually proves at most one attempt occurred. + let server = tokio::spawn(async move { + while let Ok((socket, _)) = listener.accept().await { + counter.fetch_add(1, Ordering::SeqCst); + if let Ok(mut stream) = acceptor.accept(socket).await { + let _ = read_request(&mut stream).await; + let _ = stream.write_all(&response).await; + let _ = stream.shutdown().await; + } + } + }); + + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}/permissions")); + config.additional_trust_anchors_pem = vec![trust_anchor]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let outcome = resolve_with_defaults(&provider).await; + + assert!(outcome.is_ok()); + assert_eq!(accepted_connections.load(Ordering::SeqCst), 1); + + server.abort(); +} + +#[tokio::test] +async fn a_failed_exchange_produces_no_retry_or_second_connection_attempt() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let identity = build_primary_test_identity(LOOPBACK_ADDRESS); + let trust_anchor = identity.trust_anchor_pem.clone(); + let acceptor = TlsAcceptor::from(identity.acceptor); + + let accepted_connections = Arc::new(AtomicUsize::new(0)); + let counter = Arc::clone(&accepted_connections); + // Keeps accepting indefinitely so that a defective client's second + // connection attempt would be observed here; the assertion below is what + // actually proves at most one attempt occurred, deterministically, with + // no timing guesses. + let server = tokio::spawn(async move { + while let Ok((socket, _)) = listener.accept().await { + counter.fetch_add(1, Ordering::SeqCst); + if let Ok(mut stream) = acceptor.accept(socket).await { + let _ = read_request(&mut stream).await; + // Terminate the exchange without ever sending a response, + // simulating a Provider that accepts the request but fails + // before producing any output. + let _ = stream.shutdown().await; + } + } + }); + + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}/permissions")); + config.additional_trust_anchors_pem = vec![trust_anchor]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let outcome = resolve_with_defaults(&provider).await; + + assert!(outcome.is_err()); + assert_eq!(accepted_connections.load(Ordering::SeqCst), 1); + + server.abort(); +} + +// --- Connection lifecycle ----------------------------------------------------- + +#[tokio::test] +async fn resolves_successfully_without_waiting_for_the_server_to_close_the_connection() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let identity = build_primary_test_identity(LOOPBACK_ADDRESS); + let trust_anchor = identity.trust_anchor_pem.clone(); + let acceptor = TlsAcceptor::from(identity.acceptor); + + let (client_done_tx, client_done_rx) = tokio::sync::oneshot::channel::<()>(); + let (eof_observed_tx, eof_observed_rx) = tokio::sync::oneshot::channel::(); + + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.expect("accept connection"); + let mut stream = acceptor.accept(socket).await.expect("tls handshake"); + let _ = read_request(&mut stream).await; + + // No `Connection: close`, and a correct, finite `Content-Length`: + // the socket stays open after a complete response. + let response = build_keep_alive_response(br#"{"version":1,"payload":null}"#); + stream.write_all(&response).await.expect("write response"); + + // Wait until the client's resolve() call has already returned + // before checking for a closed connection: this proves resolve() + // did not need the close to complete, and that the client actually + // dropped its own connection afterward rather than leaving a + // detached driver alive. + client_done_rx.await.expect("client completion signal"); + + let mut probe = [0_u8; 1]; + let read = stream.read(&mut probe).await.unwrap_or(0); + let _ = eof_observed_tx.send(read == 0); + }); + + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}/permissions")); + config.additional_trust_anchors_pem = vec![trust_anchor]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let envelope = resolve_with_defaults(&provider) + .await + .expect("successful resolution without waiting for a connection close"); + assert_eq!(envelope.version().get(), 1); + + client_done_tx + .send(()) + .expect("server still waiting for the completion signal"); + + // This watchdog bounds only the test's post-resolution EOF observation; + // it is deliberately generous and is not an operation-latency assertion. + let observed_eof = tokio::time::timeout(Duration::from_secs(5), eof_observed_rx) + .await + .expect("server did not report its eof observation before the test watchdog elapsed") + .expect("server reported its eof observation"); + assert!( + observed_eof, + "server did not observe the client closing its connection after resolve() returned" + ); + + tokio::time::timeout(Duration::from_secs(5), server) + .await + .expect("server did not complete before the test watchdog elapsed") + .expect("server task completed"); +} + +// --- Response validation ------------------------------------------------------ + +#[tokio::test] +async fn rejects_every_non_200_status_including_redirects() { + for (status, reason) in [(404, "Not Found"), (302, "Found"), (201, "Created")] { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let identity = build_primary_test_identity(LOOPBACK_ADDRESS); + let trust_anchor = identity.trust_anchor_pem.clone(); + let acceptor = TlsAcceptor::from(identity.acceptor); + + let response = build_response(status, reason, "application/json", &[], b"{}"); + let server = tokio::spawn(serve_one_request(listener, acceptor, response)); + + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}/permissions")); + config.additional_trust_anchors_pem = vec![trust_anchor]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let outcome = resolve_with_defaults(&provider).await; + + assert!(outcome.is_err(), "status {status}"); + server.await.expect("server task completed"); + } +} + +#[tokio::test] +async fn rejects_provider_204_no_content_as_a_failure() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let identity = build_primary_test_identity(LOOPBACK_ADDRESS); + let trust_anchor = identity.trust_anchor_pem.clone(); + let acceptor = TlsAcceptor::from(identity.acceptor); + + // A remote Provider `204` must never become a successful resolution: + // PermissionSync's own `204` targetless no-op outcome is a Core/inbound + // concept, unrelated to and never derived from a Provider status. + let response = b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n".to_vec(); + let server = tokio::spawn(serve_one_request(listener, acceptor, response)); + + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}/permissions")); + config.additional_trust_anchors_pem = vec![trust_anchor]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let outcome = resolve_with_defaults(&provider).await; + + assert!(outcome.is_err()); + server.await.expect("server task completed"); +} + +#[tokio::test] +async fn rejects_a_non_json_content_type() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let identity = build_primary_test_identity(LOOPBACK_ADDRESS); + let trust_anchor = identity.trust_anchor_pem.clone(); + let acceptor = TlsAcceptor::from(identity.acceptor); + + let response = build_response(200, "OK", "text/plain", &[], b"not json"); + let server = tokio::spawn(serve_one_request(listener, acceptor, response)); + + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}/permissions")); + config.additional_trust_anchors_pem = vec![trust_anchor]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let outcome = resolve_with_defaults(&provider).await; + + assert!(outcome.is_err()); + server.await.expect("server task completed"); +} + +#[tokio::test] +async fn rejects_a_successful_json_response_with_invalid_utf8_body_bytes() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let identity = build_primary_test_identity(LOOPBACK_ADDRESS); + let trust_anchor = identity.trust_anchor_pem.clone(); + let acceptor = TlsAcceptor::from(identity.acceptor); + + let response = ok_response(b"\xff"); + let server = tokio::spawn(serve_one_request(listener, acceptor, response)); + + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}/permissions")); + config.additional_trust_anchors_pem = vec![trust_anchor]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let outcome = resolve_with_defaults(&provider).await; + + assert!(outcome.is_err()); + server.await.expect("server task completed"); +} + +#[tokio::test] +async fn rejects_a_coded_successful_response() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let identity = build_primary_test_identity(LOOPBACK_ADDRESS); + let trust_anchor = identity.trust_anchor_pem.clone(); + let acceptor = TlsAcceptor::from(identity.acceptor); + + let response = build_response( + 200, + "OK", + "application/json", + &[("Content-Encoding", "gzip")], + b"\x1f\x8b\x00\x00", + ); + let server = tokio::spawn(serve_one_request(listener, acceptor, response)); + + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}/permissions")); + config.additional_trust_anchors_pem = vec![trust_anchor]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let outcome = resolve_with_defaults(&provider).await; + + assert!(outcome.is_err()); + server.await.expect("server task completed"); +} + +#[tokio::test] +async fn rejects_a_malformed_envelope() { + for body in [ + &b"{\"payload\":null}"[..], + &b"{\"version\":1}"[..], + &b"{\"version\":1,\"payload\":null,\"unexpected\":true}"[..], + ] { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let identity = build_primary_test_identity(LOOPBACK_ADDRESS); + let trust_anchor = identity.trust_anchor_pem.clone(); + let acceptor = TlsAcceptor::from(identity.acceptor); + + let response = ok_response(body); + let server = tokio::spawn(serve_one_request(listener, acceptor, response)); + + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}/permissions")); + config.additional_trust_anchors_pem = vec![trust_anchor]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let outcome = resolve_with_defaults(&provider).await; + + assert!(outcome.is_err()); + server.await.expect("server task completed"); + } +} + +#[tokio::test] +async fn rejects_a_declared_content_length_above_the_response_body_limit() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let identity = build_primary_test_identity(LOOPBACK_ADDRESS); + let trust_anchor = identity.trust_anchor_pem.clone(); + let acceptor = TlsAcceptor::from(identity.acceptor); + + // No body is ever sent: the client must fail from the declared + // Content-Length alone, before attempting to buffer any bytes. + let response = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 5000000\r\nConnection: close\r\n\r\n".to_vec(); + let server = tokio::spawn(serve_one_request(listener, acceptor, response)); + + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}/permissions")); + config.additional_trust_anchors_pem = vec![trust_anchor]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let outcome = resolve_with_defaults(&provider).await; + + assert!(outcome.is_err()); + server.await.expect("server task completed"); +} + +#[tokio::test] +async fn rejects_a_streamed_body_that_exceeds_the_response_body_limit() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let identity = build_primary_test_identity(LOOPBACK_ADDRESS); + let trust_anchor = identity.trust_anchor_pem.clone(); + let acceptor = TlsAcceptor::from(identity.acceptor); + + let response = oversized_chunked_response(); + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.expect("accept connection"); + let mut stream = acceptor.accept(socket).await.expect("tls handshake"); + let request = read_request(&mut stream).await; + + // The client is expected to stop reading once the response-body + // ceiling is exceeded and may close while this oversized response is + // still being written. + let _ = stream.write_all(&response).await; + let _ = stream.shutdown().await; + + request + }); + + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}/permissions")); + config.additional_trust_anchors_pem = vec![trust_anchor]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let outcome = resolve_with_defaults(&provider).await; + + assert!(outcome.is_err()); + server.await.expect("server task completed"); +} + +#[tokio::test] +async fn a_non_200_response_body_and_www_authenticate_are_not_read_or_exposed() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let identity = build_primary_test_identity(LOOPBACK_ADDRESS); + let trust_anchor = identity.trust_anchor_pem.clone(); + let acceptor = TlsAcceptor::from(identity.acceptor); + let (headers_sent_tx, mut headers_sent_rx) = tokio::sync::oneshot::channel::<()>(); + + let server = tokio::spawn(async move { + let (socket, _) = listener.accept().await.expect("accept connection"); + let mut stream = acceptor.accept(socket).await.expect("tls handshake"); + let _ = read_request(&mut stream).await; + + // Advertise a body that is never actually sent, and keep the + // connection open: if the Provider tried to read or drain it, it + // would have to wait for bytes that never arrive. + let response = b"HTTP/1.1 500 Internal Server Error\r\n\ +Content-Type: text/plain\r\n\ +Content-Length: 999999\r\n\ +WWW-Authenticate: sentinel-www-authenticate-value\r\n\ +X-Provider-Diagnostic: sentinel-provider-diagnostic-value\r\n\r\n"; + stream + .write_all(response) + .await + .expect("write response headers"); + headers_sent_tx + .send(()) + .expect("test still waiting for the headers-sent signal"); + + // Never send the body and never close: resolve() must return based on + // the non-200 headers rather than waiting for body bytes or EOF. + std::future::pending::<()>().await + }); + + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}/permissions")); + config.operation_timeout = Duration::from_secs(60); + config.additional_trust_anchors_pem = vec![trust_anchor]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let resolve = resolve_with_defaults(&provider); + tokio::pin!(resolve); + + // The watchdog starts only after the server has confirmed that headers are + // written. A body read or EOF wait then fails this bounded test, while a + // correct status-only failure may race the signal harmlessly. + let outcome = tokio::select! { + headers_sent = &mut headers_sent_rx => { + headers_sent.expect("server did not signal that response headers were sent"); + tokio::time::timeout(Duration::from_secs(5), &mut resolve) + .await + .expect("resolve() waited for a non-200 response body or EOF") + } + outcome = &mut resolve => { + headers_sent_rx + .await + .expect("server did not signal that response headers were sent"); + outcome + } + }; + let error = match outcome { + Ok(_) => panic!("expected a non-200 status to fail"), + Err(error) => error, + }; + + let debug_output = format!("{error:?}"); + let display_output = error.to_string(); + let source_output = error.source().map(ToString::to_string); + for sensitive in [ + "sentinel-www-authenticate-value", + "sentinel-provider-diagnostic-value", + ] { + assert!(!debug_output.contains(sensitive), "{debug_output}"); + assert!(!display_output.contains(sensitive), "{display_output}"); + if let Some(source_text) = &source_output { + assert!(!source_text.contains(sensitive), "{source_text}"); + } + } + + server.abort(); +} + +// --- Request construction ------------------------------------------------------ + +#[tokio::test] +async fn uses_the_configured_path_and_host_header_with_an_explicit_port() { + let listener = bind_loopback_listener().await; + let port = local_port(&listener); + let identity = build_primary_test_identity(LOOPBACK_ADDRESS); + let trust_anchor = identity.trust_anchor_pem.clone(); + let acceptor = TlsAcceptor::from(identity.acceptor); + + let response = ok_response(br#"{"version":1,"payload":null}"#); + let server = tokio::spawn(serve_one_request(listener, acceptor, response)); + + let path = "/some/nontrivial/path/with%20space"; + let mut config = test_config(&format!("https://{LOOPBACK_ADDRESS}:{port}{path}")); + config.additional_trust_anchors_pem = vec![trust_anchor]; + let provider = GenericRestPermissionProvider::new(config).expect("valid configuration"); + + let outcome = resolve_with_defaults(&provider).await; + assert!(outcome.is_ok()); + + let received = server.await.expect("server task completed"); + assert_eq!(received.request_line, format!("POST {path} HTTP/1.1")); + assert_eq!( + received.header("host"), + Some(format!("{LOOPBACK_ADDRESS}:{port}").as_str()) + ); +} + +// --- Test support ------------------------------------------------------------- + +fn test_config(endpoint: &str) -> GenericRestPermissionProviderConfig { + GenericRestPermissionProviderConfig { + endpoint: endpoint.to_owned(), + operation_timeout: Duration::from_secs(5), + additional_trust_anchors_pem: Vec::new(), + } +} + +fn far_future_deadline() -> Instant { + Instant::now() + Duration::from_secs(60) +} + +async fn resolve_with_defaults( + provider: &GenericRestPermissionProvider, +) -> Result +{ + let identity = IdentityContext::new("jdoe".to_owned(), vec![]); + let target = LogicalTarget::try_from("example".to_owned()).expect("valid target"); + let token = TechnicalCallerBearerToken::new("token".to_owned()); + let cancellation = NeverCancelled; + let context = SynchronizationContext::new(far_future_deadline(), &cancellation); + let request = PermissionProviderRequest::new(&identity, &target, &token, context); + + provider.resolve(request).await +} + +struct NeverCancelled; + +impl CancellationSignal for NeverCancelled { + fn is_cancelled(&self) -> bool { + false + } +} + +struct AlwaysCancelled; + +impl CancellationSignal for AlwaysCancelled { + fn is_cancelled(&self) -> bool { + true + } +} + +/// A cancellation signal a test can flip from `false` to `true` at a chosen +/// point during an in-flight Provider operation, modeling the real Core +/// `CancellationSignal` contract: there is no wake mechanism, so a flipped +/// signal is only ever observed the next time cooperating code checks it. +struct FlippableCancellation(AtomicBool); + +impl FlippableCancellation { + fn new() -> Self { + Self(AtomicBool::new(false)) + } + + fn cancel(&self) { + self.0.store(true, Ordering::SeqCst); + } +} + +impl CancellationSignal for FlippableCancellation { + fn is_cancelled(&self) -> bool { + self.0.load(Ordering::SeqCst) + } +} + +fn poll_once(future: F) -> F::Output { + let waker = Waker::noop(); + let mut context = Context::from_waker(waker); + let mut future = Box::pin(future); + + match future.as_mut().poll(&mut context) { + Poll::Ready(output) => output, + Poll::Pending => panic!("test future unexpectedly returned Poll::Pending"), + } +} + +async fn bind_loopback_listener() -> TcpListener { + TcpListener::bind((LOOPBACK_ADDRESS, 0)) + .await + .expect("bind ephemeral loopback listener") +} + +fn local_port(listener: &TcpListener) -> u16 { + listener + .local_addr() + .expect("listener local address") + .port() +} + +fn assert_listener_received_no_connection(listener: &TcpListener) { + match listener.try_accept() { + Err(error) => assert_eq!(error.kind(), ErrorKind::WouldBlock), + Ok(_) => panic!("provider unexpectedly initiated an outbound connection"), + } +} + +/// Supplies a synchronous `try_accept` operation for Tokio 1.52's listener, +/// whose public API exposes only `poll_accept`. A pending poll is precisely the +/// nonblocking `WouldBlock` condition the short-circuit tests must assert. +trait TryAccept { + fn try_accept(&self) -> std::io::Result<(tokio::net::TcpStream, std::net::SocketAddr)>; +} + +impl TryAccept for TcpListener { + fn try_accept(&self) -> std::io::Result<(tokio::net::TcpStream, std::net::SocketAddr)> { + let waker = Waker::noop(); + let mut context = Context::from_waker(waker); + + match self.poll_accept(&mut context) { + Poll::Ready(result) => result, + Poll::Pending => Err(ErrorKind::WouldBlock.into()), + } + } +} + +struct ReceivedRequest { + request_line: String, + headers: Vec<(String, String)>, + body: Vec, +} + +impl ReceivedRequest { + fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) + } +} + +async fn read_request(stream: &mut S) -> ReceivedRequest +where + S: tokio::io::AsyncRead + Unpin, +{ + let mut buffer = Vec::new(); + let header_end = loop { + let mut chunk = [0_u8; 512]; + let read = stream.read(&mut chunk).await.expect("read request bytes"); + assert!( + read > 0, + "connection closed before request headers completed" + ); + buffer.extend_from_slice(&chunk[..read]); + + if let Some(position) = find_subslice(&buffer, b"\r\n\r\n") { + break position; + } + }; + + let header_text = String::from_utf8(buffer[..header_end].to_vec()).expect("utf8 headers"); + let mut lines = header_text.split("\r\n"); + let request_line = lines.next().expect("request line").to_owned(); + + let mut headers = Vec::new(); + let mut content_length = 0_usize; + for line in lines { + let (name, value) = line.split_once(':').expect("header separator"); + let name = name.trim().to_owned(); + let value = value.trim().to_owned(); + if name.eq_ignore_ascii_case("content-length") { + content_length = value.parse().expect("content length digits"); + } + headers.push((name, value)); + } + + let mut body = buffer[header_end + 4..].to_vec(); + while body.len() < content_length { + let mut chunk = [0_u8; 512]; + let read = stream.read(&mut chunk).await.expect("read request body"); + assert!(read > 0, "connection closed before request body completed"); + body.extend_from_slice(&chunk[..read]); + } + + ReceivedRequest { + request_line, + headers, + body, + } +} + +fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +async fn serve_one_request( + listener: TcpListener, + acceptor: TlsAcceptor, + response: Vec, +) -> ReceivedRequest { + let (socket, _) = listener.accept().await.expect("accept connection"); + let mut stream = acceptor.accept(socket).await.expect("tls handshake"); + let request = read_request(&mut stream).await; + stream.write_all(&response).await.expect("write response"); + let _ = stream.shutdown().await; + request +} + +async fn accept_ignoring_handshake_errors(listener: TcpListener, acceptor: TlsAcceptor) { + if let Ok((socket, _)) = listener.accept().await { + let _ = acceptor.accept(socket).await; + } +} + +fn ok_response(body: &[u8]) -> Vec { + build_response(200, "OK", "application/json", &[], body) +} + +/// Builds a valid `200 application/json` response with a correct, finite +/// `Content-Length` and deliberately no `Connection: close`, so the socket +/// stays open after the complete response. +fn build_keep_alive_response(body: &[u8]) -> Vec { + let mut response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", + body.len() + ) + .into_bytes(); + response.extend_from_slice(body); + response +} + +fn build_response( + status: u16, + reason: &str, + content_type: &str, + extra_headers: &[(&str, &str)], + body: &[u8], +) -> Vec { + let mut response = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n", + body.len() + ); + for (name, value) in extra_headers { + response.push_str(&format!("{name}: {value}\r\n")); + } + response.push_str("Connection: close\r\n\r\n"); + + let mut bytes = response.into_bytes(); + bytes.extend_from_slice(body); + bytes +} + +fn oversized_chunked_response() -> Vec { + let mut response = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n".to_vec(); + + let chunk = vec![b'a'; 8192]; + // Comfortably above any plausible private response-body ceiling. + let target_total = 3_000_000_usize; + let mut sent = 0_usize; + while sent < target_total { + write_chunk(&mut response, &chunk); + sent += chunk.len(); + } + response.extend_from_slice(b"0\r\n\r\n"); + + response +} + +fn write_chunk(buffer: &mut Vec, data: &[u8]) { + buffer.extend_from_slice(format!("{:x}\r\n", data.len()).as_bytes()); + buffer.extend_from_slice(data); + buffer.extend_from_slice(b"\r\n"); +} + +struct TestIdentity { + trust_anchor_pem: Vec, + acceptor: native_tls::TlsAcceptor, +} + +/// Builds a deterministic in-memory root-and-leaf TLS identity from the +/// given fixed public key-derivation labels. Calling this twice with the +/// same labels and `ip_address` produces byte-identical certificates; +/// calling it with different labels produces an entirely independent trust +/// chain, which the untrusted-root test relies on. +fn build_test_identity(ip_address: &str, root_label: &str, leaf_label: &str) -> TestIdentity { + let (root_certificate, root_key) = build_root_certificate(root_label); + let (leaf_certificate, leaf_key) = build_leaf_certificate( + ip_address, + root_certificate.subject_name(), + &root_key, + leaf_label, + ); + + let leaf_certificate_pem = leaf_certificate.to_pem().expect("leaf certificate pem"); + let leaf_key_pem = leaf_key + .private_key_to_pem_pkcs8() + .expect("leaf private key pkcs8 pem"); + + let identity = native_tls::Identity::from_pkcs8(&leaf_certificate_pem, &leaf_key_pem) + .expect("valid pkcs8 identity"); + let acceptor = native_tls::TlsAcceptor::builder(identity) + .min_protocol_version(Some(native_tls::Protocol::Tlsv12)) + .build() + .expect("build tls acceptor"); + + TestIdentity { + trust_anchor_pem: root_certificate.to_pem().expect("root certificate pem"), + acceptor, + } +} + +/// Builds a deterministic test identity from the shared primary fixed labels. +fn build_primary_test_identity(ip_address: &str) -> TestIdentity { + build_test_identity(ip_address, PRIMARY_ROOT_KEY_LABEL, PRIMARY_LEAF_KEY_LABEL) +} + +/// Deterministically derives an Ed25519 private key from a fixed public +/// label via SHA-256, using OpenSSL's existing, mature hash implementation +/// rather than any bespoke cryptography or committed key bytes. +/// +/// No randomness is used: SHA-256 is a deterministic function of its input, +/// so the same label always yields the same 32-byte Ed25519 raw private-key +/// seed, and thus the same key. The label itself carries no cryptographic +/// value; it is not key material. +fn ed25519_key_from_label(label: &str) -> PKey { + let digest = hash(MessageDigest::sha256(), label.as_bytes()).expect("sha-256 digest"); + let seed: [u8; 32] = digest + .as_ref() + .try_into() + .expect("sha-256 digest is exactly 32 bytes"); + + PKey::private_key_from_raw_bytes(&seed, Id::ED25519).expect("valid ed25519 seed bytes") +} + +fn build_name(common_name: &str) -> X509Name { + let mut builder = openssl::x509::X509NameBuilder::new().expect("x509 name builder"); + builder + .append_entry_by_nid(Nid::COMMONNAME, common_name) + .expect("append common name"); + builder.build() +} + +/// Builds a certificate with a fixed serial number and a fixed absolute +/// validity window. Nothing here reads the wall clock or any random source. +fn new_certificate_builder( + subject_name: &X509NameRef, + issuer_name: &X509NameRef, + public_key: &PKeyRef, + serial_number: u32, +) -> openssl::x509::X509Builder { + let mut builder = X509::builder().expect("x509 builder"); + builder.set_version(2).expect("x509 version"); + builder + .set_subject_name(subject_name) + .expect("set subject name"); + builder + .set_issuer_name(issuer_name) + .expect("set issuer name"); + builder.set_pubkey(public_key).expect("set public key"); + + let serial = BigNum::from_u32(serial_number).expect("fixed serial bignum"); + builder + .set_serial_number(&serial.to_asn1_integer().expect("serial asn1 integer")) + .expect("set serial number"); + + builder + .set_not_before( + &Asn1Time::from_unix(CERTIFICATE_NOT_BEFORE_UNIX_SECONDS).expect("fixed not before"), + ) + .expect("set not before"); + builder + .set_not_after( + &Asn1Time::from_unix(CERTIFICATE_NOT_AFTER_UNIX_SECONDS).expect("fixed not after"), + ) + .expect("set not after"); + + builder +} + +fn build_root_certificate(label: &str) -> (X509, PKey) { + let key = ed25519_key_from_label(label); + // Each root uses a subject name derived from its own label so that two + // independent deterministic roots (for example the primary root and the + // alternate root used to build an unrelated trust anchor) never share a + // subject name, which would otherwise make chain building ambiguous when + // both are trusted at once, such as in a multi-certificate PEM bundle. + let name = build_name(&format!("permissionsync-test-root-{label}")); + let mut builder = new_certificate_builder(&name, &name, &key, ROOT_CERTIFICATE_SERIAL); + + let basic_constraints = BasicConstraints::new() + .critical() + .ca() + .build() + .expect("root basic constraints"); + builder + .append_extension(basic_constraints) + .expect("append root basic constraints"); + let key_usage = KeyUsage::new() + .critical() + .key_cert_sign() + .crl_sign() + .build() + .expect("root key usage"); + builder + .append_extension(key_usage) + .expect("append root key usage"); + + builder + .sign(&key, MessageDigest::null()) + .expect("sign root certificate"); + + (builder.build(), key) +} + +fn build_leaf_certificate( + ip_address: &str, + root_name: &X509NameRef, + root_key: &PKeyRef, + label: &str, +) -> (X509, PKey) { + let key = ed25519_key_from_label(label); + let name = build_name(ip_address); + let mut builder = new_certificate_builder(&name, root_name, &key, LEAF_CERTIFICATE_SERIAL); + + let basic_constraints = BasicConstraints::new() + .critical() + .build() + .expect("leaf basic constraints"); + builder + .append_extension(basic_constraints) + .expect("append leaf basic constraints"); + let key_usage = KeyUsage::new() + .critical() + .digital_signature() + .build() + .expect("leaf key usage"); + builder + .append_extension(key_usage) + .expect("append leaf key usage"); + let subject_alternative_name = SubjectAlternativeName::new() + .ip(ip_address) + .build(&builder.x509v3_context(None, None)) + .expect("leaf subject alternative name"); + builder + .append_extension(subject_alternative_name) + .expect("append leaf subject alternative name"); + + builder + .sign(root_key, MessageDigest::null()) + .expect("sign leaf certificate"); + + (builder.build(), key) +}