diff --git a/Cargo.lock b/Cargo.lock index c2e04bd..0de428d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,11 +8,14 @@ version = "0.1.0" dependencies = [ "chrono", "clap", + "digest_auth", "env_logger", + "futures", "log", "mlua", "reqwest", "rusqlite", + "rustls", "serde", "serde_json", "thiserror", @@ -117,28 +120,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "aws-lc-rs" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.41.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", -] - [[package]] name = "base64" version = "0.22.1" @@ -157,6 +138,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bstr" version = "1.12.1" @@ -186,8 +176,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -197,12 +185,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - [[package]] name = "chrono" version = "0.4.45" @@ -256,15 +238,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "colorchoice" version = "1.0.5" @@ -307,6 +280,25 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "defmt" version = "1.1.0" @@ -339,6 +331,29 @@ dependencies = [ "thiserror", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "digest_auth" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3054f4e81d395e50822796c5e99ca522e6ba7be98947d6d4b0e5e61640bdb894" +dependencies = [ + "digest", + "hex", + "md-5", + "rand", + "sha2", +] + [[package]] name = "displaydoc" version = "0.2.6" @@ -350,12 +365,6 @@ dependencies = [ "syn", ] -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "either" version = "1.16.0" @@ -455,10 +464,19 @@ dependencies = [ ] [[package]] -name = "fs_extra" -version = "1.3.0" +name = "futures" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] [[package]] name = "futures-channel" @@ -467,6 +485,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -475,6 +494,34 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -493,37 +540,36 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] [[package]] -name = "getrandom" -version = "0.2.17" +name = "generic-array" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", + "typenum", + "version_check", ] [[package]] name = "getrandom" -version = "0.3.4" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", - "js-sys", "libc", - "r-efi", - "wasip2", - "wasm-bindgen", + "wasi", ] [[package]] @@ -575,6 +621,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.4.2" @@ -904,16 +956,6 @@ dependencies = [ "syn", ] -[[package]] -name = "jobserver" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" -dependencies = [ - "getrandom 0.3.4", - "libc", -] - [[package]] name = "js-sys" version = "0.3.102" @@ -963,12 +1005,6 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - [[package]] name = "lua-src" version = "550.0.0" @@ -988,6 +1024,16 @@ dependencies = [ "which", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.8.2" @@ -1187,62 +1233,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quinn" -version = "0.11.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.3.4", - "lru-slab", - "rand", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.60.2", -] - [[package]] name = "quote" version = "1.0.45" @@ -1252,27 +1242,22 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "rand" -version = "0.9.4" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ + "libc", "rand_chacha", "rand_core", ] [[package]] name = "rand_chacha" -version = "0.9.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", "rand_core", @@ -1280,11 +1265,11 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.9.5" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.3.4", + "getrandom", ] [[package]] @@ -1347,7 +1332,6 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -1371,7 +1355,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.17", + "getrandom", "libc", "untrusted", "windows-sys 0.52.0", @@ -1412,8 +1396,8 @@ version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ - "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1438,7 +1422,6 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ - "web-time", "zeroize", ] @@ -1475,7 +1458,6 @@ version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1593,6 +1575,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1747,21 +1740,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.52.3" @@ -1889,6 +1867,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1962,15 +1946,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.125" @@ -2036,21 +2011,11 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] @@ -2149,16 +2114,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -2176,31 +2132,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -2209,102 +2148,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "writeable" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 818b852..1e70800 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,10 +9,18 @@ chrono = "0.4.45" env_logger = "0.11" log = "0.4" clap = { version = "4.6.1", features = ["derive"] } +futures = "0.3" mlua = { version = "0.11.6", features = ["lua55", "vendored", "serde", "async", "anyhow"]} -reqwest = "0.13.4" +# reqwest's default rustls provider (aws-lc-rs) null-derefs during the TLS +# handshake in this environment, and native-tls would link system OpenSSL. +# Use rustls with the self-contained, vendored `ring` provider (installed as the +# process default in stdlib::http). `rustls-no-provider` stops reqwest from +# pulling aws-lc-rs back in. +reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider", "charset", "http2", "system-proxy"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } rusqlite = { version = "0.32", features = ["bundled"] } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" thiserror = "2.0.18" tokio = { version = "1.52.3", features = ["full"] } +digest_auth = "0.3" diff --git a/docs/concurrency.md b/docs/concurrency.md new file mode 100644 index 0000000..1ed7c04 --- /dev/null +++ b/docs/concurrency.md @@ -0,0 +1,175 @@ +# Concurrency + +`a` runs every script on an async core ([tokio](https://tokio.rs/)). The async +stdlib calls — `os.sleep`, the `http` client, `sqlite` queries — don't block the +OS thread while they wait: they suspend and let other work run. This page covers +how that interacts with Lua coroutines and how to run several pieces of work at +once with `task.join`. + +Plain Lua coroutines behave exactly as in stock Lua. The moment a coroutine needs +to wait on something async — a sleep, a request, a query — run it through +`task.join` rather than driving it yourself, for the reason in +[Self-driven coroutines](#self-driven-coroutines). + +```lua +-- Three requests that would take ~3s back-to-back finish in ~1s. +local a, b, c = task.join( + function() return http.get("https://httpbingo.org/delay/1").status end, + function() return http.get("https://httpbingo.org/delay/1").status end, + function() return http.get("https://httpbingo.org/delay/1").status end +) +print(a, b, c) --> 200 200 200 +``` + +## Plain coroutines + +Stock Lua coroutines are pure VM machinery and behave normally: + +```lua +local function squares(n) + for i = 1, n do + coroutine.yield(i * i) + end +end + +local gen = coroutine.wrap(squares) +print(gen(3), gen(), gen()) --> 1 4 9 +``` + +This is ordinary cooperative scheduling: nothing runs concurrently, and the tokio +runtime is never involved. + +## Async calls suspend, they don't block + +An async stdlib call — at the top level of your script, or inside a `task.join` +coroutine — suspends until it is ready without tying up the thread: + +```lua +local t0 = os.microtime() +os.sleep(0.10) +print(string.format("waited %.3fs", os.microtime() - t0)) --> waited ~0.100s +``` + +While that sleep is pending, any sibling tasks keep making progress. + +## Running work concurrently + +### `task.join(fn1, fn2, ...)` + +Run each function as its own coroutine, drive them **concurrently** on the +runtime, and return each one's first result positionally once all have finished. +If a task raises an error, `task.join` re-raises the first one. + +Because async calls suspend instead of blocking, the tasks overlap — the +wall-clock time is the *longest* task, not the sum: + +```lua +local function worker(name, secs) + return function() + os.sleep(secs) -- suspends; siblings run meanwhile + return name + end +end + +local t0 = os.microtime() +local a, b, c = task.join( + worker("slow", 0.30), + worker("med", 0.20), + worker("fast", 0.10) +) +print(a, b, c) --> slow med fast +print(string.format("%.3fs", os.microtime() - t0)) --> ~0.300s, not 0.600s +``` + +### Returning results + +Each task's first return value lands in the matching slot, so several results come +back together: + +```lua +local me, repos = task.join( + function() return http.getJSON("https://api.github.com/users/torvalds") end, + function() return http.getJSON("https://api.github.com/users/torvalds/repos") end +) +print(me.name, #repos) -- both fetched concurrently +``` + +### Nesting + +`task.join` suspends like any other async call, so a task may itself call +`task.join`: + +```lua +local total = task.join( + function() + local x, y = task.join( + function() os.sleep(0.05); return 21 end, + function() os.sleep(0.05); return 21 end + ) + return x + y -- 42 + end, + function() os.sleep(0.10); return "sibling" end +) +print(total) --> 42 +``` + +## Self-driven coroutines + +Async stdlib functions suspend correctly only when the runtime is driving the +coroutine — at the top level of your script, or inside a `task.join` coroutine. If +you drive a coroutine **yourself** with `coroutine.resume` or `coroutine.wrap`, an +async call does *not* wait: + +```lua +-- DON'T do this: +local co = coroutine.wrap(function() + os.sleep(0.50) + return "awoke" +end) + +local v = co() -- returns immediately with an opaque value; the 0.5s wait never + -- happens, because coroutine.wrap can't drive the runtime. +``` + +Under the hood an async call yields a private marker that only the runtime's +scheduler understands; a hand-written `resume`/`wrap` loop just receives that +marker and moves on. + +**Rule of thumb:** use plain `coroutine.*` for pure-Lua generators; the moment a +coroutine needs to `os.sleep`, hit the network, or touch the database, run it +through `task.join` instead. + +## How it works + +`task.join` wraps each function in a Lua coroutine and polls them all on the tokio +runtime. The concurrency comes from the **reactor** — timers and I/O yielding +control while they wait — not from extra threads. + +- **Concurrency, not parallelism.** Everything runs on one thread; there are + simply many operations in flight at once. +- **I/O-bound work overlaps.** Sleeps, HTTP requests, and database queries all + wait at the same time. +- **CPU-bound work does not yield.** A tight compute loop with no async calls + blocks its siblings until it finishes or reaches an async call. + +## Full example + +```lua +-- Fetch several resources at once, then combine them. +local function get(url) + return function() return http.getJSON(url) end +end + +local user, repos = task.join( + get("https://api.github.com/users/torvalds"), + get("https://api.github.com/users/torvalds/repos") +) + +print(user.name) +print(#repos .. " public repos") +-- Both requests ran concurrently: ~1 round-trip of latency, not 2. +``` + +A runnable version of these patterns ships in +[`lua/coroutines-demo.lua`](../lua/coroutines-demo.lua) — run it with +`a lua/coroutines-demo.lua`. diff --git a/docs/http.md b/docs/http.md new file mode 100644 index 0000000..47b0b09 --- /dev/null +++ b/docs/http.md @@ -0,0 +1,348 @@ +# `http` + +The `http` module is an HTTP/HTTPS client for calling APIs, scraping pages, and +submitting forms. TLS is compiled into the binary (rustls with the `ring` +provider), so HTTPS works with no system library. There are two ways in: plain +functions for one-off requests, and a **session** object that carries a cookie +jar across requests for stateful flows like logging in. + +```lua +local resp = http.get("https://example.com") +print(resp.status, resp.ok) --> 200 true +print(resp.body) --> "…" + +-- JSON in one step +local data = http.getJSON("https://api.example.com/users") +for _, user in ipairs(data) do + print(user.name) +end +``` + +## Making requests + +Every helper takes a URL and an optional `opts` table (see [Request +options](#request-options)) and returns a [response](#the-response) table. + +```lua +http.get(url [, opts]) +http.post(url [, opts]) +http.put(url [, opts]) +http.patch(url [, opts]) +http.delete(url [, opts]) +http.head(url [, opts]) +``` + +These are thin shorthands over the one primitive: + +### `http.request(method, url [, opts])` + +Send a request with an explicit method (any verb, e.g. `"GET"`, `"OPTIONS"`) and +return the response. The shorthands above are just `http.request` with the method +filled in. + +```lua +local resp = http.request("DELETE", "https://api.example.com/items/42") +``` + +## The response + +Every request returns a table describing the response: + +| Field | Type | Description | +|-----------|-----------|-------------------------------------------------------------------| +| `status` | integer | HTTP status code, e.g. `200`, `404`. | +| `ok` | boolean | `true` when `status` is in the 2xx range. | +| `headers` | table | Response headers, keyed by **lowercase** name. | +| `body` | string | The raw response body (Lua strings are byte sequences). | +| `json` | function | `resp.json()` parses `body` as JSON. See [JSON](#json). | + +Header names are lowercased so you can look them up without guessing the server's +capitalization. If a header appears more than once, the first value wins. + +```lua +local resp = http.get("https://example.com") +print(resp.headers["content-type"]) --> "text/html; charset=utf-8" +``` + +A non-2xx status is **not** an error — `resp.status` and `resp.ok` simply report +it. Only a failure to get a response at all (DNS, connection, TLS, timeout) raises +a Lua error. See [Errors](#errors). + +## Request options + +The optional `opts` table accepts these fields, all optional: + +| Field | Type | Behaviour | +|-----------|--------|--------------------------------------------------------------------------| +| `headers` | table | Extra request headers, `{["X-Foo"] = "bar"}`. | +| `body` | string | Raw request body; set `Content-Type` yourself via `headers`. | +| `json` | any | Serialized to JSON; sets `Content-Type: application/json`. | +| `form` | table | URL-encoded; sets `Content-Type: application/x-www-form-urlencoded`. | +| `cookies` | table | Cookies for this request, `{session = "abc"}` → `Cookie:` header. | +| `timeout` | number | Per-request timeout in seconds (default `30`). | +| `auth` | table | Basic or digest credentials. See [Authentication](#authentication). | + +### Bodies + +`json`, `form`, and `body` are three ways to set the request body; if more than +one is given, the first present in that order wins. + +```lua +-- JSON body (table serialized to an object) +http.post("https://api.example.com/items", { json = { name = "test", count = 5 } }) + +-- Form submission (application/x-www-form-urlencoded) +http.post("https://example.com/login", { form = { user = "alice", pass = "secret" } }) + +-- Raw body with an explicit content type +http.post("https://example.com/ingest", { + body = "id,name\n1,alice\n", + headers = { ["Content-Type"] = "text/csv" }, +}) +``` + +`json` serializes the same way as `utils.toJSON`: a table with +sequential integer keys becomes a JSON array, otherwise an object, and +`utils.NULL` becomes JSON `null` (a literal Lua `nil` cannot live in +a table). + +### Headers and cookies + +```lua +local resp = http.get("https://example.com", { + headers = { ["Accept"] = "application/json", ["X-Token"] = "xyz" }, + cookies = { session = "abc123" }, + timeout = 10, +}) +``` + +Per-request `cookies` are sent as a `Cookie` header. With a [session](#sessions), +they are merged with the jar and take precedence on a name collision. + +## JSON + +### `resp.json()` + +Parse the response body as JSON and return the resulting Lua value. It is the +counterpart of `utils.fromJSON`: JSON objects become tables, arrays +become array-tables, and `null` becomes `utils.NULL`. Calling it on a +body that is not valid JSON raises an error. + +```lua +local resp = http.post("https://api.example.com/echo", { json = { hello = "world" } }) +print(resp.json().hello) --> "world" +``` + +### `http.getJSON(url [, opts])` + +Shorthand for a GET that parses the body. Returns **two** values: the parsed body +and the full response. + +```lua +local data, resp = http.getJSON("https://api.example.com/users") +print(resp.status, #data) +``` + +### `http.postJSON(url, body [, opts])` + +Shorthand for a POST with a JSON body — equivalent to setting `opts.json = body`. +Returns the response. + +```lua +local resp = http.postJSON("https://api.example.com/items", { name = "test", count = 5 }) +if resp.ok then print(resp.json().id) end +``` + +## Authentication + +Pass credentials in `opts.auth`. The `scheme` is `"basic"` (the default) or +`"digest"`. + +| Field | Type | Description | +|------------|--------|----------------------------------------------| +| `username` | string | Required. | +| `password` | string | Required. | +| `scheme` | string | `"basic"` (default) or `"digest"`. | + +```lua +-- HTTP Basic +local resp = http.get("https://api.example.com/private", { + auth = { username = "alice", password = "secret" }, +}) + +-- HTTP Digest — the 401 challenge is answered automatically +local resp = http.get("https://api.example.com/private", { + auth = { username = "alice", password = "secret", scheme = "digest" }, +}) +``` + +For basic auth the `Authorization` header is sent with the request. For digest the +client sends the request, reads the server's `401` challenge, computes the +response, and retries once; you only see the final response. Auth works the same +way on [sessions](#sessions). + +## Sessions + +A **session** wraps its own cookie jar. Cookies from `Set-Cookie` responses are +stored automatically and sent back on later requests to matching hosts — which is +what makes login-then-fetch flows work. A session also has the same request +methods as the plain module. + +### `http.session([path])` + +Create a session. With a `path`, the cookie jar is preloaded from that file (see +[Persisting the jar](#persisting-the-jar)). + +```lua +local s = http.session() -- fresh, empty jar +local s = http.session("cookies.jsonl") -- jar loaded from disk +``` + +### Requests + +A session has every method the plain module has — `request`, `get`, `post`, +`put`, `patch`, `delete`, `head`, `getJSON`, `postJSON` — called with `:` syntax +and accepting the same `opts`: + +```lua +local s = http.session() + +-- Log in; the response's Set-Cookie is captured into the jar +s:post("https://example.com/login", { form = { user = "alice", pass = "secret" } }) + +-- The session cookie is sent automatically +local page = s:get("https://example.com/dashboard") +``` + +### Cookie behaviour + +A stored cookie is sent on a request when the request host **equals** the cookie's +domain or is a subdomain of it. The jar reads the `name=value` pair and the +`Domain` attribute from each `Set-Cookie` header; other attributes (`Path`, +`Expires`, `Secure`, `HttpOnly`) are ignored. When no `Domain` is given, the +request host is used. + +`Set-Cookie` headers set on redirect responses are captured too, so a login that +`302`-redirects to a dashboard still records its cookie. + +### Inspecting and clearing + +#### `s:cookies()` + +Return the jar as a nested table, `{domain = {name = value}}`, for inspection. + +```lua +local jar = s:cookies() +for domain, names in pairs(jar) do + for name, value in pairs(names) do + print(domain, name, value) + end +end +``` + +#### `s:clearCookies()` + +Empty the in-memory jar. + +### Persisting the jar + +Cookies live in memory for the session's lifetime. Save them to reuse a logged-in +session across script runs. + +#### `s:save(path)` + +Write the jar to `path` as JSONL — one JSON object per line, one cookie per line: + +```jsonl +{"domain":"example.com","name":"session","value":"abc123"} +{"domain":"api.example.com","name":"token","value":"xyz789"} +``` + +#### `s:load(path)` + +Merge cookies from a JSONL file into the current jar (existing cookies are kept, +matching names overwritten). `http.session(path)` is the same as creating a +session and calling `:load(path)`. + +```lua +-- First run: log in and persist +local s = http.session() +s:post("https://example.com/login", { form = { user = "alice", pass = "secret" } }) +s:save("session.jsonl") + +-- Later run: restore and continue without logging in again +local s = http.session("session.jsonl") +local page = s:get("https://example.com/dashboard") +``` + +## Redirects + +Redirects are followed automatically, up to 10 hops; you receive the final +response. A `303`, and a `301`/`302` in response to a `POST`, are followed as a +bodyless `GET`, matching browser behaviour. For a session, cookies set along the +way are captured at each hop. + +## Errors + +Failing to obtain a response raises a Lua error: DNS failure, connection refused, +a TLS problem, or a timeout. An HTTP error *status* (4xx/5xx) does not — it is +reported through `resp.status`/`resp.ok`. Parsing a non-JSON body with +`resp.json()` also raises. + +Wrap calls in `pcall` or `utils.try` where you want to handle failure +rather than abort: + +```lua +local resp, err = utils.try(function() + return http.get("https://does-not-exist.invalid", { timeout = 5 }) +end) +if not resp then + log.error("request failed: " .. tostring(err)) +end +``` + +## Notes + +- **HTTPS needs no setup.** The TLS stack (rustls + `ring`) is compiled in; trust + roots come from the system certificate store. +- **A default `User-Agent` is sent** (`a/`) because some servers reject + requests without one. Override it with a `User-Agent` entry in `opts.headers`. +- **Requests don't block the event loop.** Network I/O runs on the async core, so + a slow request does not stall other async work (timers, `os.sleep`, SQLite) in + the same script. +- **The cookie jar is per session.** Plain `http.get`/`http.post` calls do not + retain cookies between calls; use a session for that. + +## Full example + +```lua +-- Talk to a JSON API with a bearer token, then drive a stateful session. + +-- One-off authenticated JSON call +local items, resp = http.getJSON("https://api.example.com/items", { + headers = { ["Authorization"] = "Bearer " .. token }, + timeout = 15, +}) +if not resp.ok then + error("list failed: HTTP " .. resp.status) +end +for _, item in ipairs(items) do + print(item.id, item.name) +end + +-- Create one +local created = http.postJSON("https://api.example.com/items", { name = "widget" }, { + headers = { ["Authorization"] = "Bearer " .. token }, +}) +print("created id:", created.json().id) + +-- A login session that persists across runs +local s = http.session("session.jsonl") -- restore if present +local home = s:get("https://example.com/dashboard") +if home.status == 401 then -- session expired; log in again + s:post("https://example.com/login", { form = { user = "alice", pass = "secret" } }) + home = s:get("https://example.com/dashboard") + s:save("session.jsonl") +end +print(home.ok and "logged in" or "login failed") +``` diff --git a/lua/coroutines-demo.lua b/lua/coroutines-demo.lua new file mode 100644 index 0000000..c578c23 --- /dev/null +++ b/lua/coroutines-demo.lua @@ -0,0 +1,99 @@ +-- Proof-of-concept: how Lua coroutines interact with the tokio runtime, and +-- how the async os.sleep behaves in each setting. +-- +-- Run with: cargo run -- lua/coroutines-demo.lua + +local function banner(s) print("\n=== " .. s .. " ===") end + +---------------------------------------------------------------------- +banner("1. Pure Lua coroutines (no async involved)") +---------------------------------------------------------------------- +-- Plain cooperative coroutines work exactly as in stock Lua: this is pure VM +-- machinery and never touches tokio. +local function counter(n) + for i = 1, n do + coroutine.yield(i * i) + end + return "done" +end + +local co = coroutine.wrap(counter) +print("squares:", co(3), co(3), co(3)) -- 1 4 9 + +local raw = coroutine.create(counter) +print("status fresh:", coroutine.status(raw)) -- suspended +coroutine.resume(raw, 1) +coroutine.resume(raw, 1) -- runs to `return` +print("status after return:", coroutine.status(raw)) -- dead + +---------------------------------------------------------------------- +banner("2. Async os.sleep at the top level") +---------------------------------------------------------------------- +-- The main chunk is itself run by mlua's async executor (exec_async), so an +-- async call here is driven correctly and really suspends on the tokio timer. +local t0 = os.microtime() +os.sleep(0.10) +print(string.format("slept ~0.10s, measured %.3fs", os.microtime() - t0)) + +---------------------------------------------------------------------- +banner("3. GOTCHA: async os.sleep inside a *manually resumed* coroutine") +---------------------------------------------------------------------- +-- mlua implements async functions by yielding a private sentinel to whatever is +-- driving the coroutine. mlua's own executor understands it; a plain +-- coroutine.resume / coroutine.wrap does NOT. So driving an async call yourself +-- does not actually wait -- the sleep is not performed by your resume loop. +local sleeper = coroutine.wrap(function() + os.sleep(0.50) + return "awoke" +end) + +local t1 = os.microtime() +local first = sleeper() -- returns immediately with the sentinel +print(string.format("first resume returned %s after only %.3fs (did NOT wait 0.5s)", + tostring(first), os.microtime() - t1)) +print("--> lesson: don't hand-drive coroutines that call async stdlib functions.") + +---------------------------------------------------------------------- +banner("4. Real concurrency: task.join drives coroutines on tokio") +---------------------------------------------------------------------- +-- task.join runs each function as its own coroutine and lets the tokio reactor +-- interleave them. Three tasks that each sleep concurrently finish in ~the +-- longest single sleep, not the sum -- proof the sleeps overlap on one thread. +local function worker(name, secs) + return function() + print(string.format(" [%s] start", name)) + os.sleep(secs) + print(string.format(" [%s] woke after %.2fs", name, secs)) + return name .. ":" .. secs + end +end + +local t2 = os.microtime() +local a, b, c = task.join( + worker("slow", 0.30), + worker("med", 0.20), + worker("fast", 0.10) +) +local elapsed = os.microtime() - t2 +print(string.format("results: %s, %s, %s", a, b, c)) +print(string.format("wall time: %.3fs", elapsed)) +print(string.format("sequential would have been ~0.60s; concurrent ~0.30s => %s", + elapsed < 0.45 and "CONCURRENT (overlapped on tokio)" or "serialized?!")) + +---------------------------------------------------------------------- +banner("5. Nested + return values") +---------------------------------------------------------------------- +-- task.join itself yields, so it composes: a joined task can join again. +local outer = task.join( + function() + local x, y = task.join( + function() os.sleep(0.05); return 21 end, + function() os.sleep(0.05); return 21 end + ) + return x + y + end, + function() os.sleep(0.10); return "sibling" end +) +print("nested join result:", outer) -- 42 + +print("\nAll demos finished.") diff --git a/lua/stdlib/http.lua b/lua/stdlib/http.lua new file mode 100644 index 0000000..5c4e4f8 --- /dev/null +++ b/lua/stdlib/http.lua @@ -0,0 +1,66 @@ +-- Lua-side of the http module. +-- http.request (stateless) and http.session (constructor) are provided by Rust +-- before this runs. This layer adds resp.json(), method shorthands, the +-- JSON/postJSON helpers, and a metatable for session objects. + +-- Attach resp.json() to a response table: parses resp.body via utils.fromJSON. +local function wrap_resp(resp) + resp.json = function() return utils.fromJSON(resp.body) end + return resp +end + +-- Wrap the stateless http.request so responses carry resp.json(). +local _request = http.request +http.request = function(method, url, opts) + return wrap_resp(_request(method, url, opts)) +end + +-- Method shorthands for the stateless module. +for _, m in ipairs({ "get", "post", "put", "patch", "delete", "head" }) do + http[m] = function(url, opts) return http.request(m:upper(), url, opts) end +end + +function http.getJSON(url, opts) + local resp = http.get(url, opts) + return resp.json(), resp +end + +function http.postJSON(url, body, opts) + opts = opts or {} + opts.json = body + return http.post(url, opts) +end + +-- Session metatable. http.session() returns a raw Rust table whose methods are +-- _request, save, load, clearCookies and cookies. The metatable adds the +-- request wrapper (for resp.json()) and the method shorthands on top. +local session_mt = {} +session_mt.__index = session_mt + +function session_mt:request(method, url, opts) + return wrap_resp(self:_request(method, url, opts)) +end + +function session_mt:get(url, opts) return self:request("GET", url, opts) end +function session_mt:post(url, opts) return self:request("POST", url, opts) end +function session_mt:put(url, opts) return self:request("PUT", url, opts) end +function session_mt:patch(url, opts) return self:request("PATCH", url, opts) end +function session_mt:delete(url, opts) return self:request("DELETE", url, opts) end +function session_mt:head(url, opts) return self:request("HEAD", url, opts) end + +function session_mt:getJSON(url, opts) + local resp = self:get(url, opts) + return resp.json(), resp +end + +function session_mt:postJSON(url, body, opts) + opts = opts or {} + opts.json = body + return self:post(url, opts) +end + +-- Wrap the Rust session constructor to install the metatable. +local _session = http.session +http.session = function(path) + return setmetatable(_session(path), session_mt) +end diff --git a/src/stdlib/http.rs b/src/stdlib/http.rs new file mode 100644 index 0000000..6aba9ba --- /dev/null +++ b/src/stdlib/http.rs @@ -0,0 +1,529 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use mlua::prelude::LuaResult; +use mlua::{Lua, Table as LuaTable, Value as LuaValue}; + +use crate::stdlib::utils::lua_to_json; + +const HTTP_LUA: &str = include_str!("../../lua/stdlib/http.lua"); + +// --------------------------------------------------------------------------- +// Cookie jar +// --------------------------------------------------------------------------- + +/// A minimal in-memory cookie jar: bare domain (no leading dot) → name → value. +/// Deliberately simple — it covers the >95% case of `Set-Cookie` flows without +/// pulling in the `cookie_store` crate, and serializes cleanly to JSONL. +#[derive(Default)] +struct CookieJar { + cookies: HashMap>, +} + +impl CookieJar { + /// All cookies (name, value) whose stored domain matches `host`: either an + /// exact match or `host` being a subdomain of the stored domain. + fn cookies_for(&self, host: &str) -> Vec<(String, String)> { + let mut out = Vec::new(); + for (domain, names) in &self.cookies { + let suffix = format!(".{domain}"); + if host == domain || host.ends_with(&suffix) { + for (name, value) in names { + out.push((name.clone(), value.clone())); + } + } + } + out + } + + /// Parse a single `Set-Cookie` header value and store the cookie. Extracts + /// `name=value` (first segment) and an optional `Domain=` attribute, falling + /// back to the request host. Malformed headers are ignored. + fn set_from_header(&mut self, host: &str, header: &str) { + let mut segments = header.split(';'); + let first = match segments.next() { + Some(s) => s.trim(), + None => return, + }; + let (name, value) = match first.split_once('=') { + Some((n, v)) => (n.trim(), v.trim()), + None => return, + }; + if name.is_empty() { + return; + } + + let mut domain = host.to_ascii_lowercase(); + for seg in segments { + if let Some((k, v)) = seg.split_once('=') + && k.trim().eq_ignore_ascii_case("domain") + { + let d = v.trim().trim_start_matches('.').to_ascii_lowercase(); + if !d.is_empty() { + domain = d; + } + } + } + + self.cookies + .entry(domain) + .or_default() + .insert(name.to_string(), value.to_string()); + } + + /// Serialize as JSONL — one `{"domain","name","value"}` object per line. + fn to_jsonl(&self) -> String { + let mut out = String::new(); + for (domain, names) in &self.cookies { + for (name, value) in names { + let obj = serde_json::json!({ + "domain": domain, + "name": name, + "value": value, + }); + out.push_str(&obj.to_string()); + out.push('\n'); + } + } + out + } + + /// Merge cookies from JSONL produced by `to_jsonl`. Bad lines are skipped. + fn merge_jsonl(&mut self, src: &str) { + for line in src.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let Ok(v) = serde_json::from_str::(line) else { + continue; + }; + let domain = v.get("domain").and_then(|x| x.as_str()); + let name = v.get("name").and_then(|x| x.as_str()); + let value = v.get("value").and_then(|x| x.as_str()); + if let (Some(d), Some(n), Some(val)) = (domain, name, value) { + self.cookies + .entry(d.to_string()) + .or_default() + .insert(n.to_string(), val.to_string()); + } + } + } +} + +// --------------------------------------------------------------------------- +// Request execution +// --------------------------------------------------------------------------- + +/// `application/x-www-form-urlencoded` body from key/value pairs. Implemented +/// inline so the build needs no optional reqwest features. +fn form_urlencode(pairs: &[(String, String)]) -> String { + fn encode(s: &str) -> String { + let mut out = String::new(); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + b' ' => out.push('+'), + _ => out.push_str(&format!("%{b:02X}")), + } + } + out + } + pairs + .iter() + .map(|(k, v)| format!("{}={}", encode(k), encode(v))) + .collect::>() + .join("&") +} + +const MAX_REDIRECTS: u32 = 10; + +/// Shared by the stateless `http.request` and a session's `:request`. When `jar` +/// is `Some`, the matching jar cookies are sent and any `Set-Cookie` responses +/// are stored back. +/// +/// Redirects are followed manually (the client is built with +/// `redirect::Policy::none`) so that `Set-Cookie` headers on 30x responses — +/// the common login → redirect → dashboard pattern — are captured into the jar, +/// which reqwest's transparent redirect following would otherwise hide. +async fn execute_request( + lua: Lua, + client: reqwest::Client, + jar: Option>>, + method: String, + url: String, + opts: Option, +) -> LuaResult { + let mut method = reqwest::Method::from_bytes(method.to_ascii_uppercase().as_bytes()) + .map_err(|e| mlua::Error::external(format!("http: invalid method '{method}': {e}")))?; + + // Parse opts once into owned pieces so each redirect hop can rebuild the + // request (a reqwest RequestBuilder is single-use). + let mut timeout: Option = None; + let mut custom_headers: Vec<(String, String)> = Vec::new(); + let mut opts_cookies: Vec<(String, String)> = Vec::new(); + // Body, with the Content-Type it implies (None for a raw body). + let mut body: Option<(Vec, Option<&'static str>)> = None; + // Auth: (is_digest, username, password). + let mut auth: Option<(bool, String, String)> = None; + + if let Some(opts) = opts.as_ref() { + if let Some(t) = opts.get::>("timeout")? { + timeout = Some(Duration::try_from_secs_f64(t).map_err(|_| { + mlua::Error::external( + "http: timeout must be a non-negative finite number of seconds", + ) + })?); + } + if let Some(headers) = opts.get::>("headers")? { + for pair in headers.pairs::() { + custom_headers.push(pair?); + } + } + if let Some(cookies) = opts.get::>("cookies")? { + for pair in cookies.pairs::() { + opts_cookies.push(pair?); + } + } + + // Body: json > form > raw body (first one present wins). + if let Some(json_val) = opts.get::>("json")? { + let json = lua_to_json(json_val, 0)?; + let s = serde_json::to_string(&json).map_err(mlua::Error::external)?; + body = Some((s.into_bytes(), Some("application/json"))); + } else if let Some(form) = opts.get::>("form")? { + let mut pairs = Vec::new(); + for pair in form.pairs::() { + pairs.push(pair?); + } + body = Some(( + form_urlencode(&pairs).into_bytes(), + Some("application/x-www-form-urlencoded"), + )); + } else if let Some(b) = opts.get::>("body")? { + body = Some((b.as_bytes().to_vec(), None)); + } + + // Auth: { username, password, scheme = "basic" (default) | "digest" }. + if let Some(auth_tbl) = opts.get::>("auth")? { + let username = auth_tbl + .get::>("username")? + .ok_or_else(|| mlua::Error::external("http: auth.username is required"))?; + let password = auth_tbl + .get::>("password")? + .ok_or_else(|| mlua::Error::external("http: auth.password is required"))?; + let is_digest = match auth_tbl.get::>("scheme")?.as_deref() { + None | Some("basic") => false, + Some("digest") => true, + Some(other) => { + return Err(mlua::Error::external(format!( + "http: auth.scheme must be \"basic\" or \"digest\", got \"{other}\"" + ))); + } + }; + auth = Some((is_digest, username, password)); + } + } + + let is_digest = matches!(auth.as_ref(), Some((true, _, _))); + let mut digest_header: Option = None; + let mut digest_tried = false; + + let mut url = url; + let mut redirects_left = MAX_REDIRECTS; + + let resp = loop { + // Host used for cookie matching and as the Set-Cookie domain fallback; + // recomputed each hop since a redirect may cross hosts. + let host = reqwest::Url::parse(&url) + .ok() + .and_then(|u| u.host_str().map(|h| h.to_ascii_lowercase())); + + let mut req = client.request(method.clone(), &url); + + if let Some(t) = timeout { + req = req.timeout(t); + } + for (k, v) in &custom_headers { + req = req.header(k, v); + } + + // Cookie header: jar cookies for this host, then per-request cookies + // which override on name collision. + let mut cookie_map: HashMap = HashMap::new(); + if let (Some(jar), Some(host)) = (jar.as_ref(), host.as_ref()) { + for (n, v) in jar.lock().unwrap().cookies_for(host) { + cookie_map.insert(n, v); + } + } + for (k, v) in &opts_cookies { + cookie_map.insert(k.clone(), v.clone()); + } + if !cookie_map.is_empty() { + let header = cookie_map + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("; "); + req = req.header(reqwest::header::COOKIE, header); + } + + if let Some((bytes, ct)) = body.as_ref() { + if let Some(ct) = ct { + req = req.header(reqwest::header::CONTENT_TYPE, *ct); + } + req = req.body(bytes.clone()); + } + + // Auth. Basic goes out on every hop; digest's Authorization is set only + // after the 401 challenge below has been answered (digest_header). + if let Some((digest, username, password)) = auth.as_ref() { + if *digest { + if let Some(h) = digest_header.as_ref() { + req = req.header(reqwest::header::AUTHORIZATION, h); + } + } else { + req = req.basic_auth(username, Some(password)); + } + } + + let resp = req + .send() + .await + .map_err(|e| mlua::Error::external(format!("http: {e}")))?; + let status = resp.status(); + + // Store Set-Cookie from this hop into the jar. + if let (Some(jar), Some(host)) = (jar.as_ref(), host.as_ref()) { + let mut j = jar.lock().unwrap(); + for value in resp.headers().get_all(reqwest::header::SET_COOKIE).iter() { + if let Ok(s) = value.to_str() { + j.set_from_header(host, s); + } + } + } + + // Digest auth: answer a 401 challenge once, then retry the same request + // with the computed Authorization header. + if is_digest && !digest_tried && status == reqwest::StatusCode::UNAUTHORIZED { + let challenge = resp + .headers() + .get(reqwest::header::WWW_AUTHENTICATE) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + if let Some((_, username, password)) = auth.as_ref() + && let Some(challenge) = challenge + && let Ok(mut prompt) = digest_auth::parse(&challenge) + { + let uri = reqwest::Url::parse(&url) + .map(|u| match u.query() { + Some(q) => format!("{}?{}", u.path(), q), + None => u.path().to_string(), + }) + .unwrap_or_else(|_| url.clone()); + let ctx = digest_auth::AuthContext::new_with_method( + username.as_str(), + password.as_str(), + uri, + body.as_ref().map(|(b, _)| b.as_slice()), + digest_auth::HttpMethod::from(method.as_str()), + ); + if let Ok(answer) = prompt.respond(&ctx) { + digest_header = Some(answer.to_header_string()); + digest_tried = true; + continue; + } + } + } + + // Follow a redirect if there is one to follow. + if status.is_redirection() + && redirects_left > 0 + && let Some(next) = resp + .headers() + .get(reqwest::header::LOCATION) + .and_then(|l| l.to_str().ok()) + .and_then(|loc| reqwest::Url::parse(&url).and_then(|base| base.join(loc)).ok()) + { + redirects_left -= 1; + // 303, and 301/302 on a POST, degrade to a bodyless GET — the + // behaviour browsers and reqwest's own redirect policy apply. + let code = status.as_u16(); + if code == 303 || ((code == 301 || code == 302) && method == reqwest::Method::POST) { + method = reqwest::Method::GET; + body = None; + } + url = next.to_string(); + continue; + } + + break resp; + }; + + let status = resp.status().as_u16(); + + // Response headers: lowercase names, first value wins. + let headers_tbl = lua.create_table()?; + for (name, value) in resp.headers().iter() { + let lname = name.as_str().to_ascii_lowercase(); + if !headers_tbl.contains_key(lname.as_str())? { + headers_tbl.raw_set(lname.as_str(), lua.create_string(value.as_bytes())?)?; + } + } + + let bytes = resp + .bytes() + .await + .map_err(|e| mlua::Error::external(format!("http: {e}")))?; + + let out = lua.create_table()?; + out.raw_set("status", status)?; + out.raw_set("ok", (200..=299).contains(&status))?; + out.raw_set("headers", headers_tbl)?; + out.raw_set("body", lua.create_string(&bytes)?)?; + Ok(out) +} + +// --------------------------------------------------------------------------- +// Session +// --------------------------------------------------------------------------- + +/// Build the plain Lua table that backs a session. State (the cookie jar) lives +/// in the closures; the Lua layer applies a metatable for the method shorthands. +/// Methods are called as `s:method(...)`, so each closure receives the session +/// table as a leading `_this` argument that it ignores. +fn make_session( + lua: &Lua, + client: reqwest::Client, + jar: Arc>, +) -> LuaResult { + let tbl = lua.create_table()?; + + tbl.raw_set( + "_request", + lua.create_async_function({ + let client = client.clone(); + let jar = jar.clone(); + move |lua, (_this, method, url, opts): (LuaTable, String, String, Option)| { + let client = client.clone(); + let jar = jar.clone(); + async move { + execute_request(lua, client, Some(jar), method, url, opts).await + } + } + })?, + )?; + + tbl.raw_set("save", { + let jar = jar.clone(); + lua.create_function(move |_, (_this, path): (LuaTable, String)| { + let jsonl = jar.lock().unwrap().to_jsonl(); + std::fs::write(&path, jsonl) + .map_err(|e| mlua::Error::external(format!("session:save: {e}"))) + })? + })?; + + tbl.raw_set("load", { + let jar = jar.clone(); + lua.create_function(move |_, (_this, path): (LuaTable, String)| { + let src = std::fs::read_to_string(&path) + .map_err(|e| mlua::Error::external(format!("session:load: {e}")))?; + jar.lock().unwrap().merge_jsonl(&src); + Ok(()) + })? + })?; + + tbl.raw_set("clearCookies", { + let jar = jar.clone(); + lua.create_function(move |_, _this: LuaTable| { + jar.lock().unwrap().cookies.clear(); + Ok(()) + })? + })?; + + tbl.raw_set("cookies", { + let jar = jar.clone(); + lua.create_function(move |lua, _this: LuaTable| { + let outer = lua.create_table()?; + let j = jar.lock().unwrap(); + for (domain, names) in j.cookies.iter() { + let inner = lua.create_table()?; + for (name, value) in names.iter() { + inner.raw_set(name.as_str(), value.as_str())?; + } + outer.raw_set(domain.as_str(), inner)?; + } + Ok(outer) + })? + })?; + + Ok(tbl) +} + +// --------------------------------------------------------------------------- +// Installation +// --------------------------------------------------------------------------- + +pub(super) fn install(lua: &Lua) -> LuaResult<()> { + // reqwest is built with `rustls-no-provider`, so it has no crypto provider + // of its own and panics ("No provider set") unless one is installed as the + // process default before the client is built. `ring` is self-contained + // (compiled in, no system OpenSSL). Idempotent across Lua states — only the + // first install in the process wins, and they would all install ring. + let _ = rustls::crypto::ring::default_provider().install_default(); + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + // reqwest sends no User-Agent by default; some edges/CDNs reject + // empty-UA requests outright. A per-request `headers` entry overrides it. + .user_agent(concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"))) + // Redirects are followed manually in execute_request so Set-Cookie + // headers on 30x responses can be captured into the cookie jar. + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(mlua::Error::external)?; + + let http = lua.create_table()?; + + // Stateless http.request — no cookie jar. + http.raw_set( + "request", + lua.create_async_function({ + let client = client.clone(); + move |lua, (method, url, opts): (String, String, Option)| { + let client = client.clone(); + async move { execute_request(lua, client, None, method, url, opts).await } + } + })?, + )?; + + // http.session(path?) — constructor returning the raw session table. + http.raw_set( + "session", + lua.create_async_function({ + let client = client.clone(); + move |lua, path: Option| { + let client = client.clone(); + async move { + let mut jar = CookieJar::default(); + if let Some(p) = path { + let src = tokio::fs::read_to_string(&p) + .await + .map_err(|e| mlua::Error::external(format!("http.session: {e}")))?; + jar.merge_jsonl(&src); + } + make_session(&lua, client, Arc::new(Mutex::new(jar))) + } + } + })?, + )?; + + lua.globals().raw_set("http", http)?; + + // Lua side adds: resp.json(), method shorthands, getJSON/postJSON, and the + // session metatable wrapping http.session(). + lua.load(HTTP_LUA).set_name("@[stdlib/http]").exec() +} diff --git a/src/stdlib/mod.rs b/src/stdlib/mod.rs index c7a4402..98f6e64 100644 --- a/src/stdlib/mod.rs +++ b/src/stdlib/mod.rs @@ -1,8 +1,10 @@ +mod http; mod logging; mod math; mod os_ext; mod sqlite; mod table; +mod task; pub(crate) mod utils; pub(crate) fn install(lua: &mlua::Lua) -> mlua::Result<()> { @@ -12,5 +14,7 @@ pub(crate) fn install(lua: &mlua::Lua) -> mlua::Result<()> { os_ext::install(lua)?; logging::install(lua)?; sqlite::install(lua)?; + http::install(lua)?; + task::install(lua)?; Ok(()) } diff --git a/src/stdlib/task.rs b/src/stdlib/task.rs new file mode 100644 index 0000000..548c0fa --- /dev/null +++ b/src/stdlib/task.rs @@ -0,0 +1,46 @@ +use futures::future::join_all; +use mlua::prelude::{LuaResult, LuaValue}; +use mlua::{Function, Lua, MultiValue, Variadic}; + +/// Proof-of-concept concurrency primitive built on Lua coroutines + tokio. +/// +/// `task.join(f1, f2, ...)` runs each function as its own Lua coroutine and +/// drives them *concurrently* on the tokio runtime, returning each one's first +/// result positionally once all have finished. Because async stdlib calls like +/// `os.sleep` yield to the tokio reactor rather than blocking the OS thread, +/// sibling coroutines make progress while one is sleeping — so N tasks that each +/// sleep T seconds finish in ~T, not ~N*T. +/// +/// Note: the coroutines are NOT `tokio::spawn`ed (the Lua state is `!Send`); +/// they are polled cooperatively on the current thread via `join_all`. The +/// concurrency comes from the reactor, not from extra threads. +pub(super) fn install(lua: &Lua) -> LuaResult<()> { + let task = lua.create_table()?; + + task.raw_set( + "join", + lua.create_async_function(|lua, funcs: Variadic| async move { + // Wrap each function in its own coroutine and turn it into a future. + let mut threads = Vec::with_capacity(funcs.len()); + for f in funcs.iter() { + let thread = lua.create_thread(f.clone())?; + threads.push(thread.into_async::(())); + } + let threads: Vec<_> = threads.into_iter().collect::>()?; + + // Poll them all concurrently. The await point is where the tokio + // reactor gets to interleave the sleeping coroutines. + let results = join_all(threads).await; + + // Collect first-return-values positionally; propagate the first error. + let mut out = Vec::with_capacity(results.len()); + for r in results { + out.push(r?); + } + Ok(MultiValue::from_vec(out)) + })?, + )?; + + lua.globals().raw_set("task", task)?; + Ok(()) +} diff --git a/src/stdlib/utils.rs b/src/stdlib/utils.rs index ad9b13a..046d460 100644 --- a/src/stdlib/utils.rs +++ b/src/stdlib/utils.rs @@ -21,7 +21,7 @@ pub(crate) fn is_null(val: &LuaValue) -> bool { // Lua → JSON // --------------------------------------------------------------------------- -fn lua_to_json(val: LuaValue, depth: u32) -> LuaResult { +pub(crate) fn lua_to_json(val: LuaValue, depth: u32) -> LuaResult { const MAX_DEPTH: u32 = 64; if depth > MAX_DEPTH { return Err(mlua::Error::external("utils.toJSON: nesting too deep (max 64 levels)")); @@ -47,7 +47,7 @@ fn lua_to_json(val: LuaValue, depth: u32) -> LuaResult { } } -fn table_to_json(t: mlua::Table, depth: u32) -> LuaResult { +pub(crate) fn table_to_json(t: mlua::Table, depth: u32) -> LuaResult { let seq_len = t.raw_len() as usize; // Try array serialization: every key must be a sequential integer in 1..=seq_len.