hurl-6.1.1/.cargo_vcs_info.json0000644000000001530000000000100120120ustar { "git": { "sha1": "ae921ffbd836db667995c17262d11b58c2ee7e87" }, "path_in_vcs": "packages/hurl" }hurl-6.1.1/Cargo.lock0000644000001146160000000000100077770ustar # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "adler32" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aae1277d39aeec15cb388266ecc24b11c80469deae6067e17a1a7aa9e5c1f234" [[package]] name = "ahash" version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if", "once_cell", "version_check", "zerocopy 0.7.35", ] [[package]] name = "aho-corasick" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] [[package]] name = "alloc-no-stdlib" version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" dependencies = [ "alloc-no-stdlib", ] [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android-tzdata" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" [[package]] name = "android_system_properties" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] [[package]] name = "anstream" version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" [[package]] name = "anstyle-parse" version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" dependencies = [ "windows-sys 0.59.0", ] [[package]] name = "anstyle-wincon" version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e" dependencies = [ "anstyle", "once_cell", "windows-sys 0.59.0", ] [[package]] name = "autocfg" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bitflags" version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" [[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ "generic-array", ] [[package]] name = "brotli" version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc97b8f16f944bba54f0433f07e30be199b6dc2bd25937444bbad560bcea29bd" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", "brotli-decompressor", ] [[package]] name = "brotli-decompressor" version = "4.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74fa05ad7d803d413eb8380983b092cbbaf9a85f151b871360e7b00cd7060b37" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", ] [[package]] name = "bumpalo" version = "3.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" [[package]] name = "cc" version = "1.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" dependencies = [ "shlex", ] [[package]] name = "cfg-if" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" version = "0.4.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" dependencies = [ "android-tzdata", "iana-time-zone", "num-traits", "windows-link", ] [[package]] name = "clap" version = "4.5.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6088f3ae8c3608d19260cd7445411865a485688711b78b5be70d78cd96136f83" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" version = "4.5.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22a7ef7f676155edfb82daa97f99441f3ebf4a58d5e32f295a56259f1b6facc8" dependencies = [ "anstream", "anstyle", "clap_lex", "strsim", "terminal_size", ] [[package]] name = "clap_lex" version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "colorchoice" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" [[package]] name = "colored" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" dependencies = [ "windows-sys 0.59.0", ] [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "core2" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" dependencies = [ "memchr", ] [[package]] name = "cpufeatures" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] [[package]] name = "crc32fast" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" dependencies = [ "cfg-if", ] [[package]] name = "crypto-common" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "typenum", ] [[package]] name = "curl" version = "0.4.47" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9fb4d13a1be2b58f14d60adba57c9834b78c62fd86c3e76a148f732686e9265" dependencies = [ "curl-sys", "libc", "openssl-probe", "openssl-sys", "schannel", "socket2", "windows-sys 0.52.0", ] [[package]] name = "curl-sys" version = "0.4.80+curl-8.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55f7df2eac63200c3ab25bde3b2268ef2ee56af3d238e76d61f01c3c49bff734" dependencies = [ "cc", "libc", "libz-sys", "openssl-sys", "pkg-config", "vcpkg", "windows-sys 0.52.0", ] [[package]] name = "dary_heap" version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04d2cd9c18b9f454ed67da600630b021a8a80bf33f8c95896ab33aaf1c26b728" [[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 = "displaydoc" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "encoding" version = "0.2.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec" dependencies = [ "encoding-index-japanese", "encoding-index-korean", "encoding-index-simpchinese", "encoding-index-singlebyte", "encoding-index-tradchinese", ] [[package]] name = "encoding-index-japanese" version = "1.20141219.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91" dependencies = [ "encoding_index_tests", ] [[package]] name = "encoding-index-korean" version = "1.20141219.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81" dependencies = [ "encoding_index_tests", ] [[package]] name = "encoding-index-simpchinese" version = "1.20141219.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7" dependencies = [ "encoding_index_tests", ] [[package]] name = "encoding-index-singlebyte" version = "1.20141219.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a" dependencies = [ "encoding_index_tests", ] [[package]] name = "encoding-index-tradchinese" version = "1.20141219.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18" dependencies = [ "encoding_index_tests", ] [[package]] name = "encoding_index_tests" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569" [[package]] name = "errno" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" dependencies = [ "libc", "windows-sys 0.59.0", ] [[package]] name = "form_urlencoded" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" dependencies = [ "percent-encoding", ] [[package]] name = "generic-array" version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", ] [[package]] name = "getrandom" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" dependencies = [ "cfg-if", "libc", "r-efi", "wasi", ] [[package]] name = "glob" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", "allocator-api2", ] [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hurl" version = "6.1.1" dependencies = [ "base64", "brotli", "cc", "chrono", "clap", "curl", "curl-sys", "encoding", "glob", "hex", "hurl_core", "lazy_static", "libflate", "libxml", "md5", "percent-encoding", "regex", "serde", "serde_json", "sha2", "similar", "terminal_size", "termion", "url", "uuid", "winres", "xml-rs", ] [[package]] name = "hurl_core" version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "505b5e449d27ba650b225aa4fd189b6db237cb90e674308ce26fa7277168fe80" dependencies = [ "colored", "libxml", "regex", ] [[package]] name = "iana-time-zone" version = "0.1.61" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "wasm-bindgen", "windows-core", ] [[package]] name = "iana-time-zone-haiku" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ "cc", ] [[package]] name = "icu_collections" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" dependencies = [ "displaydoc", "yoke", "zerofrom", "zerovec", ] [[package]] name = "icu_locid" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" dependencies = [ "displaydoc", "litemap", "tinystr", "writeable", "zerovec", ] [[package]] name = "icu_locid_transform" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" dependencies = [ "displaydoc", "icu_locid", "icu_locid_transform_data", "icu_provider", "tinystr", "zerovec", ] [[package]] name = "icu_locid_transform_data" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" [[package]] name = "icu_normalizer" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" dependencies = [ "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", "utf16_iter", "utf8_iter", "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" [[package]] name = "icu_properties" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" dependencies = [ "displaydoc", "icu_collections", "icu_locid_transform", "icu_properties_data", "icu_provider", "tinystr", "zerovec", ] [[package]] name = "icu_properties_data" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" [[package]] name = "icu_provider" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" dependencies = [ "displaydoc", "icu_locid", "icu_provider_macros", "stable_deref_trait", "tinystr", "writeable", "yoke", "zerofrom", "zerovec", ] [[package]] name = "icu_provider_macros" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "idna" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" dependencies = [ "idna_adapter", "smallvec", "utf8_iter", ] [[package]] name = "idna_adapter" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" dependencies = [ "icu_normalizer", "icu_properties", ] [[package]] name = "is_terminal_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itoa" version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" dependencies = [ "once_cell", "wasm-bindgen", ] [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" version = "0.2.171" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" [[package]] name = "libflate" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45d9dfdc14ea4ef0900c1cddbc8dcd553fbaacd8a4a282cf4018ae9dd04fb21e" dependencies = [ "adler32", "core2", "crc32fast", "dary_heap", "libflate_lz77", ] [[package]] name = "libflate_lz77" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e0d73b369f386f1c44abd9c570d5318f55ccde816ff4b562fa452e5182863d" dependencies = [ "core2", "hashbrown", "rle-decode-fast", ] [[package]] name = "libredox" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ "bitflags", "libc", "redox_syscall", ] [[package]] name = "libxml" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fe73cdec2bcb36d25a9fe3f607ffcd44bb8907ca0100c4098d1aa342d1e7bec" dependencies = [ "libc", "pkg-config", "vcpkg", ] [[package]] name = "libz-sys" version = "1.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" dependencies = [ "cc", "libc", "pkg-config", "vcpkg", ] [[package]] name = "linux-raw-sys" version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe7db12097d22ec582439daf8618b8fdd1a7bef6270e9af3b1ebcd30893cf413" [[package]] name = "litemap" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" [[package]] name = "log" version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" [[package]] name = "md5" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" [[package]] name = "memchr" version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] [[package]] name = "numtoa" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f" [[package]] name = "once_cell" version = "1.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d75b0bedcc4fe52caa0e03d9f1151a323e4aa5e2d78ba3580400cd3c9e2bc4bc" [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-src" version = "300.4.2+3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "168ce4e058f975fe43e89d9ccf78ca668601887ae736090aacc23ae353c298e2" dependencies = [ "cc", ] [[package]] name = "openssl-sys" version = "0.9.106" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bb61ea9811cc39e3c2069f40b8b8e2e70d8569b361f879786cc7ed48b777cdd" dependencies = [ "cc", "libc", "openssl-src", "pkg-config", "vcpkg", ] [[package]] name = "percent-encoding" version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "ppv-lite86" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ "zerocopy 0.8.23", ] [[package]] name = "proc-macro2" version = "1.0.94" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" dependencies = [ "unicode-ident", ] [[package]] name = "quote" version = "1.0.40" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" [[package]] name = "rand" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" dependencies = [ "rand_chacha", "rand_core", "zerocopy 0.8.23", ] [[package]] name = "rand_chacha" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", "rand_core", ] [[package]] name = "rand_core" version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ "getrandom", ] [[package]] name = "redox_syscall" version = "0.5.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b8c0c260b63a8219631167be35e6a988e9554dbd323f8bd08439c8ed1302bd1" dependencies = [ "bitflags", ] [[package]] name = "redox_termios" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20145670ba436b55d91fc92d25e71160fbfbdd57831631c8d7d36377a476f1cb" [[package]] name = "regex" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax", ] [[package]] name = "regex-automata" version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "rle-decode-fast" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" [[package]] name = "rustix" version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e56a18552996ac8d29ecc3b190b4fdbb2d91ca4ec396de7bbffaf43f3d637e96" dependencies = [ "bitflags", "errno", "libc", "linux-raw-sys", "windows-sys 0.59.0", ] [[package]] name = "rustversion" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" [[package]] name = "ryu" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" [[package]] name = "schannel" version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" dependencies = [ "windows-sys 0.59.0", ] [[package]] name = "serde" version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "serde_json" version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ "itoa", "memchr", "ryu", "serde", ] [[package]] name = "sha2" version = "0.10.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures", "digest", ] [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "similar" version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "smallvec" version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd" [[package]] name = "socket2" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c970269d99b64e60ec3bd6ad27270092a5394c4e309314b18ae3fe575695fbe8" dependencies = [ "libc", "windows-sys 0.52.0", ] [[package]] name = "stable_deref_trait" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" version = "2.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "synstructure" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "terminal_size" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed" dependencies = [ "rustix", "windows-sys 0.59.0", ] [[package]] name = "termion" version = "4.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f359c854fbecc1ea65bc3683f1dcb2dce78b174a1ca7fda37acd1fff81df6ff" dependencies = [ "libc", "libredox", "numtoa", "redox_termios", ] [[package]] name = "tinystr" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" dependencies = [ "displaydoc", "zerovec", ] [[package]] name = "toml" version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" dependencies = [ "serde", ] [[package]] name = "typenum" version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" [[package]] name = "unicode-ident" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" [[package]] name = "url" version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", "idna", "percent-encoding", ] [[package]] name = "utf16_iter" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ "getrandom", "rand", ] [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wasi" version = "0.14.2+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" dependencies = [ "wit-bindgen-rt", ] [[package]] name = "wasm-bindgen" version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", ] [[package]] name = "wasm-bindgen-backend" version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" dependencies = [ "bumpalo", "log", "proc-macro2", "quote", "syn", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", ] [[package]] name = "wasm-bindgen-macro-support" version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", "syn", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" dependencies = [ "unicode-ident", ] [[package]] name = "windows-core" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" dependencies = [ "windows-targets", ] [[package]] name = "windows-link" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76840935b766e1b0a05c0066835fb9ec80071d4c09a16f6bd5f7e655e3c14c38" [[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.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets", ] [[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 = "winres" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b68db261ef59e9e52806f688020631e987592bd83619edccda9c47d42cde4f6c" dependencies = [ "toml", ] [[package]] name = "wit-bindgen-rt" version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ "bitflags", ] [[package]] name = "write16" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" [[package]] name = "writeable" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" [[package]] name = "xml-rs" version = "0.8.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5b940ebc25896e71dd073bad2dbaa2abfe97b0a391415e22ad1326d9c54e3c4" [[package]] name = "yoke" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" dependencies = [ "serde", "stable_deref_trait", "yoke-derive", "zerofrom", ] [[package]] name = "yoke-derive" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", "syn", "synstructure", ] [[package]] name = "zerocopy" version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ "zerocopy-derive 0.7.35", ] [[package]] name = "zerocopy" version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd97444d05a4328b90e75e503a34bad781f14e28a823ad3557f0750df1ebcbc6" dependencies = [ "zerocopy-derive 0.8.23", ] [[package]] name = "zerocopy-derive" version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "zerocopy-derive" version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6352c01d0edd5db859a63e2605f4ea3183ddbd15e2c4a9e7d32184df75e4f154" dependencies = [ "proc-macro2", "quote", "syn", ] [[package]] name = "zerofrom" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", "syn", "synstructure", ] [[package]] name = "zerovec" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" dependencies = [ "yoke", "zerofrom", "zerovec-derive", ] [[package]] name = "zerovec-derive" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", "syn", ] hurl-6.1.1/Cargo.toml0000644000000054030000000000100100130ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" rust-version = "1.85.0" name = "hurl" version = "6.1.1" authors = [ "Fabrice Reix ", "Jean-Christophe Amiel ", "Filipe Pinto ", ] build = "build.rs" autolib = false autobins = false autoexamples = false autotests = false autobenches = false description = "Hurl, run and test HTTP requests" homepage = "https://hurl.dev" documentation = "https://hurl.dev" readme = "README.md" license = "Apache-2.0" repository = "https://github.com/Orange-OpenSource/hurl" [features] static-openssl = [ "curl/static-ssl", "curl-sys/static-ssl", ] [lib] name = "hurl" path = "src/lib.rs" [[bin]] name = "hurl" path = "src/main.rs" [[test]] name = "sample" path = "tests/sample.rs" [dependencies.base64] version = "0.22.1" [dependencies.brotli] version = "7.0.0" [dependencies.chrono] version = "0.4.40" features = ["clock"] default-features = false [dependencies.clap] version = "4.5.32" features = [ "string", "wrap_help", ] [dependencies.curl] version = "0.4.47" [dependencies.curl-sys] version = "0.4.80" [dependencies.encoding] version = "0.2.33" [dependencies.glob] version = "0.3.2" [dependencies.hex] version = "0.4.3" [dependencies.hurl_core] version = "6.1.1" [dependencies.lazy_static] version = "1.5.0" [dependencies.libflate] version = "2.1.0" [dependencies.libxml] version = "0.3.3" [dependencies.md5] version = "0.7.0" [dependencies.percent-encoding] version = "2.3.1" [dependencies.regex] version = "1.11.1" [dependencies.serde] version = "1.0.219" features = ["derive"] [dependencies.serde_json] version = "1.0.140" features = ["arbitrary_precision"] [dependencies.sha2] version = "0.10.8" [dependencies.similar] version = "2.7.0" [dependencies.terminal_size] version = "0.4.2" [dependencies.url] version = "2.5.4" [dependencies.uuid] version = "1.16.0" features = [ "v4", "fast-rng", ] [dependencies.xml-rs] version = "0.8.25" [build-dependencies.cc] version = "1.2.16" [target."cfg(unix)".dependencies.termion] version = "4.0.4" [target."cfg(windows)".build-dependencies.winres] version = "0.1.12" [lints.clippy] empty_structs_with_brackets = "deny" manual_string_new = "deny" semicolon_if_nothing_returned = "deny" wildcard-imports = "deny" [lints.rust] warnings = "deny" hurl-6.1.1/Cargo.toml.orig000064400000000000000000000032251046102023000134740ustar 00000000000000[package] name = "hurl" version = "6.1.1" authors = ["Fabrice Reix ", "Jean-Christophe Amiel ", "Filipe Pinto "] edition = "2021" license = "Apache-2.0" description = "Hurl, run and test HTTP requests" documentation = "https://hurl.dev" homepage = "https://hurl.dev" repository = "https://github.com/Orange-OpenSource/hurl" rust-version = "1.85.0" [lib] name = "hurl" [features] # Re-export of curl/static-ssl: use a bundled OpenSSL version and statically link to it. Only applies on platforms that # use OpenSSL static-openssl = ["curl/static-ssl", "curl-sys/static-ssl"] [dependencies] base64 = "0.22.1" brotli = "7.0.0" chrono = { version = "0.4.40", default-features = false, features = ["clock"] } clap = { version = "4.5.32", features = ["string", "wrap_help"] } curl = "0.4.47" curl-sys = "0.4.80" encoding = "0.2.33" glob = "0.3.2" hex = "0.4.3" hurl_core = { version = "6.1.1", path = "../hurl_core" } libflate = "2.1.0" libxml = "0.3.3" md5 = "0.7.0" percent-encoding = "2.3.1" regex = "1.11.1" serde = { version = "1.0.219", features = ["derive"] } serde_json = { version = "1.0.140", features = ["arbitrary_precision"] } sha2 = "0.10.8" url = "2.5.4" xml-rs = { version = "0.8.25" } lazy_static = "1.5.0" # uuid features: lets you generate random UUIDs and use a faster (but still sufficiently random) RNG uuid = { version = "1.16.0", features = ["v4" , "fast-rng"] } similar = "2.7.0" terminal_size = "0.4.2" [target.'cfg(unix)'.dependencies] termion = "4.0.4" [target.'cfg(windows)'.build-dependencies] winres = "0.1.12" [build-dependencies] cc = "1.2.16" [lints] workspace = true hurl-6.1.1/README.md000064400000000000000000002263451046102023000120760ustar 00000000000000 Hurl Logo [![deploy status](https://github.com/Orange-OpenSource/hurl/workflows/test/badge.svg)](https://github.com/Orange-OpenSource/hurl/actions) [![coverage](https://Orange-OpenSource.github.io/hurl/coverage/badges/flat.svg)](https://Orange-OpenSource.github.io/hurl/coverage) [![Crates.io](https://img.shields.io/crates/v/hurl.svg)](https://crates.io/crates/hurl) [![documentation](https://img.shields.io/badge/-documentation-ff0288)](https://hurl.dev) # What's Hurl? Hurl is a command line tool that runs HTTP requests defined in a simple plain text format. It can chain requests, capture values and evaluate queries on headers and body response. Hurl is very versatile: it can be used for both fetching data and testing HTTP sessions. Hurl makes it easy to work with HTML content, REST / SOAP / GraphQL APIs, or any other XML / JSON based APIs. ```hurl # Get home: GET https://example.org HTTP 200 [Captures] csrf_token: xpath "string(//meta[@name='_csrf_token']/@content)" # Do login! POST https://example.org/login?user=toto&password=1234 X-CSRF-TOKEN: {{csrf_token}} HTTP 302 ``` Chaining multiple requests is easy: ```hurl GET https://example.org/api/health GET https://example.org/api/step1 GET https://example.org/api/step2 GET https://example.org/api/step3 ``` # Also an HTTP Test Tool Hurl can run HTTP requests but can also be used to test HTTP responses. Different types of queries and predicates are supported, from [XPath] and [JSONPath] on body response, to assert on status code and response headers. Hurl Demo It is well adapted for REST / JSON APIs ```hurl POST https://example.org/api/tests { "id": "4568", "evaluate": true } HTTP 200 [Asserts] header "X-Frame-Options" == "SAMEORIGIN" jsonpath "$.status" == "RUNNING" # Check the status code jsonpath "$.tests" count == 25 # Check the number of items jsonpath "$.id" matches /\d{4}/ # Check the format of the id ``` HTML content ```hurl GET https://example.org HTTP 200 [Asserts] xpath "normalize-space(//head/title)" == "Hello world!" ``` GraphQL ~~~hurl POST https://example.org/graphql ```graphql { human(id: "1000") { name height(unit: FOOT) } } ``` HTTP 200 ~~~ and even SOAP APIs ```hurl POST https://example.org/InStock Content-Type: application/soap+xml; charset=utf-8 SOAPAction: "http://www.w3.org/2003/05/soap-envelope" GOOG HTTP 200 ``` Hurl can also be used to test the performance of HTTP endpoints ```hurl GET https://example.org/api/v1/pets HTTP 200 [Asserts] duration < 1000 # Duration in ms ``` And check response bytes ```hurl GET https://example.org/data.tar.gz HTTP 200 [Asserts] sha256 == hex,039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81; ``` Finally, Hurl is easy to integrate in CI/CD, with text, JUnit, TAP and HTML reports HTML report # Why Hurl?
  • Text Format: for both devops and developers
  • Fast CLI: a command line for local dev and continuous integration
  • Single Binary: easy to install, with no runtime required
# Powered by curl Hurl is a lightweight binary written in [Rust]. Under the hood, Hurl HTTP engine is powered by [libcurl], one of the most powerful and reliable file transfer libraries. With its text file format, Hurl adds syntactic sugar to run and test HTTP requests, but it's still the [curl] that we love: __fast__, __efficient__ and __IPv6 / HTTP/3 ready__. # Feedbacks To support its development, [star Hurl on GitHub]! [Feedback, suggestion, bugs or improvements] are welcome. ```hurl POST https://hurl.dev/api/feedback { "name": "John Doe", "feedback": "Hurl is awesome!" } HTTP 200 ``` # Resources [License] [Blog] [Tutorial] [Documentation] (download [HTML], [PDF], [Markdown]) [GitHub] # Table of Contents * [Samples](#samples) * [Getting Data](#getting-data) * [HTTP Headers](#http-headers) * [Query Params](#query-params) * [Basic Authentication](#basic-authentication) * [Passing Data between Requests ](#passing-data-between-requests) * [Sending Data](#sending-data) * [Sending HTML Form Data](#sending-html-form-data) * [Sending Multipart Form Data](#sending-multipart-form-data) * [Posting a JSON Body](#posting-a-json-body) * [Templating a JSON Body](#templating-a-json-body) * [Templating a XML Body](#templating-a-xml-body) * [Using GraphQL Query](#using-graphql-query) * [Using Dynamic Datas](#using-dynamic-datas) * [Testing Response](#testing-response) * [Testing Status Code](#testing-status-code) * [Testing Response Headers](#testing-response-headers) * [Testing REST APIs](#testing-rest-apis) * [Testing HTML Response](#testing-html-response) * [Testing Set-Cookie Attributes](#testing-set-cookie-attributes) * [Testing Bytes Content](#testing-bytes-content) * [SSL Certificate](#ssl-certificate) * [Checking Full Body](#checking-full-body) * [Reports](#reports) * [HTML Report](#html-report) * [JSON Report](#json-report) * [JUnit Report](#junit-report) * [TAP Report](#tap-report) * [JSON Output](#json-output) * [Others](#others) * [HTTP Version](#http-version) * [IP Address](#ip-address) * [Polling and Retry](#polling-and-retry) * [Delaying Requests](#delaying-requests) * [Skipping Requests](#skipping-requests) * [Testing Endpoint Performance](#testing-endpoint-performance) * [Using SOAP APIs](#using-soap-apis) * [Capturing and Using a CSRF Token](#capturing-and-using-a-csrf-token) * [Redacting Secrets](#redacting-secrets) * [Checking Byte Order Mark (BOM) in Response Body](#checking-byte-order-mark-bom-in-response-body) * [AWS Signature Version 4 Requests](#aws-signature-version-4-requests) * [Using curl Options](#using-curl-options) * [Manual](#manual) * [Name](#name) * [Synopsis](#synopsis) * [Description](#description) * [Hurl File Format](#hurl-file-format) * [Capturing values](#capturing-values) * [Asserts](#asserts) * [Options](#options) * [Environment](#environment) * [Exit Codes](#exit-codes) * [WWW](#www) * [See Also](#see-also) * [Installation](#installation) * [Binaries Installation](#binaries-installation) * [Linux](#linux) * [Debian / Ubuntu](#debian--ubuntu) * [Alpine](#alpine) * [Arch Linux / Manjaro](#arch-linux--manjaro) * [NixOS / Nix](#nixos--nix) * [macOS](#macos) * [Homebrew](#homebrew) * [MacPorts](#macports) * [FreeBSD](#freebsd) * [Windows](#windows) * [Zip File](#zip-file) * [Installer](#installer) * [Chocolatey](#chocolatey) * [Scoop](#scoop) * [Windows Package Manager](#windows-package-manager) * [Cargo](#cargo) * [conda-forge](#conda-forge) * [Docker](#docker) * [npm](#npm) * [Building From Sources](#building-from-sources) * [Build on Linux](#build-on-linux) * [Debian based distributions](#debian-based-distributions) * [Fedora based distributions](#fedora-based-distributions) * [Red Hat based distributions](#red-hat-based-distributions) * [Arch based distributions](#arch-based-distributions) * [Alpine based distributions](#alpine-based-distributions) * [Build on macOS](#build-on-macos) * [Build on Windows](#build-on-windows) # Samples To run a sample, edit a file with the sample content, and run Hurl: ```shell $ vi sample.hurl GET https://example.org $ hurl sample.hurl ``` By default, Hurl behaves like [curl] and outputs the last HTTP response's [entry]. To have a test oriented output, you can use [`--test` option]: ```shell $ hurl --test sample.hurl ``` A particular response can be saved with [`[Options] section`](https://hurl.dev/docs/request.html#options): ```hurl GET https://example.ord/cats/123 [Options] output: cat123.txt # use - to output to stdout HTTP 200 GET https://example.ord/dogs/567 HTTP 200 ``` Finally, Hurl can take files as input, or directories. In the latter case, Hurl will search files with `.hurl` extension recursively. ```shell $ hurl --test integration/*.hurl $ hurl --test . ``` You can check [Hurl tests suite] for more samples. ## Getting Data A simple GET: ```hurl GET https://example.org ``` Requests can be chained: ```hurl GET https://example.org/a GET https://example.org/b HEAD https://example.org/c GET https://example.org/c ``` [Doc](https://hurl.dev/docs/request.html#method) ### HTTP Headers A simple GET with headers: ```hurl GET https://example.org/news User-Agent: Mozilla/5.0 Accept: */* Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate, br Connection: keep-alive ``` [Doc](https://hurl.dev/docs/request.html#headers) ### Query Params ```hurl GET https://example.org/news [Query] order: newest search: something to search count: 100 ``` Or: ```hurl GET https://example.org/news?order=newest&search=something%20to%20search&count=100 ``` > With `[Query]` section, params don't need to be URL escaped. [Doc](https://hurl.dev/docs/request.html#query-parameters) ### Basic Authentication ```hurl GET https://example.org/protected [BasicAuth] bob: secret ``` [Doc](https://hurl.dev/docs/request.html#basic-authentication) This is equivalent to construct the request with a [Authorization] header: ```hurl # Authorization header value can be computed with `echo -n 'bob:secret' | base64` GET https://example.org/protected Authorization: Basic Ym9iOnNlY3JldA== ``` Basic authentication section allows per request authentication. If you want to add basic authentication to all the requests of a Hurl file you could use [`-u/--user` option]: ```shell $ hurl --user bob:secret login.hurl ``` [`--user`] option can also be set per request: ```hurl GET https://example.org/login [Options] user: bob:secret HTTP 200 GET https://example.org/login [Options] user: alice:secret HTTP 200 ``` ### Passing Data between Requests [Captures] can be used to pass data from one request to another: ```hurl POST https://sample.org/orders HTTP 201 [Captures] order_id: jsonpath "$.order.id" GET https://sample.org/orders/{{order_id}} HTTP 200 ``` [Doc](https://hurl.dev/docs/capturing-response.html) ## Sending Data ### Sending HTML Form Data ```hurl POST https://example.org/contact [Form] default: false token: {{token}} email: john.doe@rookie.org number: 33611223344 ``` [Doc](https://hurl.dev/docs/request.html#form-parameters) ### Sending Multipart Form Data ```hurl POST https://example.org/upload [Multipart] field1: value1 field2: file,example.txt; # One can specify the file content type: field3: file,example.zip; application/zip ``` [Doc](https://hurl.dev/docs/request.html#multipart-form-data) Multipart forms can also be sent with a [multiline string body]: ~~~hurl POST https://example.org/upload Content-Type: multipart/form-data; boundary="boundary" ``` --boundary Content-Disposition: form-data; name="key1" value1 --boundary Content-Disposition: form-data; name="upload1"; filename="data.txt" Content-Type: text/plain Hello World! --boundary Content-Disposition: form-data; name="upload2"; filename="data.html" Content-Type: text/html
Hello World!
--boundary-- ``` ~~~ In that case, files have to be inlined in the Hurl file. [Doc](https://hurl.dev/docs/request.html#multiline-string-body) ### Posting a JSON Body With an inline JSON: ```hurl POST https://example.org/api/tests { "id": "456", "evaluate": true } ``` [Doc](https://hurl.dev/docs/request.html#json-body) With a local file: ```hurl POST https://example.org/api/tests Content-Type: application/json file,data.json; ``` [Doc](https://hurl.dev/docs/request.html#file-body) ### Templating a JSON Body ```hurl PUT https://example.org/api/hits Content-Type: application/json { "key0": "{{a_string}}", "key1": {{a_bool}}, "key2": {{a_null}}, "key3": {{a_number}} } ``` Variables can be initialized via command line: ```shell $ hurl --variable a_string=apple \ --variable a_bool=true \ --variable a_null=null \ --variable a_number=42 \ test.hurl ``` Resulting in a PUT request with the following JSON body: ``` { "key0": "apple", "key1": true, "key2": null, "key3": 42 } ``` [Doc](https://hurl.dev/docs/templates.html) ### Templating a XML Body Using templates with [XML body] is not currently supported in Hurl. You can use templates in [XML multiline string body] with variables to send a variable XML body: ~~~hurl POST https://example.org/echo/post/xml ```xml {{login}} {{password}} ``` ~~~ [Doc](https://hurl.dev/docs/request.html#multiline-string-body) ### Using GraphQL Query A simple GraphQL query: ~~~hurl POST https://example.org/starwars/graphql ```graphql { human(id: "1000") { name height(unit: FOOT) } } ``` ~~~ A GraphQL query with variables: ~~~hurl POST https://example.org/starwars/graphql ```graphql query Hero($episode: Episode, $withFriends: Boolean!) { hero(episode: $episode) { name friends @include(if: $withFriends) { name } } } variables { "episode": "JEDI", "withFriends": false } ``` ~~~ GraphQL queries can also use [Hurl templates]. [Doc](https://hurl.dev/docs/request.html#graphql-body) ### Using Dynamic Datas [Functions] like `newUuid` and `newDate` can be used in templates to create dynamic datas: A file that creates a dynamic email (i.e `0531f78f-7f87-44be-a7f2-969a1c4e6d97@test.com`): ```hurl POST https://example.org/api/foo { "name": "foo", "email": "{{newUuid}}@test.com" } ``` A file that creates a dynamic query parameter (i.e `2024-12-02T10:35:44.461731Z`): ```hurl GET https://example.org/api/foo [Query] date: {{newDate}} HTTP 200 ``` [Doc](https://hurl.dev/docs/templates.html#functions) ## Testing Response Responses are optional, everything after `HTTP` is part of the response asserts. ```hurl # A request with (almost) no check: GET https://foo.com # A status code check: GET https://foo.com HTTP 200 # A test on response body GET https://foo.com HTTP 200 [Asserts] jsonpath "$.state" == "running" ``` ### Testing Status Code ```hurl GET https://example.org/order/435 HTTP 200 ``` [Doc](https://hurl.dev/docs/asserting-response.html#version-status) ```hurl GET https://example.org/order/435 # Testing status code is in a 200-300 range HTTP * [Asserts] status >= 200 status < 300 ``` [Doc](https://hurl.dev/docs/asserting-response.html#status-assert) ### Testing Response Headers Use implicit response asserts to test header values: ```hurl GET https://example.org/index.html HTTP 200 Set-Cookie: theme=light Set-Cookie: sessionToken=abc123; Expires=Wed, 09 Jun 2021 10:18:14 GMT ``` [Doc](https://hurl.dev/docs/asserting-response.html#headers) Or use explicit response asserts with [predicates]: ```hurl GET https://example.org HTTP 302 [Asserts] header "Location" contains "www.example.net" ``` [Doc](https://hurl.dev/docs/asserting-response.html#header-assert) Implicit and explicit asserts can be combined: ```hurl GET https://example.org/index.html HTTP 200 Set-Cookie: theme=light Set-Cookie: sessionToken=abc123; Expires=Wed, 09 Jun 2021 10:18:14 GMT [Asserts] header "Location" contains "www.example.net" ``` ### Testing REST APIs Asserting JSON body response (node values, collection count etc...) with [JSONPath]: ```hurl GET https://example.org/order screencapability: low HTTP 200 [Asserts] jsonpath "$.validated" == true jsonpath "$.userInfo.firstName" == "Franck" jsonpath "$.userInfo.lastName" == "Herbert" jsonpath "$.hasDevice" == false jsonpath "$.links" count == 12 jsonpath "$.state" != null jsonpath "$.order" matches "^order-\\d{8}$" jsonpath "$.order" matches /^order-\d{8}$/ # Alternative syntax with regex literal jsonpath "$.created" isIsoDate ``` [Doc](https://hurl.dev/docs/asserting-response.html#jsonpath-assert) ### Testing HTML Response ```hurl GET https://example.org HTTP 200 Content-Type: text/html; charset=UTF-8 [Asserts] xpath "string(/html/head/title)" contains "Example" # Check title xpath "count(//p)" == 2 # Check the number of p xpath "//p" count == 2 # Similar assert for p xpath "boolean(count(//h2))" == false # Check there is no h2 xpath "//h2" not exists # Similar assert for h2 xpath "string(//div[1])" matches /Hello.*/ ``` [Doc](https://hurl.dev/docs/asserting-response.html#xpath-assert) ### Testing Set-Cookie Attributes ```hurl GET https://example.org/home HTTP 200 [Asserts] cookie "JSESSIONID" == "8400BAFE2F66443613DC38AE3D9D6239" cookie "JSESSIONID[Value]" == "8400BAFE2F66443613DC38AE3D9D6239" cookie "JSESSIONID[Expires]" contains "Wed, 13 Jan 2021" cookie "JSESSIONID[Secure]" exists cookie "JSESSIONID[HttpOnly]" exists cookie "JSESSIONID[SameSite]" == "Lax" ``` [Doc](https://hurl.dev/docs/asserting-response.html#cookie-assert) ### Testing Bytes Content Check the SHA-256 response body hash: ```hurl GET https://example.org/data.tar.gz HTTP 200 [Asserts] sha256 == hex,039058c6f2c0cb492c533b0a4d14ef77cc0f78abccced5287d84a1a2011cfb81; ``` [Doc](https://hurl.dev/docs/asserting-response.html#sha-256-assert) ### SSL Certificate Check the properties of a SSL certificate: ```hurl GET https://example.org HTTP 200 [Asserts] certificate "Subject" == "CN=example.org" certificate "Issuer" == "C=US, O=Let's Encrypt, CN=R3" certificate "Expire-Date" daysAfterNow > 15 certificate "Serial-Number" matches /[\da-f]+/ ``` [Doc](https://hurl.dev/docs/asserting-response.html#ssl-certificate-assert) ### Checking Full Body Use implicit body to test an exact JSON body match: ```hurl GET https://example.org/api/cats/123 HTTP 200 { "name" : "Purrsloud", "species" : "Cat", "favFoods" : ["wet food", "dry food", "any food"], "birthYear" : 2016, "photo" : "https://learnwebcode.github.io/json-example/images/cat-2.jpg" } ``` [Doc](https://hurl.dev/docs/asserting-response.html#json-body) Or an explicit assert file: ```hurl GET https://example.org/index.html HTTP 200 [Asserts] body == file,cat.json; ``` [Doc](https://hurl.dev/docs/asserting-response.html#body-assert) Implicit asserts supports XML body: ```hurl GET https://example.org/api/catalog HTTP 200 Gambardella, Matthew XML Developer's Guide Computer 44.95 2000-10-01 An in-depth look at creating applications with XML. ``` [Doc](https://hurl.dev/docs/asserting-response.html#xml-body) Plain text: ~~~hurl GET https://example.org/models HTTP 200 ``` Year,Make,Model,Description,Price 1997,Ford,E350,"ac, abs, moon",3000.00 1999,Chevy,"Venture ""Extended Edition""","",4900.00 1999,Chevy,"Venture ""Extended Edition, Very Large""",,5000.00 1996,Jeep,Grand Cherokee,"MUST SELL! air, moon roof, loaded",4799.00 ``` ~~~ [Doc](https://hurl.dev/docs/asserting-response.html#multiline-string-body) One line: ```hurl POST https://example.org/helloworld HTTP 200 `Hello world!` ``` [Doc](https://hurl.dev/docs/asserting-response.html#oneline-string-body) File: ```hurl GET https://example.org HTTP 200 file,data.bin; ``` [Doc](https://hurl.dev/docs/asserting-response.html#file-body) ## Reports ### HTML Report ```shell $ hurl --test --report-html build/report/ *.hurl ``` [Doc](https://hurl.dev/docs/running-tests.html#generating-report) ### JSON Report ```shell $ hurl --test --report-json build/report/ *.hurl ``` [Doc](https://hurl.dev/docs/running-tests.html#generating-report) ### JUnit Report ```shell $ hurl --test --report-junit build/report.xml *.hurl ``` [Doc](https://hurl.dev/docs/running-tests.html#generating-report) ### TAP Report ```shell $ hurl --test --report-tap build/report.txt *.hurl ``` [Doc](https://hurl.dev/docs/running-tests.html#generating-report) ### JSON Output A structured output of running Hurl files can be obtained with [`--json` option]. Each file will produce a JSON export of the run. ```shell $ hurl --json *.hurl ``` ## Others ### HTTP Version Testing HTTP version (HTTP/1.0, HTTP/1.1, HTTP/2 or HTTP/3) can be done using implicit asserts: ```hurl GET https://foo.com HTTP/3 200 GET https://bar.com HTTP/2 200 ``` [Doc](https://hurl.dev/docs/asserting-response.html#version-status) Or explicit: ```hurl GET https://foo.com HTTP 200 [Asserts] version == "3" GET https://bar.com HTTP 200 [Asserts] version == "2" version toFloat > 1.1 ``` [Doc](https://hurl.dev/docs/asserting-response.html#version-assert) ### IP Address Testing the IP address of the response, as a string. This string may be IPv6 address: ```hurl GET https://foo.com HTTP 200 [Asserts] ip == " 2001:0db8:85a3:0000:0000:8a2e:0370:733" ip startsWith "2001" ip isIpv6 ``` ### Polling and Retry Retry request on any errors (asserts, captures, status code, runtime etc...): ```hurl # Create a new job POST https://api.example.org/jobs HTTP 201 [Captures] job_id: jsonpath "$.id" [Asserts] jsonpath "$.state" == "RUNNING" # Pull job status until it is completed GET https://api.example.org/jobs/{{job_id}} [Options] retry: 10 # maximum number of retry, -1 for unlimited retry-interval: 500ms HTTP 200 [Asserts] jsonpath "$.state" == "COMPLETED" ``` [Doc](https://hurl.dev/docs/entry.html#retry) ### Delaying Requests Add delay for every request, or a particular request: ```hurl # Delaying this request by 5 seconds (aka sleep) GET https://example.org/turtle [Options] delay: 5s HTTP 200 # No delay! GET https://example.org/turtle HTTP 200 ``` [Doc](https://hurl.dev/docs/manual.html#delay) ### Skipping Requests ```hurl # a, c, d are run, b is skipped GET https://example.org/a GET https://example.org/b [Options] skip: true GET https://example.org/c GET https://example.org/d ``` [Doc](https://hurl.dev/docs/manual.html#skip) ### Testing Endpoint Performance ```hurl GET https://sample.org/helloworld HTTP * [Asserts] duration < 1000 # Check that response time is less than one second ``` [Doc](https://hurl.dev/docs/asserting-response.html#duration-assert) ### Using SOAP APIs ```hurl POST https://example.org/InStock Content-Type: application/soap+xml; charset=utf-8 SOAPAction: "http://www.w3.org/2003/05/soap-envelope" GOOG HTTP 200 ``` [Doc](https://hurl.dev/docs/request.html#xml-body) ### Capturing and Using a CSRF Token ```hurl GET https://example.org HTTP 200 [Captures] csrf_token: xpath "string(//meta[@name='_csrf_token']/@content)" POST https://example.org/login?user=toto&password=1234 X-CSRF-TOKEN: {{csrf_token}} HTTP 302 ``` [Doc](https://hurl.dev/docs/capturing-response.html#xpath-capture) ### Redacting Secrets Using command-line for known values: ```shell $ hurl --secret token=1234 file.hurl ``` ```hurl POST https://example.org X-Token: {{token}} { "name": "Alice", "value": 100 } HTTP 200 ``` [Doc](https://hurl.dev/docs/templates.html#secrets) Using `redact` for dynamic values: ```hurl # Get an authorization token: GET https://example.org/token HTTP 200 [Captures] token: header "X-Token" redact # Send an authorized request: POST https://example.org X-Token: {{token}} { "name": "Alice", "value": 100 } HTTP 200 ``` [Doc](https://hurl.dev/docs/capturing-response.html#redacting-secrets) ### Checking Byte Order Mark (BOM) in Response Body ```hurl GET https://example.org/data.bin HTTP 200 [Asserts] bytes startsWith hex,efbbbf; ``` [Doc](https://hurl.dev/docs/asserting-response.html#bytes-assert) ### AWS Signature Version 4 Requests Generate signed API requests with [AWS Signature Version 4], as used by several cloud providers. ```hurl POST https://sts.eu-central-1.amazonaws.com/ [Options] aws-sigv4: aws:amz:eu-central-1:sts [Form] Action: GetCallerIdentity Version: 2011-06-15 ``` The Access Key is given per [`--user`], either with command line option or within the [`[Options]`](https://hurl.dev/docs/request.html#options) section: ```hurl POST https://sts.eu-central-1.amazonaws.com/ [Options] aws-sigv4: aws:amz:eu-central-1:sts user: bob=secret [Form] Action: GetCallerIdentity Version: 2011-06-15 ``` [Doc](https://hurl.dev/docs/manual.html#aws-sigv4) ### Using curl Options curl options (for instance [`--resolve`] or [`--connect-to`]) can be used as CLI argument. In this case, they're applicable to each request of an Hurl file. ```shell $ hurl --resolve foo.com:8000:127.0.0.1 foo.hurl ``` Use [`[Options]` section](https://hurl.dev/docs/request.html#options) to configure a specific request: ```hurl GET http://bar.com HTTP 200 GET http://foo.com:8000/resolve [Options] resolve: foo.com:8000:127.0.0.1 HTTP 200 `Hello World!` ``` [Doc](https://hurl.dev/docs/request.html#options) # Manual ## Name hurl - run and test HTTP requests. ## Synopsis **hurl** [options] [FILE...] ## Description **Hurl** is a command line tool that runs HTTP requests defined in a simple plain text format. It can chain requests, capture values and evaluate queries on headers and body response. Hurl is very versatile, it can be used for fetching data and testing HTTP sessions: HTML content, REST / SOAP / GraphQL APIs, or any other XML / JSON based APIs. ```shell $ hurl session.hurl ``` If no input files are specified, input is read from stdin. ```shell $ echo GET http://httpbin.org/get | hurl { "args": {}, "headers": { "Accept": "*/*", "Accept-Encoding": "gzip", "Content-Length": "0", "Host": "httpbin.org", "User-Agent": "hurl/0.99.10", "X-Amzn-Trace-Id": "Root=1-5eedf4c7-520814d64e2f9249ea44e0" }, "origin": "1.2.3.4", "url": "http://httpbin.org/get" } ``` Hurl can take files as input, or directories. In the latter case, Hurl will search files with `.hurl` extension recursively. Output goes to stdout by default. To have output go to a file, use the [`-o, --output`](#output) option: ```shell $ hurl -o output input.hurl ``` By default, Hurl executes all HTTP requests and outputs the response body of the last HTTP call. To have a test oriented output, you can use [`--test`](#test) option: ```shell $ hurl --test *.hurl ``` ## Hurl File Format The Hurl file format is fully documented in [https://hurl.dev/docs/hurl-file.html](https://hurl.dev/docs/hurl-file.html) It consists of one or several HTTP requests ```hurl GET http://example.org/endpoint1 GET http://example.org/endpoint2 ``` ### Capturing values A value from an HTTP response can be-reused for successive HTTP requests. A typical example occurs with CSRF tokens. ```hurl GET https://example.org HTTP 200 # Capture the CSRF token value from html body. [Captures] csrf_token: xpath "normalize-space(//meta[@name='_csrf_token']/@content)" # Do the login ! POST https://example.org/login?user=toto&password=1234 X-CSRF-TOKEN: {{csrf_token}} ``` More information on captures can be found here [https://hurl.dev/docs/capturing-response.html](https://hurl.dev/docs/capturing-response.html) ### Asserts The HTTP response defined in the Hurl file are used to make asserts. Responses are optional. At the minimum, response includes assert on the HTTP status code. ```hurl GET http://example.org HTTP 301 ``` It can also include asserts on the response headers ```hurl GET http://example.org HTTP 301 Location: http://www.example.org ``` Explicit asserts can be included by combining a query and a predicate ```hurl GET http://example.org HTTP 301 [Asserts] xpath "string(//title)" == "301 Moved" ``` With the addition of asserts, Hurl can be used as a testing tool to run scenarios. More information on asserts can be found here [https://hurl.dev/docs/asserting-response.html](https://hurl.dev/docs/asserting-response.html) ## Options Options that exist in curl have exactly the same semantics. Options specified on the command line are defined for every Hurl file's entry, except if they are tagged as cli-only (can not be defined in the Hurl request [Options] entry) For instance: ```shell $ hurl --location foo.hurl ``` will follow redirection for each entry in `foo.hurl`. You can also define an option only for a particular entry with an `[Options]` section. For instance, this Hurl file: ```hurl GET https://example.org HTTP 301 GET https://example.org [Options] location: true HTTP 200 ``` will follow a redirection only for the second entry. | Option | Description | |-------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | --aws-sigv4 <PROVIDER1[:PROVIDER2[:REGION[:SERVICE]]]> | Generate an `Authorization` header with an AWS SigV4 signature.

Use [`-u, --user`](#user) to specify Access Key Id (username) and Secret Key (password).

To use temporary session credentials (e.g. for an AWS IAM Role), add the `X-Amz-Security-Token` header containing the session token.
| | --cacert <FILE> | Specifies the certificate file for peer verification. The file may contain multiple CA certificates and must be in PEM format.
Normally Hurl is built to use a default file for this, so this option is typically used to alter that default file.
| | -E, --cert <CERTIFICATE[:PASSWORD]> | Client certificate file and password.

See also [`--key`](#key).
| | --color | Colorize debug output (the HTTP response output is not colorized).

This is a cli-only option.
| | --compressed | Request a compressed response using one of the algorithms br, gzip, deflate and automatically decompress the content.
| | --connect-timeout <SECONDS> | Maximum time in seconds that you allow Hurl's connection to take.

You can specify time units in the connect timeout expression. Set Hurl to use a connect timeout of 20 seconds with `--connect-timeout 20s` or set it to 35,000 milliseconds with `--connect-timeout 35000ms`. No spaces allowed.

See also [`-m, --max-time`](#max-time).
| | --connect-to <HOST1:PORT1:HOST2:PORT2> | For a request to the given HOST1:PORT1 pair, connect to HOST2:PORT2 instead. This option can be used several times in a command line.

See also [`--resolve`](#resolve).
| | --continue-on-error | Continue executing requests to the end of the Hurl file even when an assert error occurs.
By default, Hurl exits after an assert error in the HTTP response.

Note that this option does not affect the behavior with multiple input Hurl files.

All the input files are executed independently. The result of one file does not affect the execution of the other Hurl files.

This is a cli-only option.
| | -b, --cookie <FILE> | Read cookies from FILE (using the Netscape cookie file format).

Combined with [`-c, --cookie-jar`](#cookie-jar), you can simulate a cookie storage between successive Hurl runs.

This is a cli-only option.
| | -c, --cookie-jar <FILE> | Write cookies to FILE after running the session.
The file will be written using the Netscape cookie file format.

Combined with [`-b, --cookie`](#cookie), you can simulate a cookie storage between successive Hurl runs.

This is a cli-only option.
| | --curl <FILE> | Export each request to a list of curl commands.

This is a cli-only option.
| | --delay <MILLISECONDS> | Sets delay before each request (aka sleep). The delay is not applied to requests that have been retried because of [`--retry`](#retry). See [`--retry-interval`](#retry-interval) to space retried requests.

You can specify time units in the delay expression. Set Hurl to use a delay of 2 seconds with `--delay 2s` or set it to 500 milliseconds with `--delay 500ms`. No spaces allowed.
| | --error-format <FORMAT> | Control the format of error message (short by default or long)

This is a cli-only option.
| | --file-root <DIR> | Set root directory to import files in Hurl. This is used for files in multipart form data, request body and response output.
When it is not explicitly defined, files are relative to the Hurl file's directory.

This is a cli-only option.
| | --from-entry <ENTRY_NUMBER> | Execute Hurl file from ENTRY_NUMBER (starting at 1).

This is a cli-only option.
| | --glob <GLOB> | Specify input files that match the given glob pattern.

Multiple glob flags may be used. This flag supports common Unix glob patterns like *, ? and [].
However, to avoid your shell accidentally expanding glob patterns before Hurl handles them, you must use single quotes or double quotes around each pattern.

This is a cli-only option.
| | -H, --header <HEADER> | Add an extra header to include in information sent. Can be used several times in a command

Do not add newlines or carriage returns
| | -0, --http1.0 | Tells Hurl to use HTTP version 1.0 instead of using its internally preferred HTTP version.
| | --http1.1 | Tells Hurl to use HTTP version 1.1.
| | --http2 | Tells Hurl to use HTTP version 2.
For HTTPS, this means Hurl negotiates HTTP/2 in the TLS handshake. Hurl does this by default.
For HTTP, this means Hurl attempts to upgrade the request to HTTP/2 using the Upgrade: request header.
| | --http3 | Tells Hurl to try HTTP/3 to the host in the URL, but fallback to earlier HTTP versions if the HTTP/3 connection establishment fails. HTTP/3 is only available for HTTPS and not for HTTP URLs.
| | --ignore-asserts | Ignore all asserts defined in the Hurl file.

This is a cli-only option.
| | -i, --include | Include the HTTP headers in the output

This is a cli-only option.
| | -k, --insecure | This option explicitly allows Hurl to perform "insecure" SSL connections and transfers.
| | --interactive | Stop between requests.

This is similar to a break point, You can then continue (Press C) or quit (Press Q).

This is a cli-only option.
| | -4, --ipv4 | This option tells Hurl to use IPv4 addresses only when resolving host names, and not for example try IPv6.
| | -6, --ipv6 | This option tells Hurl to use IPv6 addresses only when resolving host names, and not for example try IPv4.
| | --jobs <NUM> | Maximum number of parallel jobs in parallel mode. Default value corresponds (in most cases) to the
current amount of CPUs.

See also [`--parallel`](#parallel).

This is a cli-only option.
| | --json | Output each Hurl file result to JSON. The format is very closed to HAR format.

This is a cli-only option.
| | --key <KEY> | Private key file name.
| | --limit-rate <SPEED> | Specify the maximum transfer rate you want Hurl to use, for both downloads and uploads. This feature is useful if you have a limited pipe and you would like your transfer not to use your entire bandwidth. To make it slower than it otherwise would be.
The given speed is measured in bytes/second.
| | -L, --location | Follow redirect. To limit the amount of redirects to follow use the [`--max-redirs`](#max-redirs) option
| | --location-trusted | Like [`-L, --location`](#location), but allows sending the name + password to all hosts that the site may redirect to.
This may or may not introduce a security breach if the site redirects you to a site to which you send your authentication info (which is plaintext in the case of HTTP Basic authentication).
| | --max-filesize <BYTES> | Specify the maximum size in bytes of a file to download. If the file requested is larger than this value, the transfer does not start.

This is a cli-only option.
| | --max-redirs <NUM> | Set maximum number of redirection-followings allowed

By default, the limit is set to 50 redirections. Set this option to -1 to make it unlimited.
| | -m, --max-time <SECONDS> | Maximum time in seconds that you allow a request/response to take. This is the standard timeout.

You can specify time units in the maximum time expression. Set Hurl to use a maximum time of 20 seconds with `--max-time 20s` or set it to 35,000 milliseconds with `--max-time 35000ms`. No spaces allowed.

See also [`--connect-timeout`](#connect-timeout).

This is a cli-only option.
| | -n, --netrc | Scan the .netrc file in the user's home directory for the username and password.

See also [`--netrc-file`](#netrc-file) and [`--netrc-optional`](#netrc-optional).
| | --netrc-file <FILE> | Like [`--netrc`](#netrc), but provide the path to the netrc file.

See also [`--netrc-optional`](#netrc-optional).
| | --netrc-optional | Similar to [`--netrc`](#netrc), but make the .netrc usage optional.

See also [`--netrc-file`](#netrc-file).
| | --no-color | Do not colorize output.

This is a cli-only option.
| | --no-output | Suppress output. By default, Hurl outputs the body of the last response.

This is a cli-only option.
| | --noproxy <HOST(S)> | Comma-separated list of hosts which do not use a proxy.

Override value from Environment variable no_proxy.
| | -o, --output <FILE> | Write output to FILE instead of stdout.
| | --parallel | Run files in parallel.

Each Hurl file is executed in its own worker thread, without sharing anything with the other workers. The default run mode is sequential. Parallel execution is by default in [`--test`](#test) mode.

See also [`--jobs`](#jobs).

This is a cli-only option.
| | --path-as-is | Tell Hurl to not handle sequences of /../ or /./ in the given URL path. Normally Hurl will squash or merge them according to standards but with this option set you tell it not to do that.
| | -x, --proxy <[PROTOCOL://]HOST[:PORT]> | Use the specified proxy.
| | --repeat <NUM> | Repeat the input files sequence NUM times, -1 for infinite loop. Given a.hurl, b.hurl, c.hurl as input, repeat two
times will run a.hurl, b.hurl, c.hurl, a.hurl, b.hurl, c.hurl.

This is a cli-only option.
| | --report-html <DIR> | Generate HTML report in DIR.

If the HTML report already exists, it will be updated with the new test results.

This is a cli-only option.
| | --report-json <DIR> | Generate JSON report in DIR.

If the JSON report already exists, it will be updated with the new test results.

This is a cli-only option.
| | --report-junit <FILE> | Generate JUnit File.

If the FILE report already exists, it will be updated with the new test results.

This is a cli-only option.
| | --report-tap <FILE> | Generate TAP report.

If the FILE report already exists, it will be updated with the new test results.

This is a cli-only option.
| | --resolve <HOST:PORT:ADDR> | Provide a custom address for a specific host and port pair. Using this, you can make the Hurl requests(s) use a specified address and prevent the otherwise normally resolved address to be used. Consider it a sort of /etc/hosts alternative provided on the command line.
| | --retry <NUM> | Maximum number of retries, 0 for no retries, -1 for unlimited retries. Retry happens if any error occurs (asserts, captures, runtimes etc...).
| | --retry-interval <MILLISECONDS> | Duration in milliseconds between each retry. Default is 1000 ms.

You can specify time units in the retry interval expression. Set Hurl to use a retry interval of 2 seconds with `--retry-interval 2s` or set it to 500 milliseconds with `--retry-interval 500ms`. No spaces allowed.
| | --secret <NAME=VALUE> | Define secret value to be redacted from logs and report. When defined, secrets can be used as variable everywhere variables are used.
| | --ssl-no-revoke | (Windows) This option tells Hurl to disable certificate revocation checks. WARNING: this option loosens the SSL security, and by using this flag you ask for exactly that.

This is a cli-only option.
| | --test | Activate test mode: with this, the HTTP response is not outputted anymore, progress is reported for each Hurl file tested, and a text summary is displayed when all files have been run.

In test mode, files are executed in parallel. To run test in a sequential way use `--job 1`.

See also [`--jobs`](#jobs).

This is a cli-only option.
| | --to-entry <ENTRY_NUMBER> | Execute Hurl file to ENTRY_NUMBER (starting at 1).
Ignore the remaining of the file. It is useful for debugging a session.

This is a cli-only option.
| | --unix-socket <PATH> | (HTTP) Connect through this Unix domain socket, instead of using the network.
| | -u, --user <USER:PASSWORD> | Add basic Authentication header to each request.
| | -A, --user-agent <NAME> | Specify the User-Agent string to send to the HTTP server.

This is a cli-only option.
| | --variable <NAME=VALUE> | Define variable (name/value) to be used in Hurl templates.
| | --variables-file <FILE> | Set properties file in which your define your variables.

Each variable is defined as name=value exactly as with [`--variable`](#variable) option.

Note that defining a variable twice produces an error.

This is a cli-only option.
| | -v, --verbose | Turn on verbose output on standard error stream.
Useful for debugging.

A line starting with '>' means data sent by Hurl.
A line staring with '<' means data received by Hurl.
A line starting with '*' means additional info provided by Hurl.

If you only want HTTP headers in the output, [`-i, --include`](#include) might be the option you're looking for.
| | --very-verbose | Turn on more verbose output on standard error stream.

In contrast to [`--verbose`](#verbose) option, this option outputs the full HTTP body request and response on standard error. In addition, lines starting with '**' are libcurl debug logs.
| | -h, --help | Usage help. This lists all current command line options with a short description.
| | -V, --version | Prints version information
| ## Environment Environment variables can only be specified in lowercase. Using an environment variable to set the proxy has the same effect as using the [`-x, --proxy`](#proxy) option. | Variable | Description | |--------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `http_proxy [PROTOCOL://][:PORT]` | Sets the proxy server to use for HTTP.
| | `https_proxy [PROTOCOL://][:PORT]` | Sets the proxy server to use for HTTPS.
| | `all_proxy [PROTOCOL://][:PORT]` | Sets the proxy server to use if no protocol-specific proxy is set.
| | `no_proxy ` | List of host names that shouldn't go through any proxy.
| | `HURL_name value` | Define variable (name/value) to be used in Hurl templates. This is similar than [`--variable`](#variable) and [`--variables-file`](#variables-file) options.
| | `NO_COLOR` | When set to a non-empty string, do not colorize output (see [`--no-color`](#no-color) option).
| ## Exit Codes | Value | Description | |-------|---------------------------------------------------------| | `0` | Success.
| | `1` | Failed to parse command-line options.
| | `2` | Input File Parsing Error.
| | `3` | Runtime error (such as failure to connect to host).
| | `4` | Assert Error.
| ## WWW [https://hurl.dev](https://hurl.dev) ## See Also curl(1) hurlfmt(1) # Installation ## Binaries Installation ### Linux Precompiled binary (depending on libc >=2.35) is available at [Hurl latest GitHub release]: ```shell $ INSTALL_DIR=/tmp $ VERSION=6.1.1 $ curl --silent --location https://github.com/Orange-OpenSource/hurl/releases/download/$VERSION/hurl-$VERSION-x86_64-unknown-linux-gnu.tar.gz | tar xvz -C $INSTALL_DIR $ export PATH=$INSTALL_DIR/hurl-$VERSION-x86_64-unknown-linux-gnu/bin:$PATH ``` #### Debian / Ubuntu For Debian >=12 / Ubuntu >=22.04, Hurl can be installed using a binary .deb file provided in each Hurl release. ```shell $ VERSION=6.1.1 $ curl --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/$VERSION/hurl_${VERSION}_amd64.deb $ sudo apt update && sudo apt install ./hurl_${VERSION}_amd64.deb ``` For Ubuntu >=18.04, Hurl can be installed from `ppa:lepapareil/hurl` ```shell $ VERSION=6.1.1 $ sudo apt-add-repository -y ppa:lepapareil/hurl $ sudo apt install hurl="${VERSION}"* ``` #### Alpine Hurl is available on `testing` channel. ```shell $ apk add --repository http://dl-cdn.alpinelinux.org/alpine/edge/testing hurl ``` #### Arch Linux / Manjaro Hurl is available on [extra] channel. ```shell $ pacman -Sy hurl ``` #### NixOS / Nix [NixOS / Nix package] is available on stable channel. ### macOS Precompiled binaries for Intel and ARM CPUs are available at [Hurl latest GitHub release]. #### Homebrew ```shell $ brew install hurl ``` #### MacPorts ```shell $ sudo port install hurl ``` ### FreeBSD ```shell $ sudo pkg install hurl ``` ### Windows Windows requires the [Visual C++ Redistributable Package] to be installed manually, as this is not included in the installer. #### Zip File Hurl can be installed from a standalone zip file at [Hurl latest GitHub release]. You will need to update your `PATH` variable. #### Installer An executable installer is also available at [Hurl latest GitHub release]. #### Chocolatey ```shell $ choco install hurl ``` #### Scoop ```shell $ scoop install hurl ``` #### Windows Package Manager ```shell $ winget install hurl ``` ### Cargo If you're a Rust programmer, Hurl can be installed with cargo. ```shell $ cargo install hurl ``` ### conda-forge ```shell $ conda install -c conda-forge hurl ``` Hurl can also be installed with [`conda-forge`] powered package manager like [`pixi`]. ### Docker ```shell $ docker pull ghcr.io/orange-opensource/hurl:latest ``` ### npm ```shell $ npm install --save-dev @orangeopensource/hurl ``` ## Building From Sources Hurl sources are available in [GitHub]. ### Build on Linux Hurl depends on libssl, libcurl and libxml2 native libraries. You will need their development files in your platform. #### Debian based distributions ```shell $ apt install -y build-essential pkg-config libssl-dev libcurl4-openssl-dev libxml2-dev ``` #### Fedora based distributions ```shell $ dnf install -y pkgconf-pkg-config gcc openssl-devel libxml2-devel ``` #### Red Hat based distributions ```shell $ yum install -y pkg-config gcc openssl-devel libxml2-devel ``` #### Arch based distributions ```shell $ pacman -S --noconfirm pkgconf gcc glibc openssl libxml2 ``` #### Alpine based distributions ```shell $ apk add curl-dev gcc libxml2-dev musl-dev openssl-dev ``` ### Build on macOS ```shell $ xcode-select --install $ brew install pkg-config ``` Hurl is written in [Rust]. You should [install] the latest stable release. ```shell $ curl https://sh.rustup.rs -sSf | sh -s -- -y $ source $HOME/.cargo/env $ rustc --version $ cargo --version ``` Then build hurl: ```shell $ git clone https://github.com/Orange-OpenSource/hurl $ cd hurl $ cargo build --release $ ./target/release/hurl --version ``` ### Build on Windows Please follow the [contrib on Windows section]. [XPath]: https://en.wikipedia.org/wiki/XPath [JSONPath]: https://goessner.net/articles/JsonPath/ [Rust]: https://www.rust-lang.org [curl]: https://curl.se [the installation section]: https://hurl.dev/docs/installation.html [Feedback, suggestion, bugs or improvements]: https://github.com/Orange-OpenSource/hurl/issues [License]: https://hurl.dev/docs/license.html [Tutorial]: https://hurl.dev/docs/tutorial/your-first-hurl-file.html [Documentation]: https://hurl.dev/docs/installation.html [Blog]: https://hurl.dev/blog/ [GitHub]: https://github.com/Orange-OpenSource/hurl [libcurl]: https://curl.se/libcurl/ [star Hurl on GitHub]: https://github.com/Orange-OpenSource/hurl/stargazers [HTML]: https://hurl.dev/assets/docs/hurl-6.1.0.html.gz [PDF]: https://hurl.dev/assets/docs/hurl-6.1.0.pdf.gz [Markdown]: https://hurl.dev/assets/docs/hurl-6.1.0.md.gz [JSON body]: https://hurl.dev/docs/request.html#json-body [XML body]: https://hurl.dev/docs/request.html#xml-body [XML multiline string body]: https://hurl.dev/docs/request.html#multiline-string-body [multiline string body]: https://hurl.dev/docs/request.html#multiline-string-body [predicates]: https://hurl.dev/docs/asserting-response.html#predicates [JSONPath]: https://goessner.net/articles/JsonPath/ [Basic authentication]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#basic_authentication_scheme [`Authorization` header]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization [Hurl tests suite]: https://github.com/Orange-OpenSource/hurl/tree/master/integration/hurl/tests_ok [Authorization]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization [`-u/--user` option]: https://hurl.dev/docs/manual.html#user [curl]: https://curl.se [entry]: https://hurl.dev/docs/entry.html [`--test` option]: https://hurl.dev/docs/manual.html#test [`--user`]: https://hurl.dev/docs/manual.html#user [Hurl templates]: https://hurl.dev/docs/templates.html [AWS Signature Version 4]: https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-authenticating-requests.html [Captures]: https://hurl.dev/docs/capturing-response.html [`--json` option]: https://hurl.dev/docs/manual.html#json [`--resolve`]: https://hurl.dev/docs/manual.html#resolve [`--connect-to`]: https://hurl.dev/docs/manual.html#connect-to [Functions]: https://hurl.dev/docs/templates.html#functions [GitHub]: https://github.com/Orange-OpenSource/hurl [Hurl latest GitHub release]: https://github.com/Orange-OpenSource/hurl/releases/latest [Visual C++ Redistributable Package]: https://learn.microsoft.com/en-us/cpp/windows/latest-supported-vc-redist?view=msvc-170#latest-microsoft-visual-c-redistributable-version [install]: https://www.rust-lang.org/tools/install [Rust]: https://www.rust-lang.org [contrib on Windows section]: https://github.com/Orange-OpenSource/hurl/blob/master/contrib/windows/README.md [NixOS / Nix package]: https://search.nixos.org/packages?from=0&size=1&sort=relevance&type=packages&query=hurl [`conda-forge`]: https://conda-forge.org [`pixi`]: https://prefix.dev [extra]: https://archlinux.org/packages/extra/x86_64/hurl/ hurl-6.1.1/build.rs000064400000000000000000000023011046102023000122440ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::path::Path; use cc::Build; #[cfg(windows)] use winres::WindowsResource; #[cfg(windows)] fn set_icon() { let mut res = WindowsResource::new(); res.set_icon("../../bin/windows/logo.ico"); res.compile().unwrap(); } #[cfg(unix)] fn set_icon() {} fn main() { let project_root = Path::new(env!("CARGO_MANIFEST_DIR")); let native_src = project_root.join("native"); set_icon(); Build::new() .file(native_src.join("libxml.c")) .flag_if_supported("-Wno-unused-parameter") // unused parameter in silent callback .compile("mylib"); } hurl-6.1.1/native/libxml.c000064400000000000000000000003001046102023000135150ustar 00000000000000// This callback will prevent from outputting error messages // It could not be implemented in Rust, because the function is variadic void silentErrorFunc(void *ctx, const char * msg, ...) { }hurl-6.1.1/src/cli/error.rs000064400000000000000000000032511046102023000136410ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::{fmt, io}; use hurl::parallel::error::JobError; use hurl::report; #[allow(unused)] #[derive(Clone, Debug, PartialEq, Eq)] pub enum CliError { IO(String), Parsing, Runtime(String), } impl From for CliError { fn from(error: report::ReportError) -> Self { CliError::IO(error.to_string()) } } impl From for CliError { fn from(error: JobError) -> Self { match error { JobError::IO(message) => CliError::IO(message), JobError::Parsing => CliError::Parsing, JobError::Runtime(message) => CliError::Runtime(message), } } } impl fmt::Display for CliError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { CliError::IO(message) => write!(f, "{}", message), CliError::Parsing => Ok(()), CliError::Runtime(message) => write!(f, "{}", message), } } } impl From for CliError { fn from(error: io::Error) -> Self { CliError::IO(error.to_string()) } } hurl-6.1.1/src/cli/interactive.rs000064400000000000000000000043251046102023000150300ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::Entry; #[cfg(target_family = "unix")] use hurl_core::ast::Request; #[cfg(target_family = "unix")] use { std::io::{stderr, stdin, Write}, termion::event::Key, termion::input::TermRead, termion::raw::IntoRawMode, }; /// Interactively asks user to execute `entry` or quit. #[cfg(target_family = "unix")] pub fn pre_entry(entry: &Entry) -> bool { eprintln!("\nInteractive mode"); eprintln!("\nNext request:"); eprintln!(); log_request(&entry.request); // In raw mode, "\n" only means "go one line down", not "line break" // To effectively go do the next new line, we have to write "\r\n". let mut stderr = stderr().into_raw_mode().unwrap(); write!( stderr, "\r\nPress Q (Quit) or C (Continue){}\r\n", termion::cursor::Hide ) .unwrap(); stderr.flush().unwrap(); let mut exit = false; for c in stdin().keys() { print!("\r"); match c.unwrap() { Key::Char('q') => { exit = true; break; } Key::Char('c') => { break; } _ => {} } } print!("{}\r{}", termion::clear::CurrentLine, termion::cursor::Show); exit } #[allow(dead_code)] #[cfg(target_family = "unix")] fn log_request(request: &Request) { let method = &request.method; let url = &request.url; eprintln!("{method} {url}"); } #[cfg(target_family = "windows")] pub fn pre_entry(_: &Entry) -> bool { eprintln!("Interactive not supported yet in windows!"); true } pub fn post_entry() -> bool { false } hurl-6.1.1/src/cli/logger.rs000064400000000000000000000040301046102023000137630ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::text::{Format, Style, StyledString}; /// A simple logger to log app related event (start, high levels error, etc...). pub struct BaseLogger { /// Format of the message in the terminal: ANSI or plain. format: Format, /// Prints debug message or not. verbose: bool, } impl BaseLogger { /// Creates a new base logger using `color` and `verbose`. pub fn new(color: bool, verbose: bool) -> BaseLogger { let format = if color { Format::Ansi } else { Format::Plain }; BaseLogger { format, verbose } } /// Prints an informational `message` on standard error. pub fn info(&self, message: &str) { eprintln!("{message}"); } /// Prints a debug `message` on standard error if the logger is in verbose mode. pub fn debug(&self, message: &str) { if !self.verbose { return; } let mut s = StyledString::new(); s.push_with("*", Style::new().blue().bold()); if !message.is_empty() { s.push(&format!(" {message}")); } eprintln!("{}", s.to_string(self.format)); } /// Prints an error `message` on standard error. pub fn error(&self, message: &str) { let mut s = StyledString::new(); s.push_with("error", Style::new().red().bold()); s.push(": "); s.push_with(message, Style::new().bold()); eprintln!("{}", s.to_string(self.format)); } } hurl-6.1.1/src/cli/mod.rs000064400000000000000000000015431046102023000132710ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ mod error; mod interactive; mod logger; pub(crate) mod options; mod summary; pub(crate) use self::error::CliError; pub(crate) use self::logger::BaseLogger; pub(crate) use self::options::OutputType; pub(crate) use self::summary::summary; hurl-6.1.1/src/cli/options/commands.rs000064400000000000000000000444371046102023000160170ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ // Generated by bin/spec/options/generate_source.py - Do not modify pub fn input_files() -> clap::Arg { clap::Arg::new("input_files") .value_name("FILES") .help("Set the input file to use") .required(false) .index(1) .num_args(1..) } pub fn aws_sigv4() -> clap::Arg { clap::Arg::new("aws_sigv4") .long("aws-sigv4") .value_name("PROVIDER1[:PROVIDER2[:REGION[:SERVICE]]]") .help("Use AWS V4 signature authentication in the transfer") .help_heading("HTTP options") .num_args(1) } pub fn cacert_file() -> clap::Arg { clap::Arg::new("cacert_file") .long("cacert") .value_name("FILE") .help("CA certificate to verify peer against (PEM format)") .help_heading("HTTP options") .num_args(1) } pub fn client_cert_file() -> clap::Arg { clap::Arg::new("client_cert_file") .long("cert") .short('E') .value_name("CERTIFICATE[:PASSWORD]") .help("Client certificate file and password") .help_heading("HTTP options") .num_args(1) } pub fn client_key_file() -> clap::Arg { clap::Arg::new("client_key_file") .long("key") .value_name("KEY") .help("Private key file name") .help_heading("HTTP options") .num_args(1) } pub fn color() -> clap::Arg { clap::Arg::new("color") .long("color") .help("Colorize output") .help_heading("Output options") .conflicts_with("no_color") .action(clap::ArgAction::SetTrue) } pub fn compressed() -> clap::Arg { clap::Arg::new("compressed") .long("compressed") .help("Request compressed response (using deflate or gzip)") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn connect_timeout() -> clap::Arg { clap::Arg::new("connect_timeout") .long("connect-timeout") .value_name("SECONDS") .default_value("300") .help("Maximum time allowed for connection") .help_heading("HTTP options") .num_args(1) } pub fn connect_to() -> clap::Arg { clap::Arg::new("connect_to") .long("connect-to") .value_name("HOST1:PORT1:HOST2:PORT2") .help("For a request to the given HOST1:PORT1 pair, connect to HOST2:PORT2 instead") .help_heading("HTTP options") .num_args(1) .action(clap::ArgAction::Append) } pub fn continue_on_error() -> clap::Arg { clap::Arg::new("continue_on_error") .long("continue-on-error") .help("Continue executing requests even if an error occurs") .help_heading("Run options") .action(clap::ArgAction::SetTrue) } pub fn cookies_input_file() -> clap::Arg { clap::Arg::new("cookies_input_file") .long("cookie") .short('b') .value_name("FILE") .help("Read cookies from FILE") .help_heading("Other options") .num_args(1) } pub fn cookies_output_file() -> clap::Arg { clap::Arg::new("cookies_output_file") .long("cookie-jar") .short('c') .value_name("FILE") .help("Write cookies to FILE after running the session") .help_heading("Other options") .num_args(1) } pub fn curl() -> clap::Arg { clap::Arg::new("curl") .long("curl") .value_name("FILE") .help("Export each request to a list of curl commands") .help_heading("Output options") .num_args(1) } pub fn delay() -> clap::Arg { clap::Arg::new("delay") .long("delay") .value_name("MILLISECONDS") .default_value("0") .help("Sets delay before each request (aka sleep)") .help_heading("Run options") .num_args(1) } pub fn error_format() -> clap::Arg { clap::Arg::new("error_format") .long("error-format") .value_name("FORMAT") .default_value("short") .value_parser(["short", "long"]) .help("Control the format of error messages") .help_heading("Output options") .num_args(1) } pub fn file_root() -> clap::Arg { clap::Arg::new("file_root") .long("file-root") .value_name("DIR") .help("Set root directory to import files [default: input file directory]") .help_heading("Other options") .num_args(1) } pub fn follow_location() -> clap::Arg { clap::Arg::new("follow_location") .long("location") .short('L') .help("Follow redirects") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn follow_location_trusted() -> clap::Arg { clap::Arg::new("follow_location_trusted") .long("location-trusted") .help("Follow redirects but allows sending the name + password to all hosts that the site may redirect to") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn from_entry() -> clap::Arg { clap::Arg::new("from_entry") .long("from-entry") .value_name("ENTRY_NUMBER") .value_parser(clap::value_parser!(u32).range(1..)) .help("Execute Hurl file from ENTRY_NUMBER (starting at 1)") .help_heading("Run options") .conflicts_with("interactive") .num_args(1) } pub fn glob() -> clap::Arg { clap::Arg::new("glob") .long("glob") .value_name("GLOB") .help("Specify input files that match the given GLOB. Multiple glob flags may be used") .help_heading("Other options") .num_args(1) .action(clap::ArgAction::Append) } pub fn header() -> clap::Arg { clap::Arg::new("header") .long("header") .short('H') .value_name("HEADER") .help("Pass custom header(s) to server") .help_heading("HTTP options") .num_args(1) .action(clap::ArgAction::Append) } pub fn http10() -> clap::Arg { clap::Arg::new("http10") .long("http1.0") .short('0') .help("Tell Hurl to use HTTP version 1.0") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn http11() -> clap::Arg { clap::Arg::new("http11") .long("http1.1") .help("Tell Hurl to use HTTP version 1.1") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn http2() -> clap::Arg { clap::Arg::new("http2") .long("http2") .help("Tell Hurl to use HTTP version 2") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn http3() -> clap::Arg { clap::Arg::new("http3") .long("http3") .help("Tell Hurl to use HTTP version 3") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn ignore_asserts() -> clap::Arg { clap::Arg::new("ignore_asserts") .long("ignore-asserts") .help("Ignore asserts defined in the Hurl file") .help_heading("Run options") .action(clap::ArgAction::SetTrue) } pub fn include() -> clap::Arg { clap::Arg::new("include") .long("include") .short('i') .help("Include the HTTP headers in the output") .help_heading("Output options") .action(clap::ArgAction::SetTrue) } pub fn insecure() -> clap::Arg { clap::Arg::new("insecure") .long("insecure") .short('k') .help("Allow insecure SSL connections") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn interactive() -> clap::Arg { clap::Arg::new("interactive") .long("interactive") .help("Turn on interactive mode") .help_heading("Run options") .conflicts_with("to_entry") .action(clap::ArgAction::SetTrue) } pub fn ipv4() -> clap::Arg { clap::Arg::new("ipv4") .long("ipv4") .short('4') .help("Tell Hurl to use IPv4 addresses only when resolving host names, and not for example try IPv6") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn ipv6() -> clap::Arg { clap::Arg::new("ipv6") .long("ipv6") .short('6') .help("Tell Hurl to use IPv6 addresses only when resolving host names, and not for example try IPv4") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn jobs() -> clap::Arg { clap::Arg::new("jobs") .long("jobs") .value_name("NUM") .value_parser(clap::value_parser!(u32).range(1..)) .help("Maximum number of parallel jobs") .help_heading("Run options") .num_args(1) } pub fn json() -> clap::Arg { clap::Arg::new("json") .long("json") .help("Output each Hurl file result to JSON") .help_heading("Output options") .conflicts_with("no_output") .action(clap::ArgAction::SetTrue) } pub fn limit_rate() -> clap::Arg { clap::Arg::new("limit_rate") .long("limit-rate") .value_name("SPEED") .value_parser(clap::value_parser!(u64)) .help("Specify the maximum transfer rate in bytes/second, for both downloads and uploads") .help_heading("HTTP options") .num_args(1) } pub fn max_filesize() -> clap::Arg { clap::Arg::new("max_filesize") .long("max-filesize") .value_name("BYTES") .value_parser(clap::value_parser!(u64)) .help("Specify the maximum size in bytes of a file to download") .help_heading("HTTP options") .num_args(1) } pub fn max_redirects() -> clap::Arg { clap::Arg::new("max_redirects") .long("max-redirs") .value_name("NUM") .default_value("50") .value_parser(clap::value_parser!(i32).range(-1..)) .allow_hyphen_values(true) .help("Maximum number of redirects allowed, -1 for unlimited redirects") .help_heading("HTTP options") .num_args(1) } pub fn max_time() -> clap::Arg { clap::Arg::new("max_time") .long("max-time") .short('m') .value_name("SECONDS") .default_value("300") .help("Maximum time allowed for the transfer") .help_heading("HTTP options") .num_args(1) } pub fn netrc() -> clap::Arg { clap::Arg::new("netrc") .long("netrc") .short('n') .help("Must read .netrc for username and password") .help_heading("Other options") .conflicts_with("netrc_file") .conflicts_with("netrc_optional") .action(clap::ArgAction::SetTrue) } pub fn netrc_file() -> clap::Arg { clap::Arg::new("netrc_file") .long("netrc-file") .value_name("FILE") .help("Specify FILE for .netrc") .help_heading("Other options") .conflicts_with("netrc") .num_args(1) } pub fn netrc_optional() -> clap::Arg { clap::Arg::new("netrc_optional") .long("netrc-optional") .help("Use either .netrc or the URL") .help_heading("Other options") .conflicts_with("netrc") .action(clap::ArgAction::SetTrue) } pub fn no_color() -> clap::Arg { clap::Arg::new("no_color") .long("no-color") .help("Do not colorize output") .help_heading("Output options") .conflicts_with("color") .action(clap::ArgAction::SetTrue) } pub fn no_output() -> clap::Arg { clap::Arg::new("no_output") .long("no-output") .help("Suppress output. By default, Hurl outputs the body of the last response") .help_heading("Output options") .conflicts_with("json") .action(clap::ArgAction::SetTrue) } pub fn noproxy() -> clap::Arg { clap::Arg::new("noproxy") .long("noproxy") .value_name("HOST(S)") .help("List of hosts which do not use proxy") .help_heading("HTTP options") .num_args(1) } pub fn output() -> clap::Arg { clap::Arg::new("output") .long("output") .short('o') .value_name("FILE") .help("Write to FILE instead of stdout") .help_heading("Output options") .num_args(1) } pub fn parallel() -> clap::Arg { clap::Arg::new("parallel") .long("parallel") .help("Run files in parallel (default in test mode)") .help_heading("Run options") .action(clap::ArgAction::SetTrue) } pub fn path_as_is() -> clap::Arg { clap::Arg::new("path_as_is") .long("path-as-is") .help("Tell Hurl to not handle sequences of /../ or /./ in the given URL path") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn proxy() -> clap::Arg { clap::Arg::new("proxy") .long("proxy") .short('x') .value_name("[PROTOCOL://]HOST[:PORT]") .help("Use proxy on given PROTOCOL/HOST/PORT") .help_heading("HTTP options") .num_args(1) } pub fn repeat() -> clap::Arg { clap::Arg::new("repeat") .long("repeat") .value_name("NUM") .value_parser(clap::value_parser!(i32).range(-1..)) .allow_hyphen_values(true) .help("Repeat the input files sequence NUM times, -1 for infinite loop") .help_heading("Run options") .num_args(1) } pub fn report_html() -> clap::Arg { clap::Arg::new("report_html") .long("report-html") .value_name("DIR") .help("Generate HTML report to DIR") .help_heading("Report options") .num_args(1) } pub fn report_json() -> clap::Arg { clap::Arg::new("report_json") .long("report-json") .value_name("DIR") .help("Generate JSON report to DIR") .help_heading("Report options") .num_args(1) } pub fn report_junit() -> clap::Arg { clap::Arg::new("report_junit") .long("report-junit") .value_name("FILE") .help("Write a JUnit XML report to FILE") .help_heading("Report options") .num_args(1) } pub fn report_tap() -> clap::Arg { clap::Arg::new("report_tap") .long("report-tap") .value_name("FILE") .help("Write a TAP report to FILE") .help_heading("Report options") .num_args(1) } pub fn resolve() -> clap::Arg { clap::Arg::new("resolve") .long("resolve") .value_name("HOST:PORT:ADDR") .help("Provide a custom address for a specific HOST and PORT pair") .help_heading("HTTP options") .num_args(1) .action(clap::ArgAction::Append) } pub fn retry() -> clap::Arg { clap::Arg::new("retry") .long("retry") .value_name("NUM") .value_parser(clap::value_parser!(i32).range(-1..)) .allow_hyphen_values(true) .help("Maximum number of retries, 0 for no retries, -1 for unlimited retries") .help_heading("Run options") .num_args(1) } pub fn retry_interval() -> clap::Arg { clap::Arg::new("retry_interval") .long("retry-interval") .value_name("MILLISECONDS") .default_value("1000") .help("Interval in milliseconds before a retry") .help_heading("Run options") .num_args(1) } pub fn secret() -> clap::Arg { clap::Arg::new("secret") .long("secret") .value_name("NAME=VALUE") .help("Define a variable which value is secret") .help_heading("Run options") .num_args(1) .action(clap::ArgAction::Append) } pub fn ssl_no_revoke() -> clap::Arg { clap::Arg::new("ssl_no_revoke") .long("ssl-no-revoke") .help("(Windows) Tell Hurl to disable certificate revocation checks") .help_heading("HTTP options") .action(clap::ArgAction::SetTrue) } pub fn test() -> clap::Arg { clap::Arg::new("test") .long("test") .help("Activate test mode (use parallel execution)") .help_heading("Run options") .action(clap::ArgAction::SetTrue) } pub fn to_entry() -> clap::Arg { clap::Arg::new("to_entry") .long("to-entry") .value_name("ENTRY_NUMBER") .value_parser(clap::value_parser!(u32).range(1..)) .help("Execute Hurl file to ENTRY_NUMBER (starting at 1)") .help_heading("Run options") .conflicts_with("interactive") .num_args(1) } pub fn unix_socket() -> clap::Arg { clap::Arg::new("unix_socket") .long("unix-socket") .value_name("PATH") .help("(HTTP) Connect through this Unix domain socket, instead of using the network") .help_heading("HTTP options") .num_args(1) } pub fn user() -> clap::Arg { clap::Arg::new("user") .long("user") .short('u') .value_name("USER:PASSWORD") .help("Add basic Authentication header to each request") .help_heading("HTTP options") .num_args(1) } pub fn user_agent() -> clap::Arg { clap::Arg::new("user_agent") .long("user-agent") .short('A') .value_name("NAME") .help("Specify the User-Agent string to send to the HTTP server") .help_heading("HTTP options") .num_args(1) } pub fn variable() -> clap::Arg { clap::Arg::new("variable") .long("variable") .value_name("NAME=VALUE") .help("Define a variable") .help_heading("Run options") .num_args(1) .action(clap::ArgAction::Append) } pub fn variables_file() -> clap::Arg { clap::Arg::new("variables_file") .long("variables-file") .value_name("FILE") .help("Define a properties file in which you define your variables") .help_heading("Run options") .num_args(1) .action(clap::ArgAction::Append) } pub fn verbose() -> clap::Arg { clap::Arg::new("verbose") .long("verbose") .short('v') .help("Turn on verbose") .help_heading("Output options") .action(clap::ArgAction::SetTrue) } pub fn very_verbose() -> clap::Arg { clap::Arg::new("very_verbose") .long("very-verbose") .help("Turn on verbose output, including HTTP response and libcurl logs") .help_heading("Output options") .action(clap::ArgAction::SetTrue) } hurl-6.1.1/src/cli/options/duration.rs000064400000000000000000000053531046102023000160350ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::str::FromStr; use hurl_core::ast::U64; use hurl_core::typing::{Duration, DurationUnit, ToSource}; use regex::Regex; /// Parses a string to a `Duration`, including time unit. /// /// Example: `32s`, `10m`, `20000`. /// pub fn parse(duration: &str) -> Result { let re = Regex::new(r"^(\d+)([a-zA-Z]*)$").unwrap(); if let Some(caps) = re.captures(duration) { let source = caps.get(1).unwrap().as_str().to_string(); let value = source.parse::().unwrap(); let unit = caps.get(2).unwrap().as_str(); let unit = if unit.is_empty() { None } else { Some(DurationUnit::from_str(unit)?) }; let value = U64::new(value, source.to_source()); Ok(Duration { value, unit }) } else { Err("Invalid duration".to_string()) } } #[cfg(test)] mod tests { use super::*; #[test] pub fn test_parse_error() { assert_eq!(parse("").unwrap_err(), "Invalid duration".to_string()); assert_eq!(parse("s").unwrap_err(), "Invalid duration".to_string()); assert_eq!(parse("10s10").unwrap_err(), "Invalid duration".to_string()); assert_eq!( parse("10mm").unwrap_err(), "Invalid duration unit mm".to_string() ); } #[test] pub fn test_parse() { assert_eq!( parse("10").unwrap(), Duration { value: U64::new(10, "10".to_source()), unit: None } ); assert_eq!( parse("10s").unwrap(), Duration { value: U64::new(10, "10".to_source()), unit: Some(DurationUnit::Second) } ); assert_eq!( parse("10000ms").unwrap(), Duration { value: U64::new(10000, "10000".to_source()), unit: Some(DurationUnit::MilliSecond) } ); assert_eq!( parse("5m").unwrap(), Duration { value: U64::new(5, "5".to_source()), unit: Some(DurationUnit::Minute) } ); } } hurl-6.1.1/src/cli/options/error.rs000064400000000000000000000040441046102023000153350ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::fmt; use std::path::PathBuf; #[derive(Clone, Debug, PartialEq, Eq)] pub enum CliOptionsError { Info(String), NoInput(String), Error(String), InvalidInputFile(PathBuf), } impl From for CliOptionsError { fn from(error: clap::Error) -> Self { match error.kind() { clap::error::ErrorKind::DisplayVersion => CliOptionsError::Info(error.to_string()), clap::error::ErrorKind::DisplayHelp => CliOptionsError::Info(error.to_string()), _ => { // Other clap errors are prefixed with "error ", we strip this prefix as we want to // have our own error prefix. let message = error.to_string(); let message = message.strip_prefix("error: ").unwrap_or(&message); CliOptionsError::Error(message.to_string()) } } } } impl fmt::Display for CliOptionsError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { CliOptionsError::Info(message) => write!(f, "{message}"), CliOptionsError::NoInput(message) => write!(f, "{message}"), CliOptionsError::Error(message) => write!(f, "error: {message}"), CliOptionsError::InvalidInputFile(path) => write!( f, "error: Cannot access '{}': No such file or directory", path.display() ), } } } hurl-6.1.1/src/cli/options/matches.rs000064400000000000000000000466431046102023000156430ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::collections::HashMap; use std::fs::File; use std::io::{BufRead, BufReader, IsTerminal}; use std::path::{Path, PathBuf}; use std::time::Duration; use std::{env, fs, io}; use clap::ArgMatches; use hurl::runner::Value; use hurl_core::input::Input; use hurl_core::typing::{BytesPerSec, Count, DurationUnit}; use crate::cli::options::{ duration, variables, CliOptionsError, ErrorFormat, HttpVersion, IpResolve, Output, }; use crate::cli::OutputType; pub fn cacert_file(arg_matches: &ArgMatches) -> Result, CliOptionsError> { match get_string(arg_matches, "cacert_file") { None => Ok(None), Some(filename) => { let path = Path::new(&filename); if path.exists() { Ok(Some(filename)) } else { Err(CliOptionsError::Error(format!( "Input file {} does not exist", path.display() ))) } } } } pub fn aws_sigv4(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "aws_sigv4") } pub fn client_cert_file(arg_matches: &ArgMatches) -> Result, CliOptionsError> { match get::(arg_matches, "client_cert_file") { None => Ok(None), Some(filename) => { if !Path::new(&filename).is_file() { let message = format!("File {filename} does not exist"); Err(CliOptionsError::Error(message)) } else { Ok(Some(filename)) } } } } pub fn client_key_file(arg_matches: &ArgMatches) -> Result, CliOptionsError> { match get::(arg_matches, "client_key_file") { None => Ok(None), Some(filename) => { if !Path::new(&filename).is_file() { let message = format!("File {filename} does not exist"); Err(CliOptionsError::Error(message)) } else { Ok(Some(filename)) } } } } /// Returns true if Hurl output uses ANSI code and false otherwise. pub fn color(arg_matches: &ArgMatches) -> bool { if has_flag(arg_matches, "color") { return true; } if has_flag(arg_matches, "no_color") { return false; } if let Ok(v) = env::var("NO_COLOR") { if !v.is_empty() { return false; } } io::stdout().is_terminal() } pub fn compressed(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "compressed") } pub fn connect_timeout(arg_matches: &ArgMatches) -> Result { let s = get::(arg_matches, "connect_timeout").unwrap_or_default(); get_duration(&s, DurationUnit::Second) } pub fn connects_to(arg_matches: &ArgMatches) -> Vec { get_strings(arg_matches, "connect_to").unwrap_or_default() } pub fn continue_on_error(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "continue_on_error") } pub fn cookie_input_file(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "cookies_input_file") } pub fn cookie_output_file(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "cookies_output_file").map(PathBuf::from) } pub fn curl_file(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "curl").map(PathBuf::from) } pub fn delay(arg_matches: &ArgMatches) -> Result { let s = get::(arg_matches, "delay").unwrap_or_default(); get_duration(&s, DurationUnit::MilliSecond) } pub fn error_format(arg_matches: &ArgMatches) -> ErrorFormat { let error_format = get::(arg_matches, "error_format"); match error_format.as_deref() { Some("long") => ErrorFormat::Long, Some("short") => ErrorFormat::Short, _ => ErrorFormat::Short, } } pub fn file_root(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "file_root") } pub fn follow_location(arg_matches: &ArgMatches) -> (bool, bool) { let follow_location = has_flag(arg_matches, "follow_location") || has_flag(arg_matches, "follow_location_trusted"); let follow_location_trusted = has_flag(arg_matches, "follow_location_trusted"); (follow_location, follow_location_trusted) } pub fn from_entry(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "from_entry").map(|x| x as usize) } pub fn headers(arg_matches: &ArgMatches) -> Vec { get_strings(arg_matches, "header").unwrap_or_default() } pub fn html_dir(arg_matches: &ArgMatches) -> Result, CliOptionsError> { if let Some(dir) = get::(arg_matches, "report_html") { let path = Path::new(&dir); if !path.exists() { match fs::create_dir_all(path) { Err(_) => Err(CliOptionsError::Error(format!( "HTML dir {} can not be created", path.display() ))), Ok(_) => Ok(Some(path.to_path_buf())), } } else if path.is_dir() { Ok(Some(path.to_path_buf())) } else { return Err(CliOptionsError::Error(format!( "{} is not a valid directory", path.display() ))); } } else { Ok(None) } } pub fn http_version(arg_matches: &ArgMatches) -> Option { if has_flag(arg_matches, "http3") { Some(HttpVersion::V3) } else if has_flag(arg_matches, "http2") { Some(HttpVersion::V2) } else if has_flag(arg_matches, "http11") { Some(HttpVersion::V11) } else if has_flag(arg_matches, "http10") { Some(HttpVersion::V10) } else { None } } pub fn ignore_asserts(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "ignore_asserts") } pub fn include(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "include") } /// Returns true if we have at least one input files. /// The input file can be a file, the standard input, or a glob (even a glob returns empty results). pub fn has_input_files(arg_matches: &ArgMatches) -> bool { get_strings(arg_matches, "input_files").is_some() || get_strings(arg_matches, "glob").is_some() || !io::stdin().is_terminal() } /// Returns the input files from the positional arguments and the glob options pub fn input_files(arg_matches: &ArgMatches) -> Result, CliOptionsError> { let mut files = vec![]; if let Some(filenames) = get_strings(arg_matches, "input_files") { for filename in &filenames { let filename = Path::new(filename); if !filename.exists() { return Err(CliOptionsError::InvalidInputFile(filename.to_path_buf())); } if filename.is_file() { let file = Input::from(filename); files.push(file); } else if filename.is_dir() { walks_hurl_files(filename, &mut files)?; } } } for filename in glob_files(arg_matches)? { files.push(filename); } if files.is_empty() && !io::stdin().is_terminal() { let input = match Input::from_stdin() { Ok(input) => input, Err(err) => return Err(CliOptionsError::Error(err.to_string())), }; files.push(input); } Ok(files) } /// Walks recursively a directory from `dir` and push Hurl files to `files`. fn walks_hurl_files(dir: &Path, files: &mut Vec) -> Result<(), CliOptionsError> { let Ok(entries) = fs::read_dir(dir) else { return Err(CliOptionsError::InvalidInputFile(dir.to_path_buf())); }; for entry in entries { let Ok(entry) = entry else { return Err(CliOptionsError::InvalidInputFile(dir.to_path_buf())); }; let path = entry.path(); if path.is_dir() { walks_hurl_files(&path, files)?; } else if entry.path().extension() == Some("hurl".as_ref()) { files.push(Input::from(entry.path())); } } Ok(()) } pub fn insecure(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "insecure") } pub fn interactive(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "interactive") } pub fn ip_resolve(arg_matches: &ArgMatches) -> Option { if has_flag(arg_matches, "ipv6") { Some(IpResolve::IpV6) } else if has_flag(arg_matches, "ipv4") { Some(IpResolve::IpV4) } else { None } } pub fn junit_file(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "report_junit").map(PathBuf::from) } pub fn limit_rate(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "limit_rate").map(BytesPerSec) } pub fn max_filesize(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "max_filesize") } pub fn max_redirect(arg_matches: &ArgMatches) -> Count { match get::(arg_matches, "max_redirects").unwrap() { -1 => Count::Infinite, m => Count::Finite(m as usize), } } pub fn jobs(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "jobs").map(|m| m as usize) } pub fn json_report_dir(arg_matches: &ArgMatches) -> Result, CliOptionsError> { if let Some(dir) = get::(arg_matches, "report_json") { let path = Path::new(&dir); if !path.exists() { match fs::create_dir_all(path) { Err(_) => Err(CliOptionsError::Error(format!( "JSON dir {} can not be created", path.display() ))), Ok(_) => Ok(Some(path.to_path_buf())), } } else if path.is_dir() { Ok(Some(path.to_path_buf())) } else { return Err(CliOptionsError::Error(format!( "{} is not a valid directory", path.display() ))); } } else { Ok(None) } } pub fn netrc(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "netrc") } pub fn netrc_file(arg_matches: &ArgMatches) -> Result, CliOptionsError> { match get::(arg_matches, "netrc_file") { None => Ok(None), Some(filename) => { if !Path::new(&filename).is_file() { let message = format!("File {filename} does not exist"); Err(CliOptionsError::Error(message)) } else { Ok(Some(filename)) } } } } pub fn netrc_optional(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "netrc_optional") } pub fn no_proxy(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "noproxy") } pub fn output(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "output").map(|filename| Output::new(&filename)) } pub fn output_type(arg_matches: &ArgMatches) -> OutputType { if has_flag(arg_matches, "json") { OutputType::Json } else if has_flag(arg_matches, "no_output") || test(arg_matches) { OutputType::NoOutput } else { OutputType::ResponseBody } } pub fn parallel(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "parallel") || has_flag(arg_matches, "test") } pub fn path_as_is(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "path_as_is") } pub fn progress_bar(arg_matches: &ArgMatches) -> bool { test(arg_matches) && !interactive(arg_matches) && !is_ci() && io::stderr().is_terminal() } pub fn proxy(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "proxy") } pub fn repeat(arg_matches: &ArgMatches) -> Option { match get::(arg_matches, "repeat") { Some(-1) => Some(Count::Infinite), Some(n) => Some(Count::Finite(n as usize)), None => None, } } pub fn resolves(arg_matches: &ArgMatches) -> Vec { get_strings(arg_matches, "resolve").unwrap_or_default() } pub fn retry(arg_matches: &ArgMatches) -> Option { match get::(arg_matches, "retry") { Some(-1) => Some(Count::Infinite), Some(r) => Some(Count::Finite(r as usize)), None => None, } } pub fn retry_interval(arg_matches: &ArgMatches) -> Result { let s = get::(arg_matches, "retry_interval").unwrap_or_default(); get_duration(&s, DurationUnit::MilliSecond) } pub fn secret(matches: &ArgMatches) -> Result, CliOptionsError> { let mut secrets = HashMap::new(); if let Some(secret) = get_strings(matches, "secret") { for s in secret { let inferred = false; let (name, value) = variables::parse(&s, inferred)?; // We check that there is no existing secrets if secrets.contains_key(&name) { return Err(CliOptionsError::Error(format!( "secret '{}' can't be reassigned", &name ))); } // Secrets can only be string. if let Value::String(value) = value { secrets.insert(name, value); } } } Ok(secrets) } pub fn ssl_no_revoke(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "ssl_no_revoke") } pub fn tap_file(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "report_tap").map(PathBuf::from) } pub fn test(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "test") } pub fn timeout(arg_matches: &ArgMatches) -> Result { let s = get::(arg_matches, "max_time").unwrap_or_default(); get_duration(&s, DurationUnit::Second) } pub fn to_entry(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "to_entry").map(|x| x as usize) } pub fn unix_socket(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "unix_socket") } pub fn user(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "user") } pub fn user_agent(arg_matches: &ArgMatches) -> Option { get::(arg_matches, "user_agent") } /// Returns a map of variables from the command line options `matches`. pub fn variables(matches: &ArgMatches) -> Result, CliOptionsError> { let mut variables = HashMap::new(); // Use environment variables prefix by HURL_ for (env_name, env_value) in env::vars() { if let Some(name) = env_name.strip_prefix("HURL_") { let inferred = true; let value = variables::parse_value(env_value.as_str(), inferred)?; variables.insert(name.to_string(), value); } } if let Some(filenames) = get_strings(matches, "variables_file") { for f in filenames.iter() { let path = Path::new(&f); if !path.exists() { return Err(CliOptionsError::Error(format!( "Properties file {} does not exist", path.display() ))); } let file = File::open(path).unwrap(); let reader = BufReader::new(file); for (index, line) in reader.lines().enumerate() { let line = match line { Ok(s) => s, Err(_) => { return Err(CliOptionsError::Error(format!( "Can not parse line {} of {}", index + 1, path.display() ))) } }; let line = line.trim(); if line.starts_with('#') || line.is_empty() { continue; } let inferred = true; let (name, value) = variables::parse(line, inferred)?; variables.insert(name.to_string(), value); } } } if let Some(input) = get_strings(matches, "variable") { for s in input { let inferred = true; let (name, value) = variables::parse(&s, inferred)?; variables.insert(name.to_string(), value); } } Ok(variables) } pub fn verbose(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "verbose") } pub fn very_verbose(arg_matches: &ArgMatches) -> bool { has_flag(arg_matches, "very_verbose") } /// Returns a list of path names from the command line options `matches`. fn glob_files(matches: &ArgMatches) -> Result, CliOptionsError> { let mut all_files = vec![]; if let Some(exprs) = get_strings(matches, "glob") { for expr in exprs { let paths = match glob::glob(&expr) { Ok(paths) => paths, Err(_) => { return Err(CliOptionsError::Error( "Failed to read glob pattern".to_string(), )) } }; let mut files = vec![]; for entry in paths { match entry { Ok(path) => files.push(Input::from(path)), Err(_) => { return Err(CliOptionsError::Error( "Failed to read glob pattern".to_string(), )) } } } if files.is_empty() { return Err(CliOptionsError::InvalidInputFile(PathBuf::from(&expr))); } all_files.extend(files); } } Ok(all_files) } /// Returns an optional value of type `T` from the command line `matches` given the option `name`. fn get(matches: &ArgMatches, name: &str) -> Option { matches.get_one::(name).cloned() } fn has_flag(matches: &ArgMatches, name: &str) -> bool { matches.get_one::(name) == Some(&true) } fn get_string(matches: &ArgMatches, name: &str) -> Option { matches.get_one::(name).map(|x| x.to_string()) } /// Returns an optional list of `String` from the command line `matches` given the option `name`. pub fn get_strings(matches: &ArgMatches, name: &str) -> Option> { matches .get_many::(name) .map(|v| v.map(|x| x.to_string()).collect()) } /// Get duration from input string `s` and `default_unit` fn get_duration(s: &str, default_unit: DurationUnit) -> Result { let duration = duration::parse(s).map_err(CliOptionsError::Error)?; let unit = duration.unit.unwrap_or(default_unit); let millis = match unit { DurationUnit::MilliSecond => duration.value.as_u64(), DurationUnit::Second => duration.value.as_u64() * 1000, DurationUnit::Minute => duration.value.as_u64() * 1000 * 60, }; Ok(Duration::from_millis(millis)) } /// Whether or not this running in a Continuous Integration environment. /// Code borrowed from fn is_ci() -> bool { env::var("CI").is_ok() || env::var("TF_BUILD").is_ok() } hurl-6.1.1/src/cli/options/mod.rs000064400000000000000000000433511046102023000147670ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ mod commands; mod duration; mod error; mod matches; mod variables; use std::collections::HashMap; use std::env; use std::path::{Path, PathBuf}; use std::time::Duration; use clap::ArgMatches; pub use error::CliOptionsError; use hurl::http; use hurl::http::RequestedHttpVersion; use hurl::runner::Output; use hurl::util::logger::{LoggerOptions, LoggerOptionsBuilder, Verbosity}; use hurl::util::path::ContextDir; use hurl_core::ast::Entry; use hurl_core::input::{Input, InputKind}; use hurl_core::typing::{BytesPerSec, Count}; use crate::cli; use crate::runner::{RunnerOptions, RunnerOptionsBuilder, Value}; /// Represents the list of all options that can be used in Hurl command line. #[derive(Clone, Debug, PartialEq, Eq)] pub struct CliOptions { pub aws_sigv4: Option, pub cacert_file: Option, pub client_cert_file: Option, pub client_key_file: Option, pub color: bool, pub compressed: bool, pub connect_timeout: Duration, pub connects_to: Vec, pub continue_on_error: bool, pub cookie_input_file: Option, pub cookie_output_file: Option, pub curl_file: Option, pub delay: Duration, pub error_format: ErrorFormat, pub file_root: Option, pub follow_location: bool, pub follow_location_trusted: bool, pub from_entry: Option, pub headers: Vec, pub html_dir: Option, pub http_version: Option, pub ignore_asserts: bool, pub include: bool, pub input_files: Vec, pub insecure: bool, pub interactive: bool, pub ip_resolve: Option, pub jobs: Option, pub json_report_dir: Option, pub junit_file: Option, pub limit_rate: Option, pub max_filesize: Option, pub max_redirect: Count, pub netrc: bool, pub netrc_file: Option, pub netrc_optional: bool, pub no_proxy: Option, pub output: Option, pub output_type: OutputType, pub parallel: bool, pub path_as_is: bool, pub progress_bar: bool, pub proxy: Option, pub repeat: Option, pub resolves: Vec, pub retry: Option, pub retry_interval: Duration, pub secrets: HashMap, pub ssl_no_revoke: bool, pub tap_file: Option, pub test: bool, pub timeout: Duration, pub to_entry: Option, pub unix_socket: Option, pub user: Option, pub user_agent: Option, pub variables: HashMap, pub verbose: bool, pub very_verbose: bool, } /// Error format: long or rich. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum ErrorFormat { Short, Long, } impl From for hurl::util::logger::ErrorFormat { fn from(value: ErrorFormat) -> Self { match value { ErrorFormat::Short => hurl::util::logger::ErrorFormat::Short, ErrorFormat::Long => hurl::util::logger::ErrorFormat::Long, } } } /// Requested HTTP version. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum HttpVersion { V10, V11, V2, V3, } impl From for RequestedHttpVersion { fn from(value: HttpVersion) -> Self { match value { HttpVersion::V10 => RequestedHttpVersion::Http10, HttpVersion::V11 => RequestedHttpVersion::Http11, HttpVersion::V2 => RequestedHttpVersion::Http2, HttpVersion::V3 => RequestedHttpVersion::Http3, } } } /// IP protocol used. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum IpResolve { IpV4, IpV6, } impl From for http::IpResolve { fn from(value: IpResolve) -> Self { match value { IpResolve::IpV4 => http::IpResolve::IpV4, IpResolve::IpV6 => http::IpResolve::IpV6, } } } fn get_version() -> String { let libcurl_version = http::libcurl_version_info(); let pkg_version = env!("CARGO_PKG_VERSION"); format!( "{} ({}) {}\nFeatures (libcurl): {}\nFeatures (built-in): brotli", pkg_version, libcurl_version.host, libcurl_version.libraries.join(" "), libcurl_version.features.join(" ") ) } pub fn parse() -> Result { let mut command = clap::Command::new("hurl") .version(get_version()) .disable_colored_help(true) .about("Hurl, run and test HTTP requests with plain text") // HTTP options .arg(commands::aws_sigv4()) .arg(commands::cacert_file()) .arg(commands::client_cert_file()) .arg(commands::compressed()) .arg(commands::connect_timeout()) .arg(commands::connect_to()) .arg(commands::header()) .arg(commands::http10()) .arg(commands::http11()) .arg(commands::http2()) .arg(commands::http3()) .arg(commands::input_files()) .arg(commands::insecure()) .arg(commands::ipv4()) .arg(commands::ipv6()) .arg(commands::client_key_file()) .arg(commands::limit_rate()) .arg(commands::follow_location()) .arg(commands::follow_location_trusted()) .arg(commands::max_filesize()) .arg(commands::max_redirects()) .arg(commands::max_time()) .arg(commands::noproxy()) .arg(commands::path_as_is()) .arg(commands::proxy()) .arg(commands::resolve()) .arg(commands::ssl_no_revoke()) .arg(commands::unix_socket()) .arg(commands::user()) .arg(commands::user_agent()) // Output options .arg(commands::color()) .arg(commands::curl()) .arg(commands::error_format()) .arg(commands::include()) .arg(commands::json()) .arg(commands::no_color()) .arg(commands::no_output()) .arg(commands::output()) .arg(commands::verbose()) .arg(commands::very_verbose()) // Run options .arg(commands::continue_on_error()) .arg(commands::delay()) .arg(commands::from_entry()) .arg(commands::ignore_asserts()) .arg(commands::interactive()) .arg(commands::jobs()) .arg(commands::parallel()) .arg(commands::repeat()) .arg(commands::retry()) .arg(commands::retry_interval()) .arg(commands::secret()) .arg(commands::test()) .arg(commands::to_entry()) .arg(commands::variable()) .arg(commands::variables_file()) // Report options .arg(commands::report_html()) .arg(commands::report_json()) .arg(commands::report_junit()) .arg(commands::report_tap()) // Other options .arg(commands::cookies_input_file()) .arg(commands::cookies_output_file()) .arg(commands::file_root()) .arg(commands::glob()) .arg(commands::netrc()) .arg(commands::netrc_file()) .arg(commands::netrc_optional()); let arg_matches = command.try_get_matches_from_mut(env::args_os())?; // If we've no file input (either from the standard input or from the command line arguments), // we just print help and exit. if !matches::has_input_files(&arg_matches) { let help = command.render_help().to_string(); return Err(CliOptionsError::NoInput(help)); } let opts = parse_matches(&arg_matches)?; if opts.input_files.is_empty() { return Err(CliOptionsError::Error( "No input files provided".to_string(), )); } Ok(opts) } fn parse_matches(arg_matches: &ArgMatches) -> Result { let aws_sigv4 = matches::aws_sigv4(arg_matches); let cacert_file = matches::cacert_file(arg_matches)?; let client_cert_file = matches::client_cert_file(arg_matches)?; let client_key_file = matches::client_key_file(arg_matches)?; let color = matches::color(arg_matches); let compressed = matches::compressed(arg_matches); let connect_timeout = matches::connect_timeout(arg_matches)?; let connects_to = matches::connects_to(arg_matches); let continue_on_error = matches::continue_on_error(arg_matches); let cookie_input_file = matches::cookie_input_file(arg_matches); let cookie_output_file = matches::cookie_output_file(arg_matches); let curl_file = matches::curl_file(arg_matches); let delay = matches::delay(arg_matches)?; let error_format = matches::error_format(arg_matches); let file_root = matches::file_root(arg_matches); let (follow_location, follow_location_trusted) = matches::follow_location(arg_matches); let from_entry = matches::from_entry(arg_matches); let headers = matches::headers(arg_matches); let html_dir = matches::html_dir(arg_matches)?; let http_version = matches::http_version(arg_matches); let ignore_asserts = matches::ignore_asserts(arg_matches); let include = matches::include(arg_matches); let input_files = matches::input_files(arg_matches)?; let insecure = matches::insecure(arg_matches); let interactive = matches::interactive(arg_matches); let ip_resolve = matches::ip_resolve(arg_matches); let jobs = matches::jobs(arg_matches); let json_report_dir = matches::json_report_dir(arg_matches)?; let junit_file = matches::junit_file(arg_matches); let limit_rate = matches::limit_rate(arg_matches); let max_filesize = matches::max_filesize(arg_matches); let max_redirect = matches::max_redirect(arg_matches); let netrc = matches::netrc(arg_matches); let netrc_file = matches::netrc_file(arg_matches)?; let netrc_optional = matches::netrc_optional(arg_matches); let no_proxy = matches::no_proxy(arg_matches); let parallel = matches::parallel(arg_matches); let path_as_is = matches::path_as_is(arg_matches); let progress_bar = matches::progress_bar(arg_matches); let proxy = matches::proxy(arg_matches); let output = matches::output(arg_matches); let output_type = matches::output_type(arg_matches); let repeat = matches::repeat(arg_matches); let resolves = matches::resolves(arg_matches); let retry = matches::retry(arg_matches); let retry_interval = matches::retry_interval(arg_matches)?; let secrets = matches::secret(arg_matches)?; let ssl_no_revoke = matches::ssl_no_revoke(arg_matches); let tap_file = matches::tap_file(arg_matches); let test = matches::test(arg_matches); let timeout = matches::timeout(arg_matches)?; let to_entry = matches::to_entry(arg_matches); let unix_socket = matches::unix_socket(arg_matches); let user = matches::user(arg_matches); let user_agent = matches::user_agent(arg_matches); let variables = matches::variables(arg_matches)?; let verbose = matches::verbose(arg_matches); let very_verbose = matches::very_verbose(arg_matches); Ok(CliOptions { aws_sigv4, cacert_file, client_cert_file, client_key_file, color, compressed, connect_timeout, connects_to, continue_on_error, cookie_input_file, cookie_output_file, curl_file, delay, error_format, file_root, follow_location, follow_location_trusted, from_entry, headers, html_dir, http_version, ignore_asserts, include, input_files, insecure, interactive, ip_resolve, json_report_dir, junit_file, limit_rate, max_filesize, max_redirect, netrc, netrc_file, netrc_optional, no_proxy, path_as_is, parallel, progress_bar, proxy, output, output_type, repeat, resolves, retry, retry_interval, secrets, ssl_no_revoke, tap_file, test, timeout, to_entry, unix_socket, user, user_agent, variables, verbose, very_verbose, jobs, }) } #[derive(Clone, Debug, PartialEq, Eq)] pub enum OutputType { /// The last HTTP response body of a Hurl file is outputted on standard output. ResponseBody, /// The whole Hurl file run is exported in a structured JSON export on standard output. Json, /// Nothing is outputted on standard output when a Hurl file run is completed. NoOutput, } impl CliOptions { /// Converts this instance of [`CliOptions`] to an instance of [`RunnerOptions`] pub fn to_runner_options(&self, filename: &Input, current_dir: &Path) -> RunnerOptions { let aws_sigv4 = self.aws_sigv4.clone(); let cacert_file = self.cacert_file.clone(); let client_cert_file = self.client_cert_file.clone(); let client_key_file = self.client_key_file.clone(); let compressed = self.compressed; let connect_timeout = self.connect_timeout; let connects_to = self.connects_to.clone(); let file_root = match &self.file_root { Some(f) => Path::new(f), None => match filename.kind() { InputKind::File(path) => path.parent().unwrap(), InputKind::Stdin(_) => current_dir, }, }; let context_dir = ContextDir::new(current_dir, file_root); let continue_on_error = self.continue_on_error; let cookie_input_file = self.cookie_input_file.clone(); let delay = self.delay; let follow_location = self.follow_location; let follow_location_trusted = self.follow_location_trusted; let from_entry = self.from_entry; let headers = &self.headers; let http_version = match self.http_version { Some(version) => version.into(), None => RequestedHttpVersion::default(), }; let ignore_asserts = self.ignore_asserts; let insecure = self.insecure; let ip_resolve = match self.ip_resolve { Some(ip) => ip.into(), None => http::IpResolve::default(), }; let max_filesize = self.max_filesize; // Like curl, we don't differentiate upload and download limit rate, we have // only one option. let max_recv_speed = self.limit_rate; let max_send_speed = self.limit_rate; let max_redirect = self.max_redirect; let netrc = self.netrc; let netrc_file = self.netrc_file.clone(); let netrc_optional = self.netrc_optional; let no_proxy = self.no_proxy.clone(); let output = self.output.clone(); let path_as_is = self.path_as_is; let post_entry = if self.interactive { Some(cli::interactive::post_entry as fn() -> bool) } else { None }; let pre_entry = if self.interactive { Some(cli::interactive::pre_entry as fn(&Entry) -> bool) } else { None }; let proxy = self.proxy.clone(); let resolves = self.resolves.clone(); let retry = self.retry; let retry_interval = self.retry_interval; let ssl_no_revoke = self.ssl_no_revoke; let timeout = self.timeout; let to_entry = self.to_entry; let unix_socket = self.unix_socket.clone(); let user = self.user.clone(); let user_agent = self.user_agent.clone(); RunnerOptionsBuilder::new() .aws_sigv4(aws_sigv4) .cacert_file(cacert_file) .client_cert_file(client_cert_file) .client_key_file(client_key_file) .delay(delay) .compressed(compressed) .connect_timeout(connect_timeout) .connects_to(&connects_to) .continue_on_error(continue_on_error) .context_dir(&context_dir) .cookie_input_file(cookie_input_file) .follow_location(follow_location) .follow_location_trusted(follow_location_trusted) .from_entry(from_entry) .headers(headers) .http_version(http_version) .ignore_asserts(ignore_asserts) .insecure(insecure) .ip_resolve(ip_resolve) .max_filesize(max_filesize) .max_recv_speed(max_recv_speed) .max_redirect(max_redirect) .max_send_speed(max_send_speed) .netrc(netrc) .netrc_file(netrc_file) .netrc_optional(netrc_optional) .no_proxy(no_proxy) .output(output) .path_as_is(path_as_is) .post_entry(post_entry) .pre_entry(pre_entry) .proxy(proxy) .resolves(&resolves) .retry(retry) .retry_interval(retry_interval) .ssl_no_revoke(ssl_no_revoke) .timeout(timeout) .to_entry(to_entry) .unix_socket(unix_socket) .user(user) .user_agent(user_agent) .build() } /// Converts this instance of [`ClipOptions`] to an instance of [`LoggerOptions`] pub fn to_logger_options(&self) -> LoggerOptions { let verbosity = Verbosity::from(self.verbose, self.very_verbose); LoggerOptionsBuilder::new() .color(self.color) .error_format(self.error_format.into()) .verbosity(verbosity) .build() } } hurl-6.1.1/src/cli/options/variables.rs000064400000000000000000000133131046102023000161530ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::is_variable_reserved; use super::CliOptionsError; use crate::runner::{Number, Value}; /// Parses a string "name=value" as a pair of `String` and `Value`. /// /// If `inferred` is `true`, value variant is inferred from the `value`, for instance `true` is parsed as [`Value::Bool(true)`]. pub fn parse(s: &str, inferred: bool) -> Result<(String, Value), CliOptionsError> { match s.find('=') { None => Err(CliOptionsError::Error(format!( "Missing value for variable {s}!" ))), Some(index) => { let (name, value) = s.split_at(index); if is_variable_reserved(name) { return Err(CliOptionsError::Error(format!( "Variable {name} conflicts with the {name} function, use a different name." ))); } let value = parse_value(&value[1..], inferred)?; Ok((name.to_string(), value)) } } } /// Parses a `value` as a pair of String and Value. /// /// If `inferred` is `true`, value variant is inferred from the `value`, for instance true is parsed as [`Value::Bool(true)`]. pub fn parse_value(s: &str, inferred: bool) -> Result { if !inferred { Ok(Value::String(s.to_string())) } else if s == "true" { Ok(Value::Bool(true)) } else if s == "false" { Ok(Value::Bool(false)) } else if s == "null" { Ok(Value::Null) } else if let Ok(v) = s.parse::() { Ok(Value::Number(Number::Integer(v))) } else if s.chars().all(char::is_numeric) { Ok(Value::Number(Number::BigInteger(s.to_string()))) } else if let Ok(v) = s.parse::() { Ok(Value::Number(Number::Float(v))) } else if let Some(s) = s.strip_prefix('"') { if let Some(s) = s.strip_suffix('"') { Ok(Value::String(s.to_string())) } else { Err(CliOptionsError::Error( "Value should end with a double quote".to_string(), )) } } else { Ok(Value::String(s.to_string())) } } #[cfg(test)] mod tests { use super::{CliOptionsError, *}; #[test] fn test_parse() { assert_eq!( parse("name=Jennifer", true).unwrap(), ("name".to_string(), Value::String("Jennifer".to_string())) ); assert_eq!( parse("female=true", true).unwrap(), ("female".to_string(), Value::Bool(true)) ); assert_eq!( parse("age=30", true).unwrap(), ("age".to_string(), Value::Number(Number::Integer(30))) ); assert_eq!( parse("height=1.7", true).unwrap(), ("height".to_string(), Value::Number(Number::Float(1.7))) ); assert_eq!( parse("id=\"123\"", true).unwrap(), ("id".to_string(), Value::String("123".to_string())) ); assert_eq!( parse("id=9223372036854775808", true).unwrap(), ( "id".to_string(), Value::Number(Number::BigInteger("9223372036854775808".to_string())) ) ); assert_eq!( parse("a_null=null", true).unwrap(), ("a_null".to_string(), Value::Null) ); assert_eq!( parse("a_null=null", false).unwrap(), ("a_null".to_string(), Value::String("null".to_string())) ); } #[test] fn test_parse_error() { assert_eq!( parse("name", true).err().unwrap(), CliOptionsError::Error("Missing value for variable name!".to_string()) ); } #[test] fn test_parse_value() { assert_eq!( parse_value("Jennifer", true).unwrap(), Value::String("Jennifer".to_string()) ); assert_eq!(parse_value("true", true).unwrap(), Value::Bool(true)); assert_eq!( parse_value("30", true).unwrap(), Value::Number(Number::Integer(30)) ); assert_eq!( parse_value("30", false).unwrap(), Value::String("30".to_string()) ); assert_eq!( parse_value("1.7", true).unwrap(), Value::Number(Number::Float(1.7)) ); assert_eq!( parse_value("1.7", false).unwrap(), Value::String("1.7".to_string()) ); assert_eq!( parse_value("1.0", true).unwrap(), Value::Number(Number::Float(1.0)) ); assert_eq!( parse_value("-1.0", true).unwrap(), Value::Number(Number::Float(-1.0)) ); assert_eq!( parse_value("\"123\"", true).unwrap(), Value::String("123".to_string()) ); assert_eq!( parse_value("\"123\"", false).unwrap(), Value::String("\"123\"".to_string()) ); assert_eq!(parse_value("null", true).unwrap(), Value::Null); } #[test] fn test_parse_value_error() { assert_eq!( parse_value("\"123", true).err().unwrap(), CliOptionsError::Error("Value should end with a double quote".to_string()) ); } } hurl-6.1.1/src/cli/summary.rs000064400000000000000000000105571046102023000142140ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::time::Duration; use crate::HurlRun; /// Returns the text summary of this Hurl `runs`. /// /// This is used in `--test`mode. pub fn summary(runs: &[HurlRun], duration: Duration) -> String { let total_files = runs.len(); let total_requests = requests_count(runs); let duration_in_ms = duration.as_millis() as f64; let requests_rate = 1000.0 * (total_requests as f64) / duration_in_ms; let success_files = runs.iter().filter(|r| r.hurl_result.success).count(); let success_percent = 100.0 * success_files as f32 / total_files as f32; let failed = total_files - success_files; let failed_percent = 100.0 * failed as f32 / total_files as f32; format!( "--------------------------------------------------------------------------------\n\ Executed files: {total_files}\n\ Executed requests: {total_requests} ({requests_rate:.1}/s)\n\ Succeeded files: {success_files} ({success_percent:.1}%)\n\ Failed files: {failed} ({failed_percent:.1}%)\n\ Duration: {duration_in_ms} ms\n" ) } /// Returns the total number of executed HTTP requests in this list of `runs`. fn requests_count(runs: &[HurlRun]) -> usize { // Each entry has a list of calls. Each call is a pair of HTTP request / response // so, for a given entry, the number of executed requests is the number of calls. This count // also the retries. runs.iter() .map(|r| { r.hurl_result .entries .iter() .map(|e| e.calls.len()) .sum::() }) .sum() } #[cfg(test)] pub mod tests { use hurl::http::CurlCmd; use hurl::runner::{EntryResult, HurlResult}; use hurl_core::ast::SourceInfo; use hurl_core::input::Input; use hurl_core::reader::Pos; use super::*; #[test] fn create_run_summary() { fn new_run(success: bool, entries_count: usize) -> HurlRun { let dummy_entry = EntryResult { entry_index: 0, source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), calls: vec![], captures: vec![], asserts: vec![], errors: vec![], transfer_duration: Duration::from_millis(0), compressed: false, curl_cmd: CurlCmd::default(), }; HurlRun { content: String::new(), filename: Input::new(""), hurl_result: HurlResult { entries: vec![dummy_entry; entries_count], success, ..Default::default() }, } } let runs = vec![new_run(true, 10), new_run(true, 20), new_run(true, 4)]; let duration = Duration::from_millis(128); let s = summary(&runs, duration); assert_eq!( s, "--------------------------------------------------------------------------------\n\ Executed files: 3\n\ Executed requests: 0 (0.0/s)\n\ Succeeded files: 3 (100.0%)\n\ Failed files: 0 (0.0%)\n\ Duration: 128 ms\n" ); let runs = vec![new_run(true, 10), new_run(false, 10), new_run(true, 40)]; let duration = Duration::from_millis(200); let s = summary(&runs, duration); assert_eq!( s, "--------------------------------------------------------------------------------\n\ Executed files: 3\n\ Executed requests: 0 (0.0/s)\n\ Succeeded files: 2 (66.7%)\n\ Failed files: 1 (33.3%)\n\ Duration: 200 ms\n" ); } } hurl-6.1.1/src/html/entities.rs000064400000000000000000002024331046102023000145340ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::collections::HashMap; use lazy_static::lazy_static; // HTML5 named character references // // Generated from and // . // Map HTML5 named character references to the equivalent Unicode character(s). static HTML5_ENTITIES: [(&str, &str); 2231] = [ ("AElig", "\u{C6}"), ("AElig;", "\u{C6}"), ("AMP", "\u{26}"), ("AMP;", "\u{26}"), ("Aacute", "\u{C1}"), ("Aacute;", "\u{C1}"), ("Abreve;", "\u{102}"), ("Acirc", "\u{C2}"), ("Acirc;", "\u{C2}"), ("Acy;", "\u{410}"), ("Afr;", "\u{1D504}"), ("Agrave", "\u{C0}"), ("Agrave;", "\u{C0}"), ("Alpha;", "\u{391}"), ("Amacr;", "\u{100}"), ("And;", "\u{2A53}"), ("Aogon;", "\u{104}"), ("Aopf;", "\u{1D538}"), ("ApplyFunction;", "\u{2061}"), ("Aring", "\u{C5}"), ("Aring;", "\u{C5}"), ("Ascr;", "\u{1D49C}"), ("Assign;", "\u{2254}"), ("Atilde", "\u{C3}"), ("Atilde;", "\u{C3}"), ("Auml", "\u{C4}"), ("Auml;", "\u{C4}"), ("Backslash;", "\u{2216}"), ("Barv;", "\u{2AE7}"), ("Barwed;", "\u{2306}"), ("Bcy;", "\u{411}"), ("Because;", "\u{2235}"), ("Bernoullis;", "\u{212C}"), ("Beta;", "\u{392}"), ("Bfr;", "\u{1D505}"), ("Bopf;", "\u{1D539}"), ("Breve;", "\u{2D8}"), ("Bscr;", "\u{212C}"), ("Bumpeq;", "\u{224E}"), ("CHcy;", "\u{427}"), ("COPY", "\u{A9}"), ("COPY;", "\u{A9}"), ("Cacute;", "\u{106}"), ("Cap;", "\u{22D2}"), ("CapitalDifferentialD;", "\u{2145}"), ("Cayleys;", "\u{212D}"), ("Ccaron;", "\u{10C}"), ("Ccedil", "\u{C7}"), ("Ccedil;", "\u{C7}"), ("Ccirc;", "\u{108}"), ("Cconint;", "\u{2230}"), ("Cdot;", "\u{10A}"), ("Cedilla;", "\u{B8}"), ("CenterDot;", "\u{B7}"), ("Cfr;", "\u{212D}"), ("Chi;", "\u{3A7}"), ("CircleDot;", "\u{2299}"), ("CircleMinus;", "\u{2296}"), ("CirclePlus;", "\u{2295}"), ("CircleTimes;", "\u{2297}"), ("ClockwiseContourIntegral;", "\u{2232}"), ("CloseCurlyDoubleQuote;", "\u{201D}"), ("CloseCurlyQuote;", "\u{2019}"), ("Colon;", "\u{2237}"), ("Colone;", "\u{2A74}"), ("Congruent;", "\u{2261}"), ("Conint;", "\u{222F}"), ("ContourIntegral;", "\u{222E}"), ("Copf;", "\u{2102}"), ("Coproduct;", "\u{2210}"), ("CounterClockwiseContourIntegral;", "\u{2233}"), ("Cross;", "\u{2A2F}"), ("Cscr;", "\u{1D49E}"), ("Cup;", "\u{22D3}"), ("CupCap;", "\u{224D}"), ("DD;", "\u{2145}"), ("DDotrahd;", "\u{2911}"), ("DJcy;", "\u{402}"), ("DScy;", "\u{405}"), ("DZcy;", "\u{40F}"), ("Dagger;", "\u{2021}"), ("Darr;", "\u{21A1}"), ("Dashv;", "\u{2AE4}"), ("Dcaron;", "\u{10E}"), ("Dcy;", "\u{414}"), ("Del;", "\u{2207}"), ("Delta;", "\u{394}"), ("Dfr;", "\u{1D507}"), ("DiacriticalAcute;", "\u{B4}"), ("DiacriticalDot;", "\u{2D9}"), ("DiacriticalDoubleAcute;", "\u{2DD}"), ("DiacriticalGrave;", "\u{60}"), ("DiacriticalTilde;", "\u{2DC}"), ("Diamond;", "\u{22C4}"), ("DifferentialD;", "\u{2146}"), ("Dopf;", "\u{1D53B}"), ("Dot;", "\u{A8}"), ("DotDot;", "\u{20DC}"), ("DotEqual;", "\u{2250}"), ("DoubleContourIntegral;", "\u{222F}"), ("DoubleDot;", "\u{A8}"), ("DoubleDownArrow;", "\u{21D3}"), ("DoubleLeftArrow;", "\u{21D0}"), ("DoubleLeftRightArrow;", "\u{21D4}"), ("DoubleLeftTee;", "\u{2AE4}"), ("DoubleLongLeftArrow;", "\u{27F8}"), ("DoubleLongLeftRightArrow;", "\u{27FA}"), ("DoubleLongRightArrow;", "\u{27F9}"), ("DoubleRightArrow;", "\u{21D2}"), ("DoubleRightTee;", "\u{22A8}"), ("DoubleUpArrow;", "\u{21D1}"), ("DoubleUpDownArrow;", "\u{21D5}"), ("DoubleVerticalBar;", "\u{2225}"), ("DownArrow;", "\u{2193}"), ("DownArrowBar;", "\u{2913}"), ("DownArrowUpArrow;", "\u{21F5}"), ("DownBreve;", "\u{311}"), ("DownLeftRightVector;", "\u{2950}"), ("DownLeftTeeVector;", "\u{295E}"), ("DownLeftVector;", "\u{21BD}"), ("DownLeftVectorBar;", "\u{2956}"), ("DownRightTeeVector;", "\u{295F}"), ("DownRightVector;", "\u{21C1}"), ("DownRightVectorBar;", "\u{2957}"), ("DownTee;", "\u{22A4}"), ("DownTeeArrow;", "\u{21A7}"), ("Downarrow;", "\u{21D3}"), ("Dscr;", "\u{1D49F}"), ("Dstrok;", "\u{110}"), ("ENG;", "\u{14A}"), ("ETH", "\u{D0}"), ("ETH;", "\u{D0}"), ("Eacute", "\u{C9}"), ("Eacute;", "\u{C9}"), ("Ecaron;", "\u{11A}"), ("Ecirc", "\u{CA}"), ("Ecirc;", "\u{CA}"), ("Ecy;", "\u{42D}"), ("Edot;", "\u{116}"), ("Efr;", "\u{1D508}"), ("Egrave", "\u{C8}"), ("Egrave;", "\u{C8}"), ("Element;", "\u{2208}"), ("Emacr;", "\u{112}"), ("EmptySmallSquare;", "\u{25FB}"), ("EmptyVerySmallSquare;", "\u{25AB}"), ("Eogon;", "\u{118}"), ("Eopf;", "\u{1D53C}"), ("Epsilon;", "\u{395}"), ("Equal;", "\u{2A75}"), ("EqualTilde;", "\u{2242}"), ("Equilibrium;", "\u{21CC}"), ("Escr;", "\u{2130}"), ("Esim;", "\u{2A73}"), ("Eta;", "\u{397}"), ("Euml", "\u{CB}"), ("Euml;", "\u{CB}"), ("Exists;", "\u{2203}"), ("ExponentialE;", "\u{2147}"), ("Fcy;", "\u{424}"), ("Ffr;", "\u{1D509}"), ("FilledSmallSquare;", "\u{25FC}"), ("FilledVerySmallSquare;", "\u{25AA}"), ("Fopf;", "\u{1D53D}"), ("ForAll;", "\u{2200}"), ("Fouriertrf;", "\u{2131}"), ("Fscr;", "\u{2131}"), ("GJcy;", "\u{403}"), ("GT", "\u{3E}"), ("GT;", "\u{3E}"), ("Gamma;", "\u{393}"), ("Gammad;", "\u{3DC}"), ("Gbreve;", "\u{11E}"), ("Gcedil;", "\u{122}"), ("Gcirc;", "\u{11C}"), ("Gcy;", "\u{413}"), ("Gdot;", "\u{120}"), ("Gfr;", "\u{1D50A}"), ("Gg;", "\u{22D9}"), ("Gopf;", "\u{1D53E}"), ("GreaterEqual;", "\u{2265}"), ("GreaterEqualLess;", "\u{22DB}"), ("GreaterFullEqual;", "\u{2267}"), ("GreaterGreater;", "\u{2AA2}"), ("GreaterLess;", "\u{2277}"), ("GreaterSlantEqual;", "\u{2A7E}"), ("GreaterTilde;", "\u{2273}"), ("Gscr;", "\u{1D4A2}"), ("Gt;", "\u{226B}"), ("HARDcy;", "\u{42A}"), ("Hacek;", "\u{2C7}"), ("Hat;", "\u{5E}"), ("Hcirc;", "\u{124}"), ("Hfr;", "\u{210C}"), ("HilbertSpace;", "\u{210B}"), ("Hopf;", "\u{210D}"), ("HorizontalLine;", "\u{2500}"), ("Hscr;", "\u{210B}"), ("Hstrok;", "\u{126}"), ("HumpDownHump;", "\u{224E}"), ("HumpEqual;", "\u{224F}"), ("IEcy;", "\u{415}"), ("IJlig;", "\u{132}"), ("IOcy;", "\u{401}"), ("Iacute", "\u{CD}"), ("Iacute;", "\u{CD}"), ("Icirc", "\u{CE}"), ("Icirc;", "\u{CE}"), ("Icy;", "\u{418}"), ("Idot;", "\u{130}"), ("Ifr;", "\u{2111}"), ("Igrave", "\u{CC}"), ("Igrave;", "\u{CC}"), ("Im;", "\u{2111}"), ("Imacr;", "\u{12A}"), ("ImaginaryI;", "\u{2148}"), ("Implies;", "\u{21D2}"), ("Int;", "\u{222C}"), ("Integral;", "\u{222B}"), ("Intersection;", "\u{22C2}"), ("InvisibleComma;", "\u{2063}"), ("InvisibleTimes;", "\u{2062}"), ("Iogon;", "\u{12E}"), ("Iopf;", "\u{1D540}"), ("Iota;", "\u{399}"), ("Iscr;", "\u{2110}"), ("Itilde;", "\u{128}"), ("Iukcy;", "\u{406}"), ("Iuml", "\u{CF}"), ("Iuml;", "\u{CF}"), ("Jcirc;", "\u{134}"), ("Jcy;", "\u{419}"), ("Jfr;", "\u{1D50D}"), ("Jopf;", "\u{1D541}"), ("Jscr;", "\u{1D4A5}"), ("Jsercy;", "\u{408}"), ("Jukcy;", "\u{404}"), ("KHcy;", "\u{425}"), ("KJcy;", "\u{40C}"), ("Kappa;", "\u{39A}"), ("Kcedil;", "\u{136}"), ("Kcy;", "\u{41A}"), ("Kfr;", "\u{1D50E}"), ("Kopf;", "\u{1D542}"), ("Kscr;", "\u{1D4A6}"), ("LJcy;", "\u{409}"), ("LT", "\u{3C}"), ("LT;", "\u{3C}"), ("Lacute;", "\u{139}"), ("Lambda;", "\u{39B}"), ("Lang;", "\u{27EA}"), ("Laplacetrf;", "\u{2112}"), ("Larr;", "\u{219E}"), ("Lcaron;", "\u{13D}"), ("Lcedil;", "\u{13B}"), ("Lcy;", "\u{41B}"), ("LeftAngleBracket;", "\u{27E8}"), ("LeftArrow;", "\u{2190}"), ("LeftArrowBar;", "\u{21E4}"), ("LeftArrowRightArrow;", "\u{21C6}"), ("LeftCeiling;", "\u{2308}"), ("LeftDoubleBracket;", "\u{27E6}"), ("LeftDownTeeVector;", "\u{2961}"), ("LeftDownVector;", "\u{21C3}"), ("LeftDownVectorBar;", "\u{2959}"), ("LeftFloor;", "\u{230A}"), ("LeftRightArrow;", "\u{2194}"), ("LeftRightVector;", "\u{294E}"), ("LeftTee;", "\u{22A3}"), ("LeftTeeArrow;", "\u{21A4}"), ("LeftTeeVector;", "\u{295A}"), ("LeftTriangle;", "\u{22B2}"), ("LeftTriangleBar;", "\u{29CF}"), ("LeftTriangleEqual;", "\u{22B4}"), ("LeftUpDownVector;", "\u{2951}"), ("LeftUpTeeVector;", "\u{2960}"), ("LeftUpVector;", "\u{21BF}"), ("LeftUpVectorBar;", "\u{2958}"), ("LeftVector;", "\u{21BC}"), ("LeftVectorBar;", "\u{2952}"), ("Leftarrow;", "\u{21D0}"), ("Leftrightarrow;", "\u{21D4}"), ("LessEqualGreater;", "\u{22DA}"), ("LessFullEqual;", "\u{2266}"), ("LessGreater;", "\u{2276}"), ("LessLess;", "\u{2AA1}"), ("LessSlantEqual;", "\u{2A7D}"), ("LessTilde;", "\u{2272}"), ("Lfr;", "\u{1D50F}"), ("Ll;", "\u{22D8}"), ("Lleftarrow;", "\u{21DA}"), ("Lmidot;", "\u{13F}"), ("LongLeftArrow;", "\u{27F5}"), ("LongLeftRightArrow;", "\u{27F7}"), ("LongRightArrow;", "\u{27F6}"), ("Longleftarrow;", "\u{27F8}"), ("Longleftrightarrow;", "\u{27FA}"), ("Longrightarrow;", "\u{27F9}"), ("Lopf;", "\u{1D543}"), ("LowerLeftArrow;", "\u{2199}"), ("LowerRightArrow;", "\u{2198}"), ("Lscr;", "\u{2112}"), ("Lsh;", "\u{21B0}"), ("Lstrok;", "\u{141}"), ("Lt;", "\u{226A}"), ("Map;", "\u{2905}"), ("Mcy;", "\u{41C}"), ("MediumSpace;", "\u{205F}"), ("Mellintrf;", "\u{2133}"), ("Mfr;", "\u{1D510}"), ("MinusPlus;", "\u{2213}"), ("Mopf;", "\u{1D544}"), ("Mscr;", "\u{2133}"), ("Mu;", "\u{39C}"), ("NJcy;", "\u{40A}"), ("Nacute;", "\u{143}"), ("Ncaron;", "\u{147}"), ("Ncedil;", "\u{145}"), ("Ncy;", "\u{41D}"), ("NegativeMediumSpace;", "\u{200B}"), ("NegativeThickSpace;", "\u{200B}"), ("NegativeThinSpace;", "\u{200B}"), ("NegativeVeryThinSpace;", "\u{200B}"), ("NestedGreaterGreater;", "\u{226B}"), ("NestedLessLess;", "\u{226A}"), ("NewLine;", "\u{A}"), ("Nfr;", "\u{1D511}"), ("NoBreak;", "\u{2060}"), ("NonBreakingSpace;", "\u{A0}"), ("Nopf;", "\u{2115}"), ("Not;", "\u{2AEC}"), ("NotCongruent;", "\u{2262}"), ("NotCupCap;", "\u{226D}"), ("NotDoubleVerticalBar;", "\u{2226}"), ("NotElement;", "\u{2209}"), ("NotEqual;", "\u{2260}"), ("NotEqualTilde;", "\u{2242}\u{338}"), ("NotExists;", "\u{2204}"), ("NotGreater;", "\u{226F}"), ("NotGreaterEqual;", "\u{2271}"), ("NotGreaterFullEqual;", "\u{2267}\u{338}"), ("NotGreaterGreater;", "\u{226B}\u{338}"), ("NotGreaterLess;", "\u{2279}"), ("NotGreaterSlantEqual;", "\u{2A7E}\u{338}"), ("NotGreaterTilde;", "\u{2275}"), ("NotHumpDownHump;", "\u{224E}\u{338}"), ("NotHumpEqual;", "\u{224F}\u{338}"), ("NotLeftTriangle;", "\u{22EA}"), ("NotLeftTriangleBar;", "\u{29CF}\u{338}"), ("NotLeftTriangleEqual;", "\u{22EC}"), ("NotLess;", "\u{226E}"), ("NotLessEqual;", "\u{2270}"), ("NotLessGreater;", "\u{2278}"), ("NotLessLess;", "\u{226A}\u{338}"), ("NotLessSlantEqual;", "\u{2A7D}\u{338}"), ("NotLessTilde;", "\u{2274}"), ("NotNestedGreaterGreater;", "\u{2AA2}\u{338}"), ("NotNestedLessLess;", "\u{2AA1}\u{338}"), ("NotPrecedes;", "\u{2280}"), ("NotPrecedesEqual;", "\u{2AAF}\u{338}"), ("NotPrecedesSlantEqual;", "\u{22E0}"), ("NotReverseElement;", "\u{220C}"), ("NotRightTriangle;", "\u{22EB}"), ("NotRightTriangleBar;", "\u{29D0}\u{338}"), ("NotRightTriangleEqual;", "\u{22ED}"), ("NotSquareSubset;", "\u{228F}\u{338}"), ("NotSquareSubsetEqual;", "\u{22E2}"), ("NotSquareSuperset;", "\u{2290}\u{338}"), ("NotSquareSupersetEqual;", "\u{22E3}"), ("NotSubset;", "\u{2282}\u{20D2}"), ("NotSubsetEqual;", "\u{2288}"), ("NotSucceeds;", "\u{2281}"), ("NotSucceedsEqual;", "\u{2AB0}\u{338}"), ("NotSucceedsSlantEqual;", "\u{22E1}"), ("NotSucceedsTilde;", "\u{227F}\u{338}"), ("NotSuperset;", "\u{2283}\u{20D2}"), ("NotSupersetEqual;", "\u{2289}"), ("NotTilde;", "\u{2241}"), ("NotTildeEqual;", "\u{2244}"), ("NotTildeFullEqual;", "\u{2247}"), ("NotTildeTilde;", "\u{2249}"), ("NotVerticalBar;", "\u{2224}"), ("Nscr;", "\u{1D4A9}"), ("Ntilde", "\u{D1}"), ("Ntilde;", "\u{D1}"), ("Nu;", "\u{39D}"), ("OElig;", "\u{152}"), ("Oacute", "\u{D3}"), ("Oacute;", "\u{D3}"), ("Ocirc", "\u{D4}"), ("Ocirc;", "\u{D4}"), ("Ocy;", "\u{41E}"), ("Odblac;", "\u{150}"), ("Ofr;", "\u{1D512}"), ("Ograve", "\u{D2}"), ("Ograve;", "\u{D2}"), ("Omacr;", "\u{14C}"), ("Omega;", "\u{3A9}"), ("Omicron;", "\u{39F}"), ("Oopf;", "\u{1D546}"), ("OpenCurlyDoubleQuote;", "\u{201C}"), ("OpenCurlyQuote;", "\u{2018}"), ("Or;", "\u{2A54}"), ("Oscr;", "\u{1D4AA}"), ("Oslash", "\u{D8}"), ("Oslash;", "\u{D8}"), ("Otilde", "\u{D5}"), ("Otilde;", "\u{D5}"), ("Otimes;", "\u{2A37}"), ("Ouml", "\u{D6}"), ("Ouml;", "\u{D6}"), ("OverBar;", "\u{203E}"), ("OverBrace;", "\u{23DE}"), ("OverBracket;", "\u{23B4}"), ("OverParenthesis;", "\u{23DC}"), ("PartialD;", "\u{2202}"), ("Pcy;", "\u{41F}"), ("Pfr;", "\u{1D513}"), ("Phi;", "\u{3A6}"), ("Pi;", "\u{3A0}"), ("PlusMinus;", "\u{B1}"), ("Poincareplane;", "\u{210C}"), ("Popf;", "\u{2119}"), ("Pr;", "\u{2ABB}"), ("Precedes;", "\u{227A}"), ("PrecedesEqual;", "\u{2AAF}"), ("PrecedesSlantEqual;", "\u{227C}"), ("PrecedesTilde;", "\u{227E}"), ("Prime;", "\u{2033}"), ("Product;", "\u{220F}"), ("Proportion;", "\u{2237}"), ("Proportional;", "\u{221D}"), ("Pscr;", "\u{1D4AB}"), ("Psi;", "\u{3A8}"), ("QUOT", "\u{22}"), ("QUOT;", "\u{22}"), ("Qfr;", "\u{1D514}"), ("Qopf;", "\u{211A}"), ("Qscr;", "\u{1D4AC}"), ("RBarr;", "\u{2910}"), ("REG", "\u{AE}"), ("REG;", "\u{AE}"), ("Racute;", "\u{154}"), ("Rang;", "\u{27EB}"), ("Rarr;", "\u{21A0}"), ("Rarrtl;", "\u{2916}"), ("Rcaron;", "\u{158}"), ("Rcedil;", "\u{156}"), ("Rcy;", "\u{420}"), ("Re;", "\u{211C}"), ("ReverseElement;", "\u{220B}"), ("ReverseEquilibrium;", "\u{21CB}"), ("ReverseUpEquilibrium;", "\u{296F}"), ("Rfr;", "\u{211C}"), ("Rho;", "\u{3A1}"), ("RightAngleBracket;", "\u{27E9}"), ("RightArrow;", "\u{2192}"), ("RightArrowBar;", "\u{21E5}"), ("RightArrowLeftArrow;", "\u{21C4}"), ("RightCeiling;", "\u{2309}"), ("RightDoubleBracket;", "\u{27E7}"), ("RightDownTeeVector;", "\u{295D}"), ("RightDownVector;", "\u{21C2}"), ("RightDownVectorBar;", "\u{2955}"), ("RightFloor;", "\u{230B}"), ("RightTee;", "\u{22A2}"), ("RightTeeArrow;", "\u{21A6}"), ("RightTeeVector;", "\u{295B}"), ("RightTriangle;", "\u{22B3}"), ("RightTriangleBar;", "\u{29D0}"), ("RightTriangleEqual;", "\u{22B5}"), ("RightUpDownVector;", "\u{294F}"), ("RightUpTeeVector;", "\u{295C}"), ("RightUpVector;", "\u{21BE}"), ("RightUpVectorBar;", "\u{2954}"), ("RightVector;", "\u{21C0}"), ("RightVectorBar;", "\u{2953}"), ("Rightarrow;", "\u{21D2}"), ("Ropf;", "\u{211D}"), ("RoundImplies;", "\u{2970}"), ("Rrightarrow;", "\u{21DB}"), ("Rscr;", "\u{211B}"), ("Rsh;", "\u{21B1}"), ("RuleDelayed;", "\u{29F4}"), ("SHCHcy;", "\u{429}"), ("SHcy;", "\u{428}"), ("SOFTcy;", "\u{42C}"), ("Sacute;", "\u{15A}"), ("Sc;", "\u{2ABC}"), ("Scaron;", "\u{160}"), ("Scedil;", "\u{15E}"), ("Scirc;", "\u{15C}"), ("Scy;", "\u{421}"), ("Sfr;", "\u{1D516}"), ("ShortDownArrow;", "\u{2193}"), ("ShortLeftArrow;", "\u{2190}"), ("ShortRightArrow;", "\u{2192}"), ("ShortUpArrow;", "\u{2191}"), ("Sigma;", "\u{3A3}"), ("SmallCircle;", "\u{2218}"), ("Sopf;", "\u{1D54A}"), ("Sqrt;", "\u{221A}"), ("Square;", "\u{25A1}"), ("SquareIntersection;", "\u{2293}"), ("SquareSubset;", "\u{228F}"), ("SquareSubsetEqual;", "\u{2291}"), ("SquareSuperset;", "\u{2290}"), ("SquareSupersetEqual;", "\u{2292}"), ("SquareUnion;", "\u{2294}"), ("Sscr;", "\u{1D4AE}"), ("Star;", "\u{22C6}"), ("Sub;", "\u{22D0}"), ("Subset;", "\u{22D0}"), ("SubsetEqual;", "\u{2286}"), ("Succeeds;", "\u{227B}"), ("SucceedsEqual;", "\u{2AB0}"), ("SucceedsSlantEqual;", "\u{227D}"), ("SucceedsTilde;", "\u{227F}"), ("SuchThat;", "\u{220B}"), ("Sum;", "\u{2211}"), ("Sup;", "\u{22D1}"), ("Superset;", "\u{2283}"), ("SupersetEqual;", "\u{2287}"), ("Supset;", "\u{22D1}"), ("THORN", "\u{DE}"), ("THORN;", "\u{DE}"), ("TRADE;", "\u{2122}"), ("TSHcy;", "\u{40B}"), ("TScy;", "\u{426}"), ("Tab;", "\u{9}"), ("Tau;", "\u{3A4}"), ("Tcaron;", "\u{164}"), ("Tcedil;", "\u{162}"), ("Tcy;", "\u{422}"), ("Tfr;", "\u{1D517}"), ("Therefore;", "\u{2234}"), ("Theta;", "\u{398}"), ("ThickSpace;", "\u{205F}\u{200A}"), ("ThinSpace;", "\u{2009}"), ("Tilde;", "\u{223C}"), ("TildeEqual;", "\u{2243}"), ("TildeFullEqual;", "\u{2245}"), ("TildeTilde;", "\u{2248}"), ("Topf;", "\u{1D54B}"), ("TripleDot;", "\u{20DB}"), ("Tscr;", "\u{1D4AF}"), ("Tstrok;", "\u{166}"), ("Uacute", "\u{DA}"), ("Uacute;", "\u{DA}"), ("Uarr;", "\u{219F}"), ("Uarrocir;", "\u{2949}"), ("Ubrcy;", "\u{40E}"), ("Ubreve;", "\u{16C}"), ("Ucirc", "\u{DB}"), ("Ucirc;", "\u{DB}"), ("Ucy;", "\u{423}"), ("Udblac;", "\u{170}"), ("Ufr;", "\u{1D518}"), ("Ugrave", "\u{D9}"), ("Ugrave;", "\u{D9}"), ("Umacr;", "\u{16A}"), ("UnderBar;", "\u{5F}"), ("UnderBrace;", "\u{23DF}"), ("UnderBracket;", "\u{23B5}"), ("UnderParenthesis;", "\u{23DD}"), ("Union;", "\u{22C3}"), ("UnionPlus;", "\u{228E}"), ("Uogon;", "\u{172}"), ("Uopf;", "\u{1D54C}"), ("UpArrow;", "\u{2191}"), ("UpArrowBar;", "\u{2912}"), ("UpArrowDownArrow;", "\u{21C5}"), ("UpDownArrow;", "\u{2195}"), ("UpEquilibrium;", "\u{296E}"), ("UpTee;", "\u{22A5}"), ("UpTeeArrow;", "\u{21A5}"), ("Uparrow;", "\u{21D1}"), ("Updownarrow;", "\u{21D5}"), ("UpperLeftArrow;", "\u{2196}"), ("UpperRightArrow;", "\u{2197}"), ("Upsi;", "\u{3D2}"), ("Upsilon;", "\u{3A5}"), ("Uring;", "\u{16E}"), ("Uscr;", "\u{1D4B0}"), ("Utilde;", "\u{168}"), ("Uuml", "\u{DC}"), ("Uuml;", "\u{DC}"), ("VDash;", "\u{22AB}"), ("Vbar;", "\u{2AEB}"), ("Vcy;", "\u{412}"), ("Vdash;", "\u{22A9}"), ("Vdashl;", "\u{2AE6}"), ("Vee;", "\u{22C1}"), ("Verbar;", "\u{2016}"), ("Vert;", "\u{2016}"), ("VerticalBar;", "\u{2223}"), ("VerticalLine;", "\u{7C}"), ("VerticalSeparator;", "\u{2758}"), ("VerticalTilde;", "\u{2240}"), ("VeryThinSpace;", "\u{200A}"), ("Vfr;", "\u{1D519}"), ("Vopf;", "\u{1D54D}"), ("Vscr;", "\u{1D4B1}"), ("Vvdash;", "\u{22AA}"), ("Wcirc;", "\u{174}"), ("Wedge;", "\u{22C0}"), ("Wfr;", "\u{1D51A}"), ("Wopf;", "\u{1D54E}"), ("Wscr;", "\u{1D4B2}"), ("Xfr;", "\u{1D51B}"), ("Xi;", "\u{39E}"), ("Xopf;", "\u{1D54F}"), ("Xscr;", "\u{1D4B3}"), ("YAcy;", "\u{42F}"), ("YIcy;", "\u{407}"), ("YUcy;", "\u{42E}"), ("Yacute", "\u{DD}"), ("Yacute;", "\u{DD}"), ("Ycirc;", "\u{176}"), ("Ycy;", "\u{42B}"), ("Yfr;", "\u{1D51C}"), ("Yopf;", "\u{1D550}"), ("Yscr;", "\u{1D4B4}"), ("Yuml;", "\u{178}"), ("ZHcy;", "\u{416}"), ("Zacute;", "\u{179}"), ("Zcaron;", "\u{17D}"), ("Zcy;", "\u{417}"), ("Zdot;", "\u{17B}"), ("ZeroWidthSpace;", "\u{200B}"), ("Zeta;", "\u{396}"), ("Zfr;", "\u{2128}"), ("Zopf;", "\u{2124}"), ("Zscr;", "\u{1D4B5}"), ("aacute", "\u{E1}"), ("aacute;", "\u{E1}"), ("abreve;", "\u{103}"), ("ac;", "\u{223E}"), ("acE;", "\u{223E}\u{333}"), ("acd;", "\u{223F}"), ("acirc", "\u{E2}"), ("acirc;", "\u{E2}"), ("acute", "\u{B4}"), ("acute;", "\u{B4}"), ("acy;", "\u{430}"), ("aelig", "\u{E6}"), ("aelig;", "\u{E6}"), ("af;", "\u{2061}"), ("afr;", "\u{1D51E}"), ("agrave", "\u{E0}"), ("agrave;", "\u{E0}"), ("alefsym;", "\u{2135}"), ("aleph;", "\u{2135}"), ("alpha;", "\u{3B1}"), ("amacr;", "\u{101}"), ("amalg;", "\u{2A3F}"), ("amp", "\u{26}"), ("amp;", "\u{26}"), ("and;", "\u{2227}"), ("andand;", "\u{2A55}"), ("andd;", "\u{2A5C}"), ("andslope;", "\u{2A58}"), ("andv;", "\u{2A5A}"), ("ang;", "\u{2220}"), ("ange;", "\u{29A4}"), ("angle;", "\u{2220}"), ("angmsd;", "\u{2221}"), ("angmsdaa;", "\u{29A8}"), ("angmsdab;", "\u{29A9}"), ("angmsdac;", "\u{29AA}"), ("angmsdad;", "\u{29AB}"), ("angmsdae;", "\u{29AC}"), ("angmsdaf;", "\u{29AD}"), ("angmsdag;", "\u{29AE}"), ("angmsdah;", "\u{29AF}"), ("angrt;", "\u{221F}"), ("angrtvb;", "\u{22BE}"), ("angrtvbd;", "\u{299D}"), ("angsph;", "\u{2222}"), ("angst;", "\u{C5}"), ("angzarr;", "\u{237C}"), ("aogon;", "\u{105}"), ("aopf;", "\u{1D552}"), ("ap;", "\u{2248}"), ("apE;", "\u{2A70}"), ("apacir;", "\u{2A6F}"), ("ape;", "\u{224A}"), ("apid;", "\u{224B}"), ("apos;", "\u{27}"), ("approx;", "\u{2248}"), ("approxeq;", "\u{224A}"), ("aring", "\u{E5}"), ("aring;", "\u{E5}"), ("ascr;", "\u{1D4B6}"), ("ast;", "\u{2A}"), ("asymp;", "\u{2248}"), ("asympeq;", "\u{224D}"), ("atilde", "\u{E3}"), ("atilde;", "\u{E3}"), ("auml", "\u{E4}"), ("auml;", "\u{E4}"), ("awconint;", "\u{2233}"), ("awint;", "\u{2A11}"), ("bNot;", "\u{2AED}"), ("backcong;", "\u{224C}"), ("backepsilon;", "\u{3F6}"), ("backprime;", "\u{2035}"), ("backsim;", "\u{223D}"), ("backsimeq;", "\u{22CD}"), ("barvee;", "\u{22BD}"), ("barwed;", "\u{2305}"), ("barwedge;", "\u{2305}"), ("bbrk;", "\u{23B5}"), ("bbrktbrk;", "\u{23B6}"), ("bcong;", "\u{224C}"), ("bcy;", "\u{431}"), ("bdquo;", "\u{201E}"), ("becaus;", "\u{2235}"), ("because;", "\u{2235}"), ("bemptyv;", "\u{29B0}"), ("bepsi;", "\u{3F6}"), ("bernou;", "\u{212C}"), ("beta;", "\u{3B2}"), ("beth;", "\u{2136}"), ("between;", "\u{226C}"), ("bfr;", "\u{1D51F}"), ("bigcap;", "\u{22C2}"), ("bigcirc;", "\u{25EF}"), ("bigcup;", "\u{22C3}"), ("bigodot;", "\u{2A00}"), ("bigoplus;", "\u{2A01}"), ("bigotimes;", "\u{2A02}"), ("bigsqcup;", "\u{2A06}"), ("bigstar;", "\u{2605}"), ("bigtriangledown;", "\u{25BD}"), ("bigtriangleup;", "\u{25B3}"), ("biguplus;", "\u{2A04}"), ("bigvee;", "\u{22C1}"), ("bigwedge;", "\u{22C0}"), ("bkarow;", "\u{290D}"), ("blacklozenge;", "\u{29EB}"), ("blacksquare;", "\u{25AA}"), ("blacktriangle;", "\u{25B4}"), ("blacktriangledown;", "\u{25BE}"), ("blacktriangleleft;", "\u{25C2}"), ("blacktriangleright;", "\u{25B8}"), ("blank;", "\u{2423}"), ("blk12;", "\u{2592}"), ("blk14;", "\u{2591}"), ("blk34;", "\u{2593}"), ("block;", "\u{2588}"), ("bne;", "\u{3D}\u{20E5}"), ("bnequiv;", "\u{2261}\u{20E5}"), ("bnot;", "\u{2310}"), ("bopf;", "\u{1D553}"), ("bot;", "\u{22A5}"), ("bottom;", "\u{22A5}"), ("bowtie;", "\u{22C8}"), ("boxDL;", "\u{2557}"), ("boxDR;", "\u{2554}"), ("boxDl;", "\u{2556}"), ("boxDr;", "\u{2553}"), ("boxH;", "\u{2550}"), ("boxHD;", "\u{2566}"), ("boxHU;", "\u{2569}"), ("boxHd;", "\u{2564}"), ("boxHu;", "\u{2567}"), ("boxUL;", "\u{255D}"), ("boxUR;", "\u{255A}"), ("boxUl;", "\u{255C}"), ("boxUr;", "\u{2559}"), ("boxV;", "\u{2551}"), ("boxVH;", "\u{256C}"), ("boxVL;", "\u{2563}"), ("boxVR;", "\u{2560}"), ("boxVh;", "\u{256B}"), ("boxVl;", "\u{2562}"), ("boxVr;", "\u{255F}"), ("boxbox;", "\u{29C9}"), ("boxdL;", "\u{2555}"), ("boxdR;", "\u{2552}"), ("boxdl;", "\u{2510}"), ("boxdr;", "\u{250C}"), ("boxh;", "\u{2500}"), ("boxhD;", "\u{2565}"), ("boxhU;", "\u{2568}"), ("boxhd;", "\u{252C}"), ("boxhu;", "\u{2534}"), ("boxminus;", "\u{229F}"), ("boxplus;", "\u{229E}"), ("boxtimes;", "\u{22A0}"), ("boxuL;", "\u{255B}"), ("boxuR;", "\u{2558}"), ("boxul;", "\u{2518}"), ("boxur;", "\u{2514}"), ("boxv;", "\u{2502}"), ("boxvH;", "\u{256A}"), ("boxvL;", "\u{2561}"), ("boxvR;", "\u{255E}"), ("boxvh;", "\u{253C}"), ("boxvl;", "\u{2524}"), ("boxvr;", "\u{251C}"), ("bprime;", "\u{2035}"), ("breve;", "\u{2D8}"), ("brvbar", "\u{A6}"), ("brvbar;", "\u{A6}"), ("bscr;", "\u{1D4B7}"), ("bsemi;", "\u{204F}"), ("bsim;", "\u{223D}"), ("bsime;", "\u{22CD}"), ("bsol;", "\u{5C}"), ("bsolb;", "\u{29C5}"), ("bsolhsub;", "\u{27C8}"), ("bull;", "\u{2022}"), ("bullet;", "\u{2022}"), ("bump;", "\u{224E}"), ("bumpE;", "\u{2AAE}"), ("bumpe;", "\u{224F}"), ("bumpeq;", "\u{224F}"), ("cacute;", "\u{107}"), ("cap;", "\u{2229}"), ("capand;", "\u{2A44}"), ("capbrcup;", "\u{2A49}"), ("capcap;", "\u{2A4B}"), ("capcup;", "\u{2A47}"), ("capdot;", "\u{2A40}"), ("caps;", "\u{2229}\u{FE00}"), ("caret;", "\u{2041}"), ("caron;", "\u{2C7}"), ("ccaps;", "\u{2A4D}"), ("ccaron;", "\u{10D}"), ("ccedil", "\u{E7}"), ("ccedil;", "\u{E7}"), ("ccirc;", "\u{109}"), ("ccups;", "\u{2A4C}"), ("ccupssm;", "\u{2A50}"), ("cdot;", "\u{10B}"), ("cedil", "\u{B8}"), ("cedil;", "\u{B8}"), ("cemptyv;", "\u{29B2}"), ("cent", "\u{A2}"), ("cent;", "\u{A2}"), ("centerdot;", "\u{B7}"), ("cfr;", "\u{1D520}"), ("chcy;", "\u{447}"), ("check;", "\u{2713}"), ("checkmark;", "\u{2713}"), ("chi;", "\u{3C7}"), ("cir;", "\u{25CB}"), ("cirE;", "\u{29C3}"), ("circ;", "\u{2C6}"), ("circeq;", "\u{2257}"), ("circlearrowleft;", "\u{21BA}"), ("circlearrowright;", "\u{21BB}"), ("circledR;", "\u{AE}"), ("circledS;", "\u{24C8}"), ("circledast;", "\u{229B}"), ("circledcirc;", "\u{229A}"), ("circleddash;", "\u{229D}"), ("cire;", "\u{2257}"), ("cirfnint;", "\u{2A10}"), ("cirmid;", "\u{2AEF}"), ("cirscir;", "\u{29C2}"), ("clubs;", "\u{2663}"), ("clubsuit;", "\u{2663}"), ("colon;", "\u{3A}"), ("colone;", "\u{2254}"), ("coloneq;", "\u{2254}"), ("comma;", "\u{2C}"), ("commat;", "\u{40}"), ("comp;", "\u{2201}"), ("compfn;", "\u{2218}"), ("complement;", "\u{2201}"), ("complexes;", "\u{2102}"), ("cong;", "\u{2245}"), ("congdot;", "\u{2A6D}"), ("conint;", "\u{222E}"), ("copf;", "\u{1D554}"), ("coprod;", "\u{2210}"), ("copy", "\u{A9}"), ("copy;", "\u{A9}"), ("copysr;", "\u{2117}"), ("crarr;", "\u{21B5}"), ("cross;", "\u{2717}"), ("cscr;", "\u{1D4B8}"), ("csub;", "\u{2ACF}"), ("csube;", "\u{2AD1}"), ("csup;", "\u{2AD0}"), ("csupe;", "\u{2AD2}"), ("ctdot;", "\u{22EF}"), ("cudarrl;", "\u{2938}"), ("cudarrr;", "\u{2935}"), ("cuepr;", "\u{22DE}"), ("cuesc;", "\u{22DF}"), ("cularr;", "\u{21B6}"), ("cularrp;", "\u{293D}"), ("cup;", "\u{222A}"), ("cupbrcap;", "\u{2A48}"), ("cupcap;", "\u{2A46}"), ("cupcup;", "\u{2A4A}"), ("cupdot;", "\u{228D}"), ("cupor;", "\u{2A45}"), ("cups;", "\u{222A}\u{FE00}"), ("curarr;", "\u{21B7}"), ("curarrm;", "\u{293C}"), ("curlyeqprec;", "\u{22DE}"), ("curlyeqsucc;", "\u{22DF}"), ("curlyvee;", "\u{22CE}"), ("curlywedge;", "\u{22CF}"), ("curren", "\u{A4}"), ("curren;", "\u{A4}"), ("curvearrowleft;", "\u{21B6}"), ("curvearrowright;", "\u{21B7}"), ("cuvee;", "\u{22CE}"), ("cuwed;", "\u{22CF}"), ("cwconint;", "\u{2232}"), ("cwint;", "\u{2231}"), ("cylcty;", "\u{232D}"), ("dArr;", "\u{21D3}"), ("dHar;", "\u{2965}"), ("dagger;", "\u{2020}"), ("daleth;", "\u{2138}"), ("darr;", "\u{2193}"), ("dash;", "\u{2010}"), ("dashv;", "\u{22A3}"), ("dbkarow;", "\u{290F}"), ("dblac;", "\u{2DD}"), ("dcaron;", "\u{10F}"), ("dcy;", "\u{434}"), ("dd;", "\u{2146}"), ("ddagger;", "\u{2021}"), ("ddarr;", "\u{21CA}"), ("ddotseq;", "\u{2A77}"), ("deg", "\u{B0}"), ("deg;", "\u{B0}"), ("delta;", "\u{3B4}"), ("demptyv;", "\u{29B1}"), ("dfisht;", "\u{297F}"), ("dfr;", "\u{1D521}"), ("dharl;", "\u{21C3}"), ("dharr;", "\u{21C2}"), ("diam;", "\u{22C4}"), ("diamond;", "\u{22C4}"), ("diamondsuit;", "\u{2666}"), ("diams;", "\u{2666}"), ("die;", "\u{A8}"), ("digamma;", "\u{3DD}"), ("disin;", "\u{22F2}"), ("div;", "\u{F7}"), ("divide", "\u{F7}"), ("divide;", "\u{F7}"), ("divideontimes;", "\u{22C7}"), ("divonx;", "\u{22C7}"), ("djcy;", "\u{452}"), ("dlcorn;", "\u{231E}"), ("dlcrop;", "\u{230D}"), ("dollar;", "\u{24}"), ("dopf;", "\u{1D555}"), ("dot;", "\u{2D9}"), ("doteq;", "\u{2250}"), ("doteqdot;", "\u{2251}"), ("dotminus;", "\u{2238}"), ("dotplus;", "\u{2214}"), ("dotsquare;", "\u{22A1}"), ("doublebarwedge;", "\u{2306}"), ("downarrow;", "\u{2193}"), ("downdownarrows;", "\u{21CA}"), ("downharpoonleft;", "\u{21C3}"), ("downharpoonright;", "\u{21C2}"), ("drbkarow;", "\u{2910}"), ("drcorn;", "\u{231F}"), ("drcrop;", "\u{230C}"), ("dscr;", "\u{1D4B9}"), ("dscy;", "\u{455}"), ("dsol;", "\u{29F6}"), ("dstrok;", "\u{111}"), ("dtdot;", "\u{22F1}"), ("dtri;", "\u{25BF}"), ("dtrif;", "\u{25BE}"), ("duarr;", "\u{21F5}"), ("duhar;", "\u{296F}"), ("dwangle;", "\u{29A6}"), ("dzcy;", "\u{45F}"), ("dzigrarr;", "\u{27FF}"), ("eDDot;", "\u{2A77}"), ("eDot;", "\u{2251}"), ("eacute", "\u{E9}"), ("eacute;", "\u{E9}"), ("easter;", "\u{2A6E}"), ("ecaron;", "\u{11B}"), ("ecir;", "\u{2256}"), ("ecirc", "\u{EA}"), ("ecirc;", "\u{EA}"), ("ecolon;", "\u{2255}"), ("ecy;", "\u{44D}"), ("edot;", "\u{117}"), ("ee;", "\u{2147}"), ("efDot;", "\u{2252}"), ("efr;", "\u{1D522}"), ("eg;", "\u{2A9A}"), ("egrave", "\u{E8}"), ("egrave;", "\u{E8}"), ("egs;", "\u{2A96}"), ("egsdot;", "\u{2A98}"), ("el;", "\u{2A99}"), ("elinters;", "\u{23E7}"), ("ell;", "\u{2113}"), ("els;", "\u{2A95}"), ("elsdot;", "\u{2A97}"), ("emacr;", "\u{113}"), ("empty;", "\u{2205}"), ("emptyset;", "\u{2205}"), ("emptyv;", "\u{2205}"), ("emsp13;", "\u{2004}"), ("emsp14;", "\u{2005}"), ("emsp;", "\u{2003}"), ("eng;", "\u{14B}"), ("ensp;", "\u{2002}"), ("eogon;", "\u{119}"), ("eopf;", "\u{1D556}"), ("epar;", "\u{22D5}"), ("eparsl;", "\u{29E3}"), ("eplus;", "\u{2A71}"), ("epsi;", "\u{3B5}"), ("epsilon;", "\u{3B5}"), ("epsiv;", "\u{3F5}"), ("eqcirc;", "\u{2256}"), ("eqcolon;", "\u{2255}"), ("eqsim;", "\u{2242}"), ("eqslantgtr;", "\u{2A96}"), ("eqslantless;", "\u{2A95}"), ("equals;", "\u{3D}"), ("equest;", "\u{225F}"), ("equiv;", "\u{2261}"), ("equivDD;", "\u{2A78}"), ("eqvparsl;", "\u{29E5}"), ("erDot;", "\u{2253}"), ("erarr;", "\u{2971}"), ("escr;", "\u{212F}"), ("esdot;", "\u{2250}"), ("esim;", "\u{2242}"), ("eta;", "\u{3B7}"), ("eth", "\u{F0}"), ("eth;", "\u{F0}"), ("euml", "\u{EB}"), ("euml;", "\u{EB}"), ("euro;", "\u{20AC}"), ("excl;", "\u{21}"), ("exist;", "\u{2203}"), ("expectation;", "\u{2130}"), ("exponentiale;", "\u{2147}"), ("fallingdotseq;", "\u{2252}"), ("fcy;", "\u{444}"), ("female;", "\u{2640}"), ("ffilig;", "\u{FB03}"), ("fflig;", "\u{FB00}"), ("ffllig;", "\u{FB04}"), ("ffr;", "\u{1D523}"), ("filig;", "\u{FB01}"), ("fjlig;", "\u{66}\u{6A}"), ("flat;", "\u{266D}"), ("fllig;", "\u{FB02}"), ("fltns;", "\u{25B1}"), ("fnof;", "\u{192}"), ("fopf;", "\u{1D557}"), ("forall;", "\u{2200}"), ("fork;", "\u{22D4}"), ("forkv;", "\u{2AD9}"), ("fpartint;", "\u{2A0D}"), ("frac12", "\u{BD}"), ("frac12;", "\u{BD}"), ("frac13;", "\u{2153}"), ("frac14", "\u{BC}"), ("frac14;", "\u{BC}"), ("frac15;", "\u{2155}"), ("frac16;", "\u{2159}"), ("frac18;", "\u{215B}"), ("frac23;", "\u{2154}"), ("frac25;", "\u{2156}"), ("frac34", "\u{BE}"), ("frac34;", "\u{BE}"), ("frac35;", "\u{2157}"), ("frac38;", "\u{215C}"), ("frac45;", "\u{2158}"), ("frac56;", "\u{215A}"), ("frac58;", "\u{215D}"), ("frac78;", "\u{215E}"), ("frasl;", "\u{2044}"), ("frown;", "\u{2322}"), ("fscr;", "\u{1D4BB}"), ("gE;", "\u{2267}"), ("gEl;", "\u{2A8C}"), ("gacute;", "\u{1F5}"), ("gamma;", "\u{3B3}"), ("gammad;", "\u{3DD}"), ("gap;", "\u{2A86}"), ("gbreve;", "\u{11F}"), ("gcirc;", "\u{11D}"), ("gcy;", "\u{433}"), ("gdot;", "\u{121}"), ("ge;", "\u{2265}"), ("gel;", "\u{22DB}"), ("geq;", "\u{2265}"), ("geqq;", "\u{2267}"), ("geqslant;", "\u{2A7E}"), ("ges;", "\u{2A7E}"), ("gescc;", "\u{2AA9}"), ("gesdot;", "\u{2A80}"), ("gesdoto;", "\u{2A82}"), ("gesdotol;", "\u{2A84}"), ("gesl;", "\u{22DB}\u{FE00}"), ("gesles;", "\u{2A94}"), ("gfr;", "\u{1D524}"), ("gg;", "\u{226B}"), ("ggg;", "\u{22D9}"), ("gimel;", "\u{2137}"), ("gjcy;", "\u{453}"), ("gl;", "\u{2277}"), ("glE;", "\u{2A92}"), ("gla;", "\u{2AA5}"), ("glj;", "\u{2AA4}"), ("gnE;", "\u{2269}"), ("gnap;", "\u{2A8A}"), ("gnapprox;", "\u{2A8A}"), ("gne;", "\u{2A88}"), ("gneq;", "\u{2A88}"), ("gneqq;", "\u{2269}"), ("gnsim;", "\u{22E7}"), ("gopf;", "\u{1D558}"), ("grave;", "\u{60}"), ("gscr;", "\u{210A}"), ("gsim;", "\u{2273}"), ("gsime;", "\u{2A8E}"), ("gsiml;", "\u{2A90}"), ("gt", "\u{3E}"), ("gt;", "\u{3E}"), ("gtcc;", "\u{2AA7}"), ("gtcir;", "\u{2A7A}"), ("gtdot;", "\u{22D7}"), ("gtlPar;", "\u{2995}"), ("gtquest;", "\u{2A7C}"), ("gtrapprox;", "\u{2A86}"), ("gtrarr;", "\u{2978}"), ("gtrdot;", "\u{22D7}"), ("gtreqless;", "\u{22DB}"), ("gtreqqless;", "\u{2A8C}"), ("gtrless;", "\u{2277}"), ("gtrsim;", "\u{2273}"), ("gvertneqq;", "\u{2269}\u{FE00}"), ("gvnE;", "\u{2269}\u{FE00}"), ("hArr;", "\u{21D4}"), ("hairsp;", "\u{200A}"), ("half;", "\u{BD}"), ("hamilt;", "\u{210B}"), ("hardcy;", "\u{44A}"), ("harr;", "\u{2194}"), ("harrcir;", "\u{2948}"), ("harrw;", "\u{21AD}"), ("hbar;", "\u{210F}"), ("hcirc;", "\u{125}"), ("hearts;", "\u{2665}"), ("heartsuit;", "\u{2665}"), ("hellip;", "\u{2026}"), ("hercon;", "\u{22B9}"), ("hfr;", "\u{1D525}"), ("hksearow;", "\u{2925}"), ("hkswarow;", "\u{2926}"), ("hoarr;", "\u{21FF}"), ("homtht;", "\u{223B}"), ("hookleftarrow;", "\u{21A9}"), ("hookrightarrow;", "\u{21AA}"), ("hopf;", "\u{1D559}"), ("horbar;", "\u{2015}"), ("hscr;", "\u{1D4BD}"), ("hslash;", "\u{210F}"), ("hstrok;", "\u{127}"), ("hybull;", "\u{2043}"), ("hyphen;", "\u{2010}"), ("iacute", "\u{ED}"), ("iacute;", "\u{ED}"), ("ic;", "\u{2063}"), ("icirc", "\u{EE}"), ("icirc;", "\u{EE}"), ("icy;", "\u{438}"), ("iecy;", "\u{435}"), ("iexcl", "\u{A1}"), ("iexcl;", "\u{A1}"), ("iff;", "\u{21D4}"), ("ifr;", "\u{1D526}"), ("igrave", "\u{EC}"), ("igrave;", "\u{EC}"), ("ii;", "\u{2148}"), ("iiiint;", "\u{2A0C}"), ("iiint;", "\u{222D}"), ("iinfin;", "\u{29DC}"), ("iiota;", "\u{2129}"), ("ijlig;", "\u{133}"), ("imacr;", "\u{12B}"), ("image;", "\u{2111}"), ("imagline;", "\u{2110}"), ("imagpart;", "\u{2111}"), ("imath;", "\u{131}"), ("imof;", "\u{22B7}"), ("imped;", "\u{1B5}"), ("in;", "\u{2208}"), ("incare;", "\u{2105}"), ("infin;", "\u{221E}"), ("infintie;", "\u{29DD}"), ("inodot;", "\u{131}"), ("int;", "\u{222B}"), ("intcal;", "\u{22BA}"), ("integers;", "\u{2124}"), ("intercal;", "\u{22BA}"), ("intlarhk;", "\u{2A17}"), ("intprod;", "\u{2A3C}"), ("iocy;", "\u{451}"), ("iogon;", "\u{12F}"), ("iopf;", "\u{1D55A}"), ("iota;", "\u{3B9}"), ("iprod;", "\u{2A3C}"), ("iquest", "\u{BF}"), ("iquest;", "\u{BF}"), ("iscr;", "\u{1D4BE}"), ("isin;", "\u{2208}"), ("isinE;", "\u{22F9}"), ("isindot;", "\u{22F5}"), ("isins;", "\u{22F4}"), ("isinsv;", "\u{22F3}"), ("isinv;", "\u{2208}"), ("it;", "\u{2062}"), ("itilde;", "\u{129}"), ("iukcy;", "\u{456}"), ("iuml", "\u{EF}"), ("iuml;", "\u{EF}"), ("jcirc;", "\u{135}"), ("jcy;", "\u{439}"), ("jfr;", "\u{1D527}"), ("jmath;", "\u{237}"), ("jopf;", "\u{1D55B}"), ("jscr;", "\u{1D4BF}"), ("jsercy;", "\u{458}"), ("jukcy;", "\u{454}"), ("kappa;", "\u{3BA}"), ("kappav;", "\u{3F0}"), ("kcedil;", "\u{137}"), ("kcy;", "\u{43A}"), ("kfr;", "\u{1D528}"), ("kgreen;", "\u{138}"), ("khcy;", "\u{445}"), ("kjcy;", "\u{45C}"), ("kopf;", "\u{1D55C}"), ("kscr;", "\u{1D4C0}"), ("lAarr;", "\u{21DA}"), ("lArr;", "\u{21D0}"), ("lAtail;", "\u{291B}"), ("lBarr;", "\u{290E}"), ("lE;", "\u{2266}"), ("lEg;", "\u{2A8B}"), ("lHar;", "\u{2962}"), ("lacute;", "\u{13A}"), ("laemptyv;", "\u{29B4}"), ("lagran;", "\u{2112}"), ("lambda;", "\u{3BB}"), ("lang;", "\u{27E8}"), ("langd;", "\u{2991}"), ("langle;", "\u{27E8}"), ("lap;", "\u{2A85}"), ("laquo", "\u{AB}"), ("laquo;", "\u{AB}"), ("larr;", "\u{2190}"), ("larrb;", "\u{21E4}"), ("larrbfs;", "\u{291F}"), ("larrfs;", "\u{291D}"), ("larrhk;", "\u{21A9}"), ("larrlp;", "\u{21AB}"), ("larrpl;", "\u{2939}"), ("larrsim;", "\u{2973}"), ("larrtl;", "\u{21A2}"), ("lat;", "\u{2AAB}"), ("latail;", "\u{2919}"), ("late;", "\u{2AAD}"), ("lates;", "\u{2AAD}\u{FE00}"), ("lbarr;", "\u{290C}"), ("lbbrk;", "\u{2772}"), ("lbrace;", "\u{7B}"), ("lbrack;", "\u{5B}"), ("lbrke;", "\u{298B}"), ("lbrksld;", "\u{298F}"), ("lbrkslu;", "\u{298D}"), ("lcaron;", "\u{13E}"), ("lcedil;", "\u{13C}"), ("lceil;", "\u{2308}"), ("lcub;", "\u{7B}"), ("lcy;", "\u{43B}"), ("ldca;", "\u{2936}"), ("ldquo;", "\u{201C}"), ("ldquor;", "\u{201E}"), ("ldrdhar;", "\u{2967}"), ("ldrushar;", "\u{294B}"), ("ldsh;", "\u{21B2}"), ("le;", "\u{2264}"), ("leftarrow;", "\u{2190}"), ("leftarrowtail;", "\u{21A2}"), ("leftharpoondown;", "\u{21BD}"), ("leftharpoonup;", "\u{21BC}"), ("leftleftarrows;", "\u{21C7}"), ("leftrightarrow;", "\u{2194}"), ("leftrightarrows;", "\u{21C6}"), ("leftrightharpoons;", "\u{21CB}"), ("leftrightsquigarrow;", "\u{21AD}"), ("leftthreetimes;", "\u{22CB}"), ("leg;", "\u{22DA}"), ("leq;", "\u{2264}"), ("leqq;", "\u{2266}"), ("leqslant;", "\u{2A7D}"), ("les;", "\u{2A7D}"), ("lescc;", "\u{2AA8}"), ("lesdot;", "\u{2A7F}"), ("lesdoto;", "\u{2A81}"), ("lesdotor;", "\u{2A83}"), ("lesg;", "\u{22DA}\u{FE00}"), ("lesges;", "\u{2A93}"), ("lessapprox;", "\u{2A85}"), ("lessdot;", "\u{22D6}"), ("lesseqgtr;", "\u{22DA}"), ("lesseqqgtr;", "\u{2A8B}"), ("lessgtr;", "\u{2276}"), ("lesssim;", "\u{2272}"), ("lfisht;", "\u{297C}"), ("lfloor;", "\u{230A}"), ("lfr;", "\u{1D529}"), ("lg;", "\u{2276}"), ("lgE;", "\u{2A91}"), ("lhard;", "\u{21BD}"), ("lharu;", "\u{21BC}"), ("lharul;", "\u{296A}"), ("lhblk;", "\u{2584}"), ("ljcy;", "\u{459}"), ("ll;", "\u{226A}"), ("llarr;", "\u{21C7}"), ("llcorner;", "\u{231E}"), ("llhard;", "\u{296B}"), ("lltri;", "\u{25FA}"), ("lmidot;", "\u{140}"), ("lmoust;", "\u{23B0}"), ("lmoustache;", "\u{23B0}"), ("lnE;", "\u{2268}"), ("lnap;", "\u{2A89}"), ("lnapprox;", "\u{2A89}"), ("lne;", "\u{2A87}"), ("lneq;", "\u{2A87}"), ("lneqq;", "\u{2268}"), ("lnsim;", "\u{22E6}"), ("loang;", "\u{27EC}"), ("loarr;", "\u{21FD}"), ("lobrk;", "\u{27E6}"), ("longleftarrow;", "\u{27F5}"), ("longleftrightarrow;", "\u{27F7}"), ("longmapsto;", "\u{27FC}"), ("longrightarrow;", "\u{27F6}"), ("looparrowleft;", "\u{21AB}"), ("looparrowright;", "\u{21AC}"), ("lopar;", "\u{2985}"), ("lopf;", "\u{1D55D}"), ("loplus;", "\u{2A2D}"), ("lotimes;", "\u{2A34}"), ("lowast;", "\u{2217}"), ("lowbar;", "\u{5F}"), ("loz;", "\u{25CA}"), ("lozenge;", "\u{25CA}"), ("lozf;", "\u{29EB}"), ("lpar;", "\u{28}"), ("lparlt;", "\u{2993}"), ("lrarr;", "\u{21C6}"), ("lrcorner;", "\u{231F}"), ("lrhar;", "\u{21CB}"), ("lrhard;", "\u{296D}"), ("lrm;", "\u{200E}"), ("lrtri;", "\u{22BF}"), ("lsaquo;", "\u{2039}"), ("lscr;", "\u{1D4C1}"), ("lsh;", "\u{21B0}"), ("lsim;", "\u{2272}"), ("lsime;", "\u{2A8D}"), ("lsimg;", "\u{2A8F}"), ("lsqb;", "\u{5B}"), ("lsquo;", "\u{2018}"), ("lsquor;", "\u{201A}"), ("lstrok;", "\u{142}"), ("lt", "\u{3C}"), ("lt;", "\u{3C}"), ("ltcc;", "\u{2AA6}"), ("ltcir;", "\u{2A79}"), ("ltdot;", "\u{22D6}"), ("lthree;", "\u{22CB}"), ("ltimes;", "\u{22C9}"), ("ltlarr;", "\u{2976}"), ("ltquest;", "\u{2A7B}"), ("ltrPar;", "\u{2996}"), ("ltri;", "\u{25C3}"), ("ltrie;", "\u{22B4}"), ("ltrif;", "\u{25C2}"), ("lurdshar;", "\u{294A}"), ("luruhar;", "\u{2966}"), ("lvertneqq;", "\u{2268}\u{FE00}"), ("lvnE;", "\u{2268}\u{FE00}"), ("mDDot;", "\u{223A}"), ("macr", "\u{AF}"), ("macr;", "\u{AF}"), ("male;", "\u{2642}"), ("malt;", "\u{2720}"), ("maltese;", "\u{2720}"), ("map;", "\u{21A6}"), ("mapsto;", "\u{21A6}"), ("mapstodown;", "\u{21A7}"), ("mapstoleft;", "\u{21A4}"), ("mapstoup;", "\u{21A5}"), ("marker;", "\u{25AE}"), ("mcomma;", "\u{2A29}"), ("mcy;", "\u{43C}"), ("mdash;", "\u{2014}"), ("measuredangle;", "\u{2221}"), ("mfr;", "\u{1D52A}"), ("mho;", "\u{2127}"), ("micro", "\u{B5}"), ("micro;", "\u{B5}"), ("mid;", "\u{2223}"), ("midast;", "\u{2A}"), ("midcir;", "\u{2AF0}"), ("middot", "\u{B7}"), ("middot;", "\u{B7}"), ("minus;", "\u{2212}"), ("minusb;", "\u{229F}"), ("minusd;", "\u{2238}"), ("minusdu;", "\u{2A2A}"), ("mlcp;", "\u{2ADB}"), ("mldr;", "\u{2026}"), ("mnplus;", "\u{2213}"), ("models;", "\u{22A7}"), ("mopf;", "\u{1D55E}"), ("mp;", "\u{2213}"), ("mscr;", "\u{1D4C2}"), ("mstpos;", "\u{223E}"), ("mu;", "\u{3BC}"), ("multimap;", "\u{22B8}"), ("mumap;", "\u{22B8}"), ("nGg;", "\u{22D9}\u{338}"), ("nGt;", "\u{226B}\u{20D2}"), ("nGtv;", "\u{226B}\u{338}"), ("nLeftarrow;", "\u{21CD}"), ("nLeftrightarrow;", "\u{21CE}"), ("nLl;", "\u{22D8}\u{338}"), ("nLt;", "\u{226A}\u{20D2}"), ("nLtv;", "\u{226A}\u{338}"), ("nRightarrow;", "\u{21CF}"), ("nVDash;", "\u{22AF}"), ("nVdash;", "\u{22AE}"), ("nabla;", "\u{2207}"), ("nacute;", "\u{144}"), ("nang;", "\u{2220}\u{20D2}"), ("nap;", "\u{2249}"), ("napE;", "\u{2A70}\u{338}"), ("napid;", "\u{224B}\u{338}"), ("napos;", "\u{149}"), ("napprox;", "\u{2249}"), ("natur;", "\u{266E}"), ("natural;", "\u{266E}"), ("naturals;", "\u{2115}"), ("nbsp", "\u{A0}"), ("nbsp;", "\u{A0}"), ("nbump;", "\u{224E}\u{338}"), ("nbumpe;", "\u{224F}\u{338}"), ("ncap;", "\u{2A43}"), ("ncaron;", "\u{148}"), ("ncedil;", "\u{146}"), ("ncong;", "\u{2247}"), ("ncongdot;", "\u{2A6D}\u{338}"), ("ncup;", "\u{2A42}"), ("ncy;", "\u{43D}"), ("ndash;", "\u{2013}"), ("ne;", "\u{2260}"), ("neArr;", "\u{21D7}"), ("nearhk;", "\u{2924}"), ("nearr;", "\u{2197}"), ("nearrow;", "\u{2197}"), ("nedot;", "\u{2250}\u{338}"), ("nequiv;", "\u{2262}"), ("nesear;", "\u{2928}"), ("nesim;", "\u{2242}\u{338}"), ("nexist;", "\u{2204}"), ("nexists;", "\u{2204}"), ("nfr;", "\u{1D52B}"), ("ngE;", "\u{2267}\u{338}"), ("nge;", "\u{2271}"), ("ngeq;", "\u{2271}"), ("ngeqq;", "\u{2267}\u{338}"), ("ngeqslant;", "\u{2A7E}\u{338}"), ("nges;", "\u{2A7E}\u{338}"), ("ngsim;", "\u{2275}"), ("ngt;", "\u{226F}"), ("ngtr;", "\u{226F}"), ("nhArr;", "\u{21CE}"), ("nharr;", "\u{21AE}"), ("nhpar;", "\u{2AF2}"), ("ni;", "\u{220B}"), ("nis;", "\u{22FC}"), ("nisd;", "\u{22FA}"), ("niv;", "\u{220B}"), ("njcy;", "\u{45A}"), ("nlArr;", "\u{21CD}"), ("nlE;", "\u{2266}\u{338}"), ("nlarr;", "\u{219A}"), ("nldr;", "\u{2025}"), ("nle;", "\u{2270}"), ("nleftarrow;", "\u{219A}"), ("nleftrightarrow;", "\u{21AE}"), ("nleq;", "\u{2270}"), ("nleqq;", "\u{2266}\u{338}"), ("nleqslant;", "\u{2A7D}\u{338}"), ("nles;", "\u{2A7D}\u{338}"), ("nless;", "\u{226E}"), ("nlsim;", "\u{2274}"), ("nlt;", "\u{226E}"), ("nltri;", "\u{22EA}"), ("nltrie;", "\u{22EC}"), ("nmid;", "\u{2224}"), ("nopf;", "\u{1D55F}"), ("not", "\u{AC}"), ("not;", "\u{AC}"), ("notin;", "\u{2209}"), ("notinE;", "\u{22F9}\u{338}"), ("notindot;", "\u{22F5}\u{338}"), ("notinva;", "\u{2209}"), ("notinvb;", "\u{22F7}"), ("notinvc;", "\u{22F6}"), ("notni;", "\u{220C}"), ("notniva;", "\u{220C}"), ("notnivb;", "\u{22FE}"), ("notnivc;", "\u{22FD}"), ("npar;", "\u{2226}"), ("nparallel;", "\u{2226}"), ("nparsl;", "\u{2AFD}\u{20E5}"), ("npart;", "\u{2202}\u{338}"), ("npolint;", "\u{2A14}"), ("npr;", "\u{2280}"), ("nprcue;", "\u{22E0}"), ("npre;", "\u{2AAF}\u{338}"), ("nprec;", "\u{2280}"), ("npreceq;", "\u{2AAF}\u{338}"), ("nrArr;", "\u{21CF}"), ("nrarr;", "\u{219B}"), ("nrarrc;", "\u{2933}\u{338}"), ("nrarrw;", "\u{219D}\u{338}"), ("nrightarrow;", "\u{219B}"), ("nrtri;", "\u{22EB}"), ("nrtrie;", "\u{22ED}"), ("nsc;", "\u{2281}"), ("nsccue;", "\u{22E1}"), ("nsce;", "\u{2AB0}\u{338}"), ("nscr;", "\u{1D4C3}"), ("nshortmid;", "\u{2224}"), ("nshortparallel;", "\u{2226}"), ("nsim;", "\u{2241}"), ("nsime;", "\u{2244}"), ("nsimeq;", "\u{2244}"), ("nsmid;", "\u{2224}"), ("nspar;", "\u{2226}"), ("nsqsube;", "\u{22E2}"), ("nsqsupe;", "\u{22E3}"), ("nsub;", "\u{2284}"), ("nsubE;", "\u{2AC5}\u{338}"), ("nsube;", "\u{2288}"), ("nsubset;", "\u{2282}\u{20D2}"), ("nsubseteq;", "\u{2288}"), ("nsubseteqq;", "\u{2AC5}\u{338}"), ("nsucc;", "\u{2281}"), ("nsucceq;", "\u{2AB0}\u{338}"), ("nsup;", "\u{2285}"), ("nsupE;", "\u{2AC6}\u{338}"), ("nsupe;", "\u{2289}"), ("nsupset;", "\u{2283}\u{20D2}"), ("nsupseteq;", "\u{2289}"), ("nsupseteqq;", "\u{2AC6}\u{338}"), ("ntgl;", "\u{2279}"), ("ntilde", "\u{F1}"), ("ntilde;", "\u{F1}"), ("ntlg;", "\u{2278}"), ("ntriangleleft;", "\u{22EA}"), ("ntrianglelefteq;", "\u{22EC}"), ("ntriangleright;", "\u{22EB}"), ("ntrianglerighteq;", "\u{22ED}"), ("nu;", "\u{3BD}"), ("num;", "\u{23}"), ("numero;", "\u{2116}"), ("numsp;", "\u{2007}"), ("nvDash;", "\u{22AD}"), ("nvHarr;", "\u{2904}"), ("nvap;", "\u{224D}\u{20D2}"), ("nvdash;", "\u{22AC}"), ("nvge;", "\u{2265}\u{20D2}"), ("nvgt;", "\u{3E}\u{20D2}"), ("nvinfin;", "\u{29DE}"), ("nvlArr;", "\u{2902}"), ("nvle;", "\u{2264}\u{20D2}"), ("nvlt;", "\u{3C}\u{20D2}"), ("nvltrie;", "\u{22B4}\u{20D2}"), ("nvrArr;", "\u{2903}"), ("nvrtrie;", "\u{22B5}\u{20D2}"), ("nvsim;", "\u{223C}\u{20D2}"), ("nwArr;", "\u{21D6}"), ("nwarhk;", "\u{2923}"), ("nwarr;", "\u{2196}"), ("nwarrow;", "\u{2196}"), ("nwnear;", "\u{2927}"), ("oS;", "\u{24C8}"), ("oacute", "\u{F3}"), ("oacute;", "\u{F3}"), ("oast;", "\u{229B}"), ("ocir;", "\u{229A}"), ("ocirc", "\u{F4}"), ("ocirc;", "\u{F4}"), ("ocy;", "\u{43E}"), ("odash;", "\u{229D}"), ("odblac;", "\u{151}"), ("odiv;", "\u{2A38}"), ("odot;", "\u{2299}"), ("odsold;", "\u{29BC}"), ("oelig;", "\u{153}"), ("ofcir;", "\u{29BF}"), ("ofr;", "\u{1D52C}"), ("ogon;", "\u{2DB}"), ("ograve", "\u{F2}"), ("ograve;", "\u{F2}"), ("ogt;", "\u{29C1}"), ("ohbar;", "\u{29B5}"), ("ohm;", "\u{3A9}"), ("oint;", "\u{222E}"), ("olarr;", "\u{21BA}"), ("olcir;", "\u{29BE}"), ("olcross;", "\u{29BB}"), ("oline;", "\u{203E}"), ("olt;", "\u{29C0}"), ("omacr;", "\u{14D}"), ("omega;", "\u{3C9}"), ("omicron;", "\u{3BF}"), ("omid;", "\u{29B6}"), ("ominus;", "\u{2296}"), ("oopf;", "\u{1D560}"), ("opar;", "\u{29B7}"), ("operp;", "\u{29B9}"), ("oplus;", "\u{2295}"), ("or;", "\u{2228}"), ("orarr;", "\u{21BB}"), ("ord;", "\u{2A5D}"), ("order;", "\u{2134}"), ("orderof;", "\u{2134}"), ("ordf", "\u{AA}"), ("ordf;", "\u{AA}"), ("ordm", "\u{BA}"), ("ordm;", "\u{BA}"), ("origof;", "\u{22B6}"), ("oror;", "\u{2A56}"), ("orslope;", "\u{2A57}"), ("orv;", "\u{2A5B}"), ("oscr;", "\u{2134}"), ("oslash", "\u{F8}"), ("oslash;", "\u{F8}"), ("osol;", "\u{2298}"), ("otilde", "\u{F5}"), ("otilde;", "\u{F5}"), ("otimes;", "\u{2297}"), ("otimesas;", "\u{2A36}"), ("ouml", "\u{F6}"), ("ouml;", "\u{F6}"), ("ovbar;", "\u{233D}"), ("par;", "\u{2225}"), ("para", "\u{B6}"), ("para;", "\u{B6}"), ("parallel;", "\u{2225}"), ("parsim;", "\u{2AF3}"), ("parsl;", "\u{2AFD}"), ("part;", "\u{2202}"), ("pcy;", "\u{43F}"), ("percnt;", "\u{25}"), ("period;", "\u{2E}"), ("permil;", "\u{2030}"), ("perp;", "\u{22A5}"), ("pertenk;", "\u{2031}"), ("pfr;", "\u{1D52D}"), ("phi;", "\u{3C6}"), ("phiv;", "\u{3D5}"), ("phmmat;", "\u{2133}"), ("phone;", "\u{260E}"), ("pi;", "\u{3C0}"), ("pitchfork;", "\u{22D4}"), ("piv;", "\u{3D6}"), ("planck;", "\u{210F}"), ("planckh;", "\u{210E}"), ("plankv;", "\u{210F}"), ("plus;", "\u{2B}"), ("plusacir;", "\u{2A23}"), ("plusb;", "\u{229E}"), ("pluscir;", "\u{2A22}"), ("plusdo;", "\u{2214}"), ("plusdu;", "\u{2A25}"), ("pluse;", "\u{2A72}"), ("plusmn", "\u{B1}"), ("plusmn;", "\u{B1}"), ("plussim;", "\u{2A26}"), ("plustwo;", "\u{2A27}"), ("pm;", "\u{B1}"), ("pointint;", "\u{2A15}"), ("popf;", "\u{1D561}"), ("pound", "\u{A3}"), ("pound;", "\u{A3}"), ("pr;", "\u{227A}"), ("prE;", "\u{2AB3}"), ("prap;", "\u{2AB7}"), ("prcue;", "\u{227C}"), ("pre;", "\u{2AAF}"), ("prec;", "\u{227A}"), ("precapprox;", "\u{2AB7}"), ("preccurlyeq;", "\u{227C}"), ("preceq;", "\u{2AAF}"), ("precnapprox;", "\u{2AB9}"), ("precneqq;", "\u{2AB5}"), ("precnsim;", "\u{22E8}"), ("precsim;", "\u{227E}"), ("prime;", "\u{2032}"), ("primes;", "\u{2119}"), ("prnE;", "\u{2AB5}"), ("prnap;", "\u{2AB9}"), ("prnsim;", "\u{22E8}"), ("prod;", "\u{220F}"), ("profalar;", "\u{232E}"), ("profline;", "\u{2312}"), ("profsurf;", "\u{2313}"), ("prop;", "\u{221D}"), ("propto;", "\u{221D}"), ("prsim;", "\u{227E}"), ("prurel;", "\u{22B0}"), ("pscr;", "\u{1D4C5}"), ("psi;", "\u{3C8}"), ("puncsp;", "\u{2008}"), ("qfr;", "\u{1D52E}"), ("qint;", "\u{2A0C}"), ("qopf;", "\u{1D562}"), ("qprime;", "\u{2057}"), ("qscr;", "\u{1D4C6}"), ("quaternions;", "\u{210D}"), ("quatint;", "\u{2A16}"), ("quest;", "\u{3F}"), ("questeq;", "\u{225F}"), ("quot", "\u{22}"), ("quot;", "\u{22}"), ("rAarr;", "\u{21DB}"), ("rArr;", "\u{21D2}"), ("rAtail;", "\u{291C}"), ("rBarr;", "\u{290F}"), ("rHar;", "\u{2964}"), ("race;", "\u{223D}\u{331}"), ("racute;", "\u{155}"), ("radic;", "\u{221A}"), ("raemptyv;", "\u{29B3}"), ("rang;", "\u{27E9}"), ("rangd;", "\u{2992}"), ("range;", "\u{29A5}"), ("rangle;", "\u{27E9}"), ("raquo", "\u{BB}"), ("raquo;", "\u{BB}"), ("rarr;", "\u{2192}"), ("rarrap;", "\u{2975}"), ("rarrb;", "\u{21E5}"), ("rarrbfs;", "\u{2920}"), ("rarrc;", "\u{2933}"), ("rarrfs;", "\u{291E}"), ("rarrhk;", "\u{21AA}"), ("rarrlp;", "\u{21AC}"), ("rarrpl;", "\u{2945}"), ("rarrsim;", "\u{2974}"), ("rarrtl;", "\u{21A3}"), ("rarrw;", "\u{219D}"), ("ratail;", "\u{291A}"), ("ratio;", "\u{2236}"), ("rationals;", "\u{211A}"), ("rbarr;", "\u{290D}"), ("rbbrk;", "\u{2773}"), ("rbrace;", "\u{7D}"), ("rbrack;", "\u{5D}"), ("rbrke;", "\u{298C}"), ("rbrksld;", "\u{298E}"), ("rbrkslu;", "\u{2990}"), ("rcaron;", "\u{159}"), ("rcedil;", "\u{157}"), ("rceil;", "\u{2309}"), ("rcub;", "\u{7D}"), ("rcy;", "\u{440}"), ("rdca;", "\u{2937}"), ("rdldhar;", "\u{2969}"), ("rdquo;", "\u{201D}"), ("rdquor;", "\u{201D}"), ("rdsh;", "\u{21B3}"), ("real;", "\u{211C}"), ("realine;", "\u{211B}"), ("realpart;", "\u{211C}"), ("reals;", "\u{211D}"), ("rect;", "\u{25AD}"), ("reg", "\u{AE}"), ("reg;", "\u{AE}"), ("rfisht;", "\u{297D}"), ("rfloor;", "\u{230B}"), ("rfr;", "\u{1D52F}"), ("rhard;", "\u{21C1}"), ("rharu;", "\u{21C0}"), ("rharul;", "\u{296C}"), ("rho;", "\u{3C1}"), ("rhov;", "\u{3F1}"), ("rightarrow;", "\u{2192}"), ("rightarrowtail;", "\u{21A3}"), ("rightharpoondown;", "\u{21C1}"), ("rightharpoonup;", "\u{21C0}"), ("rightleftarrows;", "\u{21C4}"), ("rightleftharpoons;", "\u{21CC}"), ("rightrightarrows;", "\u{21C9}"), ("rightsquigarrow;", "\u{219D}"), ("rightthreetimes;", "\u{22CC}"), ("ring;", "\u{2DA}"), ("risingdotseq;", "\u{2253}"), ("rlarr;", "\u{21C4}"), ("rlhar;", "\u{21CC}"), ("rlm;", "\u{200F}"), ("rmoust;", "\u{23B1}"), ("rmoustache;", "\u{23B1}"), ("rnmid;", "\u{2AEE}"), ("roang;", "\u{27ED}"), ("roarr;", "\u{21FE}"), ("robrk;", "\u{27E7}"), ("ropar;", "\u{2986}"), ("ropf;", "\u{1D563}"), ("roplus;", "\u{2A2E}"), ("rotimes;", "\u{2A35}"), ("rpar;", "\u{29}"), ("rpargt;", "\u{2994}"), ("rppolint;", "\u{2A12}"), ("rrarr;", "\u{21C9}"), ("rsaquo;", "\u{203A}"), ("rscr;", "\u{1D4C7}"), ("rsh;", "\u{21B1}"), ("rsqb;", "\u{5D}"), ("rsquo;", "\u{2019}"), ("rsquor;", "\u{2019}"), ("rthree;", "\u{22CC}"), ("rtimes;", "\u{22CA}"), ("rtri;", "\u{25B9}"), ("rtrie;", "\u{22B5}"), ("rtrif;", "\u{25B8}"), ("rtriltri;", "\u{29CE}"), ("ruluhar;", "\u{2968}"), ("rx;", "\u{211E}"), ("sacute;", "\u{15B}"), ("sbquo;", "\u{201A}"), ("sc;", "\u{227B}"), ("scE;", "\u{2AB4}"), ("scap;", "\u{2AB8}"), ("scaron;", "\u{161}"), ("sccue;", "\u{227D}"), ("sce;", "\u{2AB0}"), ("scedil;", "\u{15F}"), ("scirc;", "\u{15D}"), ("scnE;", "\u{2AB6}"), ("scnap;", "\u{2ABA}"), ("scnsim;", "\u{22E9}"), ("scpolint;", "\u{2A13}"), ("scsim;", "\u{227F}"), ("scy;", "\u{441}"), ("sdot;", "\u{22C5}"), ("sdotb;", "\u{22A1}"), ("sdote;", "\u{2A66}"), ("seArr;", "\u{21D8}"), ("searhk;", "\u{2925}"), ("searr;", "\u{2198}"), ("searrow;", "\u{2198}"), ("sect", "\u{A7}"), ("sect;", "\u{A7}"), ("semi;", "\u{3B}"), ("seswar;", "\u{2929}"), ("setminus;", "\u{2216}"), ("setmn;", "\u{2216}"), ("sext;", "\u{2736}"), ("sfr;", "\u{1D530}"), ("sfrown;", "\u{2322}"), ("sharp;", "\u{266F}"), ("shchcy;", "\u{449}"), ("shcy;", "\u{448}"), ("shortmid;", "\u{2223}"), ("shortparallel;", "\u{2225}"), ("shy", "\u{AD}"), ("shy;", "\u{AD}"), ("sigma;", "\u{3C3}"), ("sigmaf;", "\u{3C2}"), ("sigmav;", "\u{3C2}"), ("sim;", "\u{223C}"), ("simdot;", "\u{2A6A}"), ("sime;", "\u{2243}"), ("simeq;", "\u{2243}"), ("simg;", "\u{2A9E}"), ("simgE;", "\u{2AA0}"), ("siml;", "\u{2A9D}"), ("simlE;", "\u{2A9F}"), ("simne;", "\u{2246}"), ("simplus;", "\u{2A24}"), ("simrarr;", "\u{2972}"), ("slarr;", "\u{2190}"), ("smallsetminus;", "\u{2216}"), ("smashp;", "\u{2A33}"), ("smeparsl;", "\u{29E4}"), ("smid;", "\u{2223}"), ("smile;", "\u{2323}"), ("smt;", "\u{2AAA}"), ("smte;", "\u{2AAC}"), ("smtes;", "\u{2AAC}\u{FE00}"), ("softcy;", "\u{44C}"), ("sol;", "\u{2F}"), ("solb;", "\u{29C4}"), ("solbar;", "\u{233F}"), ("sopf;", "\u{1D564}"), ("spades;", "\u{2660}"), ("spadesuit;", "\u{2660}"), ("spar;", "\u{2225}"), ("sqcap;", "\u{2293}"), ("sqcaps;", "\u{2293}\u{FE00}"), ("sqcup;", "\u{2294}"), ("sqcups;", "\u{2294}\u{FE00}"), ("sqsub;", "\u{228F}"), ("sqsube;", "\u{2291}"), ("sqsubset;", "\u{228F}"), ("sqsubseteq;", "\u{2291}"), ("sqsup;", "\u{2290}"), ("sqsupe;", "\u{2292}"), ("sqsupset;", "\u{2290}"), ("sqsupseteq;", "\u{2292}"), ("squ;", "\u{25A1}"), ("square;", "\u{25A1}"), ("squarf;", "\u{25AA}"), ("squf;", "\u{25AA}"), ("srarr;", "\u{2192}"), ("sscr;", "\u{1D4C8}"), ("ssetmn;", "\u{2216}"), ("ssmile;", "\u{2323}"), ("sstarf;", "\u{22C6}"), ("star;", "\u{2606}"), ("starf;", "\u{2605}"), ("straightepsilon;", "\u{3F5}"), ("straightphi;", "\u{3D5}"), ("strns;", "\u{AF}"), ("sub;", "\u{2282}"), ("subE;", "\u{2AC5}"), ("subdot;", "\u{2ABD}"), ("sube;", "\u{2286}"), ("subedot;", "\u{2AC3}"), ("submult;", "\u{2AC1}"), ("subnE;", "\u{2ACB}"), ("subne;", "\u{228A}"), ("subplus;", "\u{2ABF}"), ("subrarr;", "\u{2979}"), ("subset;", "\u{2282}"), ("subseteq;", "\u{2286}"), ("subseteqq;", "\u{2AC5}"), ("subsetneq;", "\u{228A}"), ("subsetneqq;", "\u{2ACB}"), ("subsim;", "\u{2AC7}"), ("subsub;", "\u{2AD5}"), ("subsup;", "\u{2AD3}"), ("succ;", "\u{227B}"), ("succapprox;", "\u{2AB8}"), ("succcurlyeq;", "\u{227D}"), ("succeq;", "\u{2AB0}"), ("succnapprox;", "\u{2ABA}"), ("succneqq;", "\u{2AB6}"), ("succnsim;", "\u{22E9}"), ("succsim;", "\u{227F}"), ("sum;", "\u{2211}"), ("sung;", "\u{266A}"), ("sup1", "\u{B9}"), ("sup1;", "\u{B9}"), ("sup2", "\u{B2}"), ("sup2;", "\u{B2}"), ("sup3", "\u{B3}"), ("sup3;", "\u{B3}"), ("sup;", "\u{2283}"), ("supE;", "\u{2AC6}"), ("supdot;", "\u{2ABE}"), ("supdsub;", "\u{2AD8}"), ("supe;", "\u{2287}"), ("supedot;", "\u{2AC4}"), ("suphsol;", "\u{27C9}"), ("suphsub;", "\u{2AD7}"), ("suplarr;", "\u{297B}"), ("supmult;", "\u{2AC2}"), ("supnE;", "\u{2ACC}"), ("supne;", "\u{228B}"), ("supplus;", "\u{2AC0}"), ("supset;", "\u{2283}"), ("supseteq;", "\u{2287}"), ("supseteqq;", "\u{2AC6}"), ("supsetneq;", "\u{228B}"), ("supsetneqq;", "\u{2ACC}"), ("supsim;", "\u{2AC8}"), ("supsub;", "\u{2AD4}"), ("supsup;", "\u{2AD6}"), ("swArr;", "\u{21D9}"), ("swarhk;", "\u{2926}"), ("swarr;", "\u{2199}"), ("swarrow;", "\u{2199}"), ("swnwar;", "\u{292A}"), ("szlig", "\u{DF}"), ("szlig;", "\u{DF}"), ("target;", "\u{2316}"), ("tau;", "\u{3C4}"), ("tbrk;", "\u{23B4}"), ("tcaron;", "\u{165}"), ("tcedil;", "\u{163}"), ("tcy;", "\u{442}"), ("tdot;", "\u{20DB}"), ("telrec;", "\u{2315}"), ("tfr;", "\u{1D531}"), ("there4;", "\u{2234}"), ("therefore;", "\u{2234}"), ("theta;", "\u{3B8}"), ("thetasym;", "\u{3D1}"), ("thetav;", "\u{3D1}"), ("thickapprox;", "\u{2248}"), ("thicksim;", "\u{223C}"), ("thinsp;", "\u{2009}"), ("thkap;", "\u{2248}"), ("thksim;", "\u{223C}"), ("thorn", "\u{FE}"), ("thorn;", "\u{FE}"), ("tilde;", "\u{2DC}"), ("times", "\u{D7}"), ("times;", "\u{D7}"), ("timesb;", "\u{22A0}"), ("timesbar;", "\u{2A31}"), ("timesd;", "\u{2A30}"), ("tint;", "\u{222D}"), ("toea;", "\u{2928}"), ("top;", "\u{22A4}"), ("topbot;", "\u{2336}"), ("topcir;", "\u{2AF1}"), ("topf;", "\u{1D565}"), ("topfork;", "\u{2ADA}"), ("tosa;", "\u{2929}"), ("tprime;", "\u{2034}"), ("trade;", "\u{2122}"), ("triangle;", "\u{25B5}"), ("triangledown;", "\u{25BF}"), ("triangleleft;", "\u{25C3}"), ("trianglelefteq;", "\u{22B4}"), ("triangleq;", "\u{225C}"), ("triangleright;", "\u{25B9}"), ("trianglerighteq;", "\u{22B5}"), ("tridot;", "\u{25EC}"), ("trie;", "\u{225C}"), ("triminus;", "\u{2A3A}"), ("triplus;", "\u{2A39}"), ("trisb;", "\u{29CD}"), ("tritime;", "\u{2A3B}"), ("trpezium;", "\u{23E2}"), ("tscr;", "\u{1D4C9}"), ("tscy;", "\u{446}"), ("tshcy;", "\u{45B}"), ("tstrok;", "\u{167}"), ("twixt;", "\u{226C}"), ("twoheadleftarrow;", "\u{219E}"), ("twoheadrightarrow;", "\u{21A0}"), ("uArr;", "\u{21D1}"), ("uHar;", "\u{2963}"), ("uacute", "\u{FA}"), ("uacute;", "\u{FA}"), ("uarr;", "\u{2191}"), ("ubrcy;", "\u{45E}"), ("ubreve;", "\u{16D}"), ("ucirc", "\u{FB}"), ("ucirc;", "\u{FB}"), ("ucy;", "\u{443}"), ("udarr;", "\u{21C5}"), ("udblac;", "\u{171}"), ("udhar;", "\u{296E}"), ("ufisht;", "\u{297E}"), ("ufr;", "\u{1D532}"), ("ugrave", "\u{F9}"), ("ugrave;", "\u{F9}"), ("uharl;", "\u{21BF}"), ("uharr;", "\u{21BE}"), ("uhblk;", "\u{2580}"), ("ulcorn;", "\u{231C}"), ("ulcorner;", "\u{231C}"), ("ulcrop;", "\u{230F}"), ("ultri;", "\u{25F8}"), ("umacr;", "\u{16B}"), ("uml", "\u{A8}"), ("uml;", "\u{A8}"), ("uogon;", "\u{173}"), ("uopf;", "\u{1D566}"), ("uparrow;", "\u{2191}"), ("updownarrow;", "\u{2195}"), ("upharpoonleft;", "\u{21BF}"), ("upharpoonright;", "\u{21BE}"), ("uplus;", "\u{228E}"), ("upsi;", "\u{3C5}"), ("upsih;", "\u{3D2}"), ("upsilon;", "\u{3C5}"), ("upuparrows;", "\u{21C8}"), ("urcorn;", "\u{231D}"), ("urcorner;", "\u{231D}"), ("urcrop;", "\u{230E}"), ("uring;", "\u{16F}"), ("urtri;", "\u{25F9}"), ("uscr;", "\u{1D4CA}"), ("utdot;", "\u{22F0}"), ("utilde;", "\u{169}"), ("utri;", "\u{25B5}"), ("utrif;", "\u{25B4}"), ("uuarr;", "\u{21C8}"), ("uuml", "\u{FC}"), ("uuml;", "\u{FC}"), ("uwangle;", "\u{29A7}"), ("vArr;", "\u{21D5}"), ("vBar;", "\u{2AE8}"), ("vBarv;", "\u{2AE9}"), ("vDash;", "\u{22A8}"), ("vangrt;", "\u{299C}"), ("varepsilon;", "\u{3F5}"), ("varkappa;", "\u{3F0}"), ("varnothing;", "\u{2205}"), ("varphi;", "\u{3D5}"), ("varpi;", "\u{3D6}"), ("varpropto;", "\u{221D}"), ("varr;", "\u{2195}"), ("varrho;", "\u{3F1}"), ("varsigma;", "\u{3C2}"), ("varsubsetneq;", "\u{228A}\u{FE00}"), ("varsubsetneqq;", "\u{2ACB}\u{FE00}"), ("varsupsetneq;", "\u{228B}\u{FE00}"), ("varsupsetneqq;", "\u{2ACC}\u{FE00}"), ("vartheta;", "\u{3D1}"), ("vartriangleleft;", "\u{22B2}"), ("vartriangleright;", "\u{22B3}"), ("vcy;", "\u{432}"), ("vdash;", "\u{22A2}"), ("vee;", "\u{2228}"), ("veebar;", "\u{22BB}"), ("veeeq;", "\u{225A}"), ("vellip;", "\u{22EE}"), ("verbar;", "\u{7C}"), ("vert;", "\u{7C}"), ("vfr;", "\u{1D533}"), ("vltri;", "\u{22B2}"), ("vnsub;", "\u{2282}\u{20D2}"), ("vnsup;", "\u{2283}\u{20D2}"), ("vopf;", "\u{1D567}"), ("vprop;", "\u{221D}"), ("vrtri;", "\u{22B3}"), ("vscr;", "\u{1D4CB}"), ("vsubnE;", "\u{2ACB}\u{FE00}"), ("vsubne;", "\u{228A}\u{FE00}"), ("vsupnE;", "\u{2ACC}\u{FE00}"), ("vsupne;", "\u{228B}\u{FE00}"), ("vzigzag;", "\u{299A}"), ("wcirc;", "\u{175}"), ("wedbar;", "\u{2A5F}"), ("wedge;", "\u{2227}"), ("wedgeq;", "\u{2259}"), ("weierp;", "\u{2118}"), ("wfr;", "\u{1D534}"), ("wopf;", "\u{1D568}"), ("wp;", "\u{2118}"), ("wr;", "\u{2240}"), ("wreath;", "\u{2240}"), ("wscr;", "\u{1D4CC}"), ("xcap;", "\u{22C2}"), ("xcirc;", "\u{25EF}"), ("xcup;", "\u{22C3}"), ("xdtri;", "\u{25BD}"), ("xfr;", "\u{1D535}"), ("xhArr;", "\u{27FA}"), ("xharr;", "\u{27F7}"), ("xi;", "\u{3BE}"), ("xlArr;", "\u{27F8}"), ("xlarr;", "\u{27F5}"), ("xmap;", "\u{27FC}"), ("xnis;", "\u{22FB}"), ("xodot;", "\u{2A00}"), ("xopf;", "\u{1D569}"), ("xoplus;", "\u{2A01}"), ("xotime;", "\u{2A02}"), ("xrArr;", "\u{27F9}"), ("xrarr;", "\u{27F6}"), ("xscr;", "\u{1D4CD}"), ("xsqcup;", "\u{2A06}"), ("xuplus;", "\u{2A04}"), ("xutri;", "\u{25B3}"), ("xvee;", "\u{22C1}"), ("xwedge;", "\u{22C0}"), ("yacute", "\u{FD}"), ("yacute;", "\u{FD}"), ("yacy;", "\u{44F}"), ("ycirc;", "\u{177}"), ("ycy;", "\u{44B}"), ("yen", "\u{A5}"), ("yen;", "\u{A5}"), ("yfr;", "\u{1D536}"), ("yicy;", "\u{457}"), ("yopf;", "\u{1D56A}"), ("yscr;", "\u{1D4CE}"), ("yucy;", "\u{44E}"), ("yuml", "\u{FF}"), ("yuml;", "\u{FF}"), ("zacute;", "\u{17A}"), ("zcaron;", "\u{17E}"), ("zcy;", "\u{437}"), ("zdot;", "\u{17C}"), ("zeetrf;", "\u{2128}"), ("zeta;", "\u{3B6}"), ("zfr;", "\u{1D537}"), ("zhcy;", "\u{436}"), ("zigrarr;", "\u{21DD}"), ("zopf;", "\u{1D56B}"), ("zscr;", "\u{1D4CF}"), ("zwj;", "\u{200D}"), ("zwnj;", "\u{200C}"), ]; lazy_static! { pub static ref HTML5_ENTITIES_REF: HashMap<&'static str, &'static str> = HTML5_ENTITIES.iter().copied().collect(); } hurl-6.1.1/src/html/escape.rs000064400000000000000000000032601046102023000141450ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ /// Replaces special characters "&", "<" and ">" to HTML-safe sequences. /// /// Both double quote (") and single quote (') characters are also /// translated. pub fn html_escape(text: &str) -> String { let mut output = String::new(); for c in text.chars() { match c { '&' => output.push_str("&"), '<' => output.push_str("<"), '>' => output.push_str(">"), '"' => output.push_str("""), '\'' => output.push_str("'"), _ => output.push(c), } } output } #[cfg(test)] mod tests { use super::html_escape; #[test] fn eval_html_escape() { let tests = [ ("foo", "foo"), ("", "<tag>"), ("foo & bar", "foo & bar"), ( "string with double quote: \"baz\"", "string with double quote: "baz"", ), ]; for (input, output) in tests.iter() { assert_eq!(html_escape(input), output.to_string()); } } } hurl-6.1.1/src/html/mod.rs000064400000000000000000000013341046102023000134640ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ mod entities; mod escape; mod unescape; pub use escape::html_escape; pub use unescape::html_unescape; hurl-6.1.1/src/html/unescape.rs000064400000000000000000000415511046102023000145150ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::collections::HashMap; use lazy_static::lazy_static; use regex::{Captures, Regex}; use crate::html::entities::HTML5_ENTITIES_REF; // Ref https://html.spec.whatwg.org/#decimal-character-reference-start-state static INVALID_CHAR: [(u32, &str); 34] = [ (0x00, "\u{fffd}"), // REPLACEMENT CHARACTER (0x0d, "\r"), // CARRIAGE RETURN (0x80, "\u{20ac}"), // EURO SIGN (0x81, "\u{81}"), // (0x82, "\u{201a}"), // SINGLE LOW-9 QUOTATION MARK (0x83, "\u{0192}"), // LATIN SMALL LETTER F WITH HOOK (0x84, "\u{201e}"), // DOUBLE LOW-9 QUOTATION MARK (0x85, "\u{2026}"), // HORIZONTAL ELLIPSIS (0x86, "\u{2020}"), // DAGGER (0x87, "\u{2021}"), // DOUBLE DAGGER (0x88, "\u{02c6}"), // MODIFIER LETTER CIRCUMFLEX ACCENT (0x89, "\u{2030}"), // PER MILLE SIGN (0x8a, "\u{0160}"), // LATIN CAPITAL LETTER S WITH CARON (0x8b, "\u{2039}"), // SINGLE LEFT-POINTING ANGLE QUOTATION MARK (0x8c, "\u{0152}"), // LATIN CAPITAL LIGATURE OE (0x8d, "\u{8d}"), // (0x8e, "\u{017d}"), // LATIN CAPITAL LETTER Z WITH CARON (0x8f, "\u{8f}"), // (0x90, "\u{90}"), // (0x91, "\u{2018}"), // LEFT SINGLE QUOTATION MARK (0x92, "\u{2019}"), // RIGHT SINGLE QUOTATION MARK (0x93, "\u{201c}"), // LEFT DOUBLE QUOTATION MARK (0x94, "\u{201d}"), // RIGHT DOUBLE QUOTATION MARK (0x95, "\u{2022}"), // BULLET (0x96, "\u{2013}"), // EN DASH (0x97, "\u{2014}"), // EM DASH (0x98, "\u{02dc}"), // SMALL TILDE (0x99, "\u{2122}"), // TRADE MARK SIGN (0x9a, "\u{0161}"), // LATIN SMALL LETTER S WITH CARON (0x9b, "\u{203a}"), // SINGLE RIGHT-POINTING ANGLE QUOTATION MARK (0x9c, "\u{0153}"), // LATIN SMALL LIGATURE OE (0x9d, "\u{9d}"), // (0x9e, "\u{017e}"), // LATIN SMALL LETTER Z WITH CARON (0x9f, "\u{0178}"), // LATIN CAPITAL LETTER Y WITH DIAERESIS ]; lazy_static! { static ref INVALID_CHAR_REF: HashMap = INVALID_CHAR.iter().copied().collect(); } static INVALID_CODEPOINTS: [u32; 126] = [ // 0x0001 to 0x0008 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, // 0x000E to 0x001F 0xe, 0xf, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, // 0x007F to 0x009F 0x7f, 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f, 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f, // 0xFDD0 to 0xFDEF 0xfdd0, 0xfdd1, 0xfdd2, 0xfdd3, 0xfdd4, 0xfdd5, 0xfdd6, 0xfdd7, 0xfdd8, 0xfdd9, 0xfdda, 0xfddb, 0xfddc, 0xfddd, 0xfdde, 0xfddf, 0xfde0, 0xfde1, 0xfde2, 0xfde3, 0xfde4, 0xfde5, 0xfde6, 0xfde7, 0xfde8, 0xfde9, 0xfdea, 0xfdeb, 0xfdec, 0xfded, 0xfdee, 0xfdef, // Others 0xb, 0xfffe, 0xffff, 0x1fffe, 0x1ffff, 0x2fffe, 0x2ffff, 0x3fffe, 0x3ffff, 0x4fffe, 0x4ffff, 0x5fffe, 0x5ffff, 0x6fffe, 0x6ffff, 0x7fffe, 0x7ffff, 0x8fffe, 0x8ffff, 0x9fffe, 0x9ffff, 0xafffe, 0xaffff, 0xbfffe, 0xbffff, 0xcfffe, 0xcffff, 0xdfffe, 0xdffff, 0xefffe, 0xeffff, 0xffffe, 0xfffff, 0x10fffe, 0x10ffff, ]; lazy_static! { static ref CHAR_REF: Regex = Regex::new(concat!( r"&(#\d+;?", r"|#[xX][\da-fA-F]+;?", r"|[^\t\n\f <&#;]{1,32};?)", )) .unwrap(); } /// Convert all named and numeric character references (e.g. >, >, /// &x3e;) in the string `text` to the corresponding unicode characters. /// This function uses the rules defined by the HTML 5 standard /// for both valid and invalid character references, and the list of /// HTML 5 named character references defined in html.entities.html5. /// /// The code is adapted from the Python standard library: /// /// /// See MDN decoder tool: pub fn html_unescape(text: &str) -> String { if text.chars().any(|c| c == '&') { CHAR_REF .replace_all(text, |caps: &Captures| { let s = &caps[1]; let s0 = s.chars().next().unwrap(); if s0 == '#' { // Numeric charref let s1 = s.chars().nth(1).unwrap(); let num = if s1 == 'x' || s1 == 'X' { let val = s[2..].trim_end_matches(';'); match u32::from_str_radix(val, 16) { Ok(val) => val, Err(_) => return "\u{FFFD}".to_string(), } } else { let val = s[1..].trim_end_matches(';'); match val.parse::() { Ok(val) => val, Err(_) => return "\u{FFFD}".to_string(), } }; if let Some(char) = INVALID_CHAR_REF.get(&num) { return char.to_string(); } if (0xD800..=0xDFFF).contains(&num) || num > 0x10FFFF { return "\u{FFFD}".to_string(); } if INVALID_CODEPOINTS.contains(&num) { return String::new(); } char::from_u32(num).unwrap().to_string() } else { if let Some(entity) = HTML5_ENTITIES_REF.get(s) { return entity.to_string(); } // Find the longest matching name (as defined by the standard) for x in (1..s.len()).rev() { let name = &s[..x]; if let Some(entity) = HTML5_ENTITIES_REF.get(name) { return format!("{}{}", entity, &s[x..]); } } format!("&{s}") } }) .to_string() } else { text.to_string() } } #[cfg(test)] mod tests { use super::html_unescape; /// Extracts from Python test suites: https://github.com/python/cpython/blob/main/Lib/test/test_html.py #[test] fn test_html_unescape() { fn check(text: &str, expected: &str) { assert_eq!(html_unescape(text), expected.to_string()); } fn check_num(num: usize, expected: &str) { let text = format!("&#{num}"); check(&text, expected); let text = format!("&#{num};"); check(&text, expected); let text = format!("&#x{num:x}"); check(&text, expected); let text = format!("&#x{num:x};"); check(&text, expected); } check("Hurl⇄", "Hurl⇄"); // Check simple check( "Foo © bar 𝌆 baz ☃ qux", "Foo © bar 𝌆 baz ☃ qux", ); // Check text with no character references check("no character references", "no character references"); // Check & followed by invalid chars check("&\n&\t& &&", "&\n&\t& &&"); // Check & followed by numbers and letters check("&0 &9 &a &0; &9; &a;", "&0 &9 &a &0; &9; &a;"); // Check incomplete entities at the end of the string for x in ["&", "&#", "&#x", "&#X", "&#y", "&#xy", "&#Xy"].iter() { check(x, x); check(&format!("{x};"), &format!("{x};")); } // Check several combinations of numeric character references, // possibly followed by different characters // Format Ӓ (without ending semi-colon) for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#{num}"), &format!("{char}")); check(&format!("&#{num} "), &format!("{char} ")); check(&format!("&#{num}X"), &format!("{char}X")); } // Format Ӓ (without ending semi-colon) for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#{num:07}"), &format!("{char}")); check(&format!("&#{num:07} "), &format!("{char} ")); check(&format!("&#{num:07}X"), &format!("{char}X")); } // Format Ӓ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#{num};"), &format!("{char}")); check(&format!("&#{num}; "), &format!("{char} ")); check(&format!("&#{num};X"), &format!("{char}X")); } // Format Ӓ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#{num:07};"), &format!("{char}")); check(&format!("&#{num:07}; "), &format!("{char} ")); check(&format!("&#{num:07};X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#x{num:x}"), &format!("{char}")); check(&format!("&#x{num:x} "), &format!("{char} ")); check(&format!("&#x{num:x}X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#x{num:06x}"), &format!("{char}")); check(&format!("&#x{num:06x} "), &format!("{char} ")); check(&format!("&#x{num:06x}X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#x{num:x};"), &format!("{char}")); check(&format!("&#x{num:x}; "), &format!("{char} ")); check(&format!("&#x{num:x};X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#x{num:06x};"), &format!("{char}")); check(&format!("&#x{num:06x}; "), &format!("{char} ")); check(&format!("&#x{num:06x};X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#x{num:X}"), &format!("{char}")); check(&format!("&#x{num:X} "), &format!("{char} ")); check(&format!("&#x{num:X}X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#x{num:06X}"), &format!("{char}")); check(&format!("&#x{num:06X} "), &format!("{char} ")); check(&format!("&#x{num:06X}X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#x{num:X};"), &format!("{char}")); check(&format!("&#x{num:X}; "), &format!("{char} ")); check(&format!("&#x{num:X};X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#x{num:06X};"), &format!("{char}")); check(&format!("&#x{num:06X}; "), &format!("{char} ")); check(&format!("&#x{num:06X};X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#X{num:x};"), &format!("{char}")); check(&format!("&#X{num:x}; "), &format!("{char} ")); check(&format!("&#X{num:x};X"), &format!("{char}X")); } // Format ᪼ for (num, char) in [ (65, 'A'), (97, 'a'), (34, '"'), (38, '&'), (0x2603, '\u{2603}'), (0x101234, '\u{101234}'), ] .iter() { check(&format!("&#X{num:06x};"), &format!("{char}")); check(&format!("&#X{num:06x}; "), &format!("{char} ")); check(&format!("&#X{num:06x};X"), &format!("{char}X")); } // Check invalid code points for cp in [0xD800, 0xDB00, 0xDC00, 0xDFFF, 0x110000] { check_num(cp, "\u{FFFD}"); } // Check more invalid code points for cp in [0x1, 0xb, 0xe, 0x7f, 0xfffe, 0xffff, 0x10fffe, 0x10ffff] { check_num(cp, ""); } // Check invalid numbers for (num, ch) in [(0x0d, "\r"), (0x80, "\u{20ac}"), (0x95, "\u{2022}")] { check_num(num, ch); } // Check small numbers check_num(0, "\u{FFFD}"); check_num(9, "\t"); // Check a big number check_num(1000000000000000000, "\u{FFFD}"); // Check that multiple trailing semicolons are handled correctly for e in ["";", "";", "";", "";"] { check(e, "\";"); } // Check that semicolons in the middle don't create problems for e in [""quot;", ""quot;", ""quot;", ""quot;"] { check(e, "\"quot;"); } // Check triple adjacent charrefs for e in [""", """, """, """] { // check(&e.repeat(3), "\"\"\""); check(&format!("{e};").repeat(3), "\"\"\""); } // Check that the case is respected for e in ["&", "&", "&", "&"] { check(e, "&"); } for e in ["&Amp", "&Amp;"] { check(e, e); } // Check that nonexistent named entities are returned unchanged check("&svadilfari;", "&svadilfari;"); // The following examples are in the html5 specs check("¬it", "¬it"); check("¬it;", "¬it;"); check("¬in", "¬in"); check("∉", "∉"); // A similar example with a long name check( "¬ReallyAnExistingNamedCharacterReference;", "¬ReallyAnExistingNamedCharacterReference;", ); // Longest valid name check("∳", "∳"); // Check a charref that maps to two unicode chars check("∾̳", "\u{223e}\u{333}"); check("&acE", "&acE"); // See Python #12888 check(&"{ ".repeat(1050), &"{ ".repeat(1050)); // See Python #15156 check( "ÉricÉric&alphacentauriαcentauri", "ÉricÉric&alphacentauriαcentauri", ); check("&co;", "&co;"); } } hurl-6.1.1/src/http/call.rs000064400000000000000000000022751046102023000136400ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use crate::http::{Request, Response, Timings}; /// Holds an HTTP request and the corresponding HTTP response. /// The request and responses are the runtime, evaluated data created by an HTTP exchange. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Call { /// The real HTTP request (vs the specified request in a Hurl file source) pub request: Request, /// The real HTTP response (vs the specified request in a Hurl file source) pub response: Response, /// Timings of the exchange, see pub timings: Timings, } hurl-6.1.1/src/http/certificate.rs000064400000000000000000000215401046102023000152030ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::collections::HashMap; use chrono::{DateTime, NaiveDateTime, Utc}; use crate::http::easy_ext::CertInfo; #[derive(Clone, Debug, PartialEq, Eq)] pub struct Certificate { pub subject: String, pub issuer: String, pub start_date: DateTime, pub expire_date: DateTime, pub serial_number: String, } impl TryFrom for Certificate { type Error = String; /// parse `cert_info` /// support different "formats" in cert info /// - attribute name: "Start date" vs "Start Date" /// - date format: "Jan 10 08:29:52 2023 GMT" vs "2023-01-10 08:29:52 GMT" fn try_from(cert_info: CertInfo) -> Result { let attributes = parse_attributes(&cert_info.data); let subject = parse_subject(&attributes)?; let issuer = parse_issuer(&attributes)?; let start_date = parse_start_date(&attributes)?; let expire_date = parse_expire_date(&attributes)?; let serial_number = parse_serial_number(&attributes)?; Ok(Certificate { subject, issuer, start_date, expire_date, serial_number, }) } } /// Parses certificate's subject attribute. /// /// TODO: we're exposing the subject and issuer directly from libcurl. In the certificate, these /// properties are list of pair of key-value. /// Through libcurl, these lists are serialized to a string: /// /// Example: /// vec![("C","US"),("O","Google Trust Services LLC"),("CN","GTS Root R1"))] => /// "C = US, O = Google Trust Services LLC, CN = GTS Root R1" /// /// We should normalize the serialization (use 'A = B' or 'A=B') to always have the same issuer/ /// subject given a certain certificate. Actually the value can differ on different platforms, for /// a given certificate. /// /// See: /// - /// - https://curl.se/mail/lib-2024-06/0013.html fn parse_subject(attributes: &HashMap) -> Result { match attributes.get("subject") { None => Err(format!("missing Subject attribute in {attributes:?}")), Some(value) => Ok(value.clone()), } } /// Parses certificate's issuer attribute. fn parse_issuer(attributes: &HashMap) -> Result { match attributes.get("issuer") { None => Err(format!("missing Issuer attribute in {attributes:?}")), Some(value) => Ok(value.clone()), } } fn parse_start_date(attributes: &HashMap) -> Result, String> { match attributes.get("start date") { None => Err(format!("missing start date attribute in {attributes:?}")), Some(value) => Ok(parse_date(value)?), } } fn parse_expire_date(attributes: &HashMap) -> Result, String> { match attributes.get("expire date") { None => Err("missing expire date attribute".to_string()), Some(value) => Ok(parse_date(value)?), } } fn parse_date(value: &str) -> Result, String> { let naive_date_time = match NaiveDateTime::parse_from_str(value, "%b %d %H:%M:%S %Y GMT") { Ok(d) => d, Err(_) => NaiveDateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S GMT") .map_err(|_| format!("can not parse date <{value}>"))?, }; Ok(naive_date_time.and_local_timezone(Utc).unwrap()) } fn parse_serial_number(attributes: &HashMap) -> Result { let value = attributes .get("serial number") .cloned() .ok_or(format!("Missing serial number attribute in {attributes:?}"))?; let normalized_value = if value.contains(':') { value .split(':') .filter(|e| !e.is_empty()) .collect::>() .join(":") } else { value .chars() .collect::>() .chunks(2) .map(|c| c.iter().collect::()) .collect::>() .join(":") }; Ok(normalized_value) } fn parse_attributes(data: &Vec) -> HashMap { let mut map = HashMap::new(); for s in data { if let Some((name, value)) = parse_attribute(s) { map.insert(name.to_lowercase(), value); } } map } fn parse_attribute(s: &str) -> Option<(String, String)> { if let Some(index) = s.find(':') { let (name, value) = s.split_at(index); Some((name.to_string(), value[1..].to_string())) } else { None } } #[cfg(test)] mod tests { use super::*; use crate::http::certificate::Certificate; use crate::http::easy_ext::CertInfo; #[test] fn test_parse_subject() { let mut attributes = HashMap::new(); attributes.insert( "subject".to_string(), "C=US, ST=Denial, L=Springfield, O=Dis, CN=localhost".to_string(), ); assert_eq!( parse_subject(&attributes).unwrap(), "C=US, ST=Denial, L=Springfield, O=Dis, CN=localhost".to_string() ); } #[test] fn test_parse_start_date() { let mut attributes = HashMap::new(); attributes.insert( "start date".to_string(), "Jan 10 08:29:52 2023 GMT".to_string(), ); assert_eq!( parse_start_date(&attributes).unwrap(), chrono::DateTime::parse_from_rfc2822("Tue, 10 Jan 2023 08:29:52 GMT") .unwrap() .with_timezone(&chrono::Utc) ); let mut attributes = HashMap::new(); attributes.insert( "start date".to_string(), "2023-01-10 08:29:52 GMT".to_string(), ); assert_eq!( parse_start_date(&attributes).unwrap(), chrono::DateTime::parse_from_rfc2822("Tue, 10 Jan 2023 08:29:52 GMT") .unwrap() .with_timezone(&chrono::Utc) ); } #[test] fn test_parse_serial_number() { let mut attributes = HashMap::new(); attributes.insert( "serial number".to_string(), "1e:e8:b1:7f:1b:64:d8:d6:b3:de:87:01:03:d2:a4:f5:33:53:5a:b0:".to_string(), ); assert_eq!( parse_serial_number(&attributes).unwrap(), "1e:e8:b1:7f:1b:64:d8:d6:b3:de:87:01:03:d2:a4:f5:33:53:5a:b0".to_string() ); let mut attributes = HashMap::new(); attributes.insert( "serial number".to_string(), "1ee8b17f1b64d8d6b3de870103d2a4f533535ab0".to_string(), ); assert_eq!( parse_serial_number(&attributes).unwrap(), "1e:e8:b1:7f:1b:64:d8:d6:b3:de:87:01:03:d2:a4:f5:33:53:5a:b0".to_string() ); } #[test] fn test_try_from() { assert_eq!( Certificate::try_from(CertInfo { data: vec![ "Subject:C = US, ST = Denial, L = Springfield, O = Dis, CN = localhost" .to_string(), "Issuer:C = US, ST = Denial, L = Springfield, O = Dis, CN = localhost" .to_string(), "Serial Number:1ee8b17f1b64d8d6b3de870103d2a4f533535ab0".to_string(), "Start date:Jan 10 08:29:52 2023 GMT".to_string(), "Expire date:Oct 30 08:29:52 2025 GMT".to_string(), ] }) .unwrap(), Certificate { subject: "C = US, ST = Denial, L = Springfield, O = Dis, CN = localhost" .to_string(), issuer: "C = US, ST = Denial, L = Springfield, O = Dis, CN = localhost".to_string(), start_date: chrono::DateTime::parse_from_rfc2822("Tue, 10 Jan 2023 08:29:52 GMT") .unwrap() .with_timezone(&chrono::Utc), expire_date: chrono::DateTime::parse_from_rfc2822("Thu, 30 Oct 2025 08:29:52 GMT") .unwrap() .with_timezone(&chrono::Utc), serial_number: "1e:e8:b1:7f:1b:64:d8:d6:b3:de:87:01:03:d2:a4:f5:33:53:5a:b0" .to_string() } ); assert_eq!( Certificate::try_from(CertInfo { data: vec![] }) .err() .unwrap(), "missing Subject attribute in {}".to_string() ); } } hurl-6.1.1/src/http/client.rs000064400000000000000000001262231046102023000142030ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::collections::HashMap; use std::str; use std::str::FromStr; use std::time::Instant; use base64::engine::general_purpose; use base64::Engine; use chrono::Utc; use curl::easy::{List, NetRc, SslOpt}; use curl::{easy, Version}; use encoding::all::ISO_8859_1; use encoding::{DecoderTrap, Encoding}; use hurl_core::typing::Count; use crate::http::certificate::Certificate; use crate::http::curl_cmd::CurlCmd; use crate::http::debug::log_body; use crate::http::header::{ HeaderVec, ACCEPT_ENCODING, AUTHORIZATION, CONTENT_TYPE, EXPECT, LOCATION, USER_AGENT, }; use crate::http::ip::IpAddr; use crate::http::options::ClientOptions; use crate::http::timings::Timings; use crate::http::url::Url; use crate::http::{ easy_ext, Call, Cookie, FileParam, Header, HttpError, HttpVersion, IpResolve, Method, MultipartParam, Param, Request, RequestCookie, RequestSpec, RequestedHttpVersion, Response, Verbosity, }; use crate::runner::Output; use crate::util::logger::Logger; use crate::util::path::ContextDir; /// Defines an HTTP client to execute HTTP requests. /// /// Most of the methods are delegated to libcurl functions, while some /// features are implemented "by hand" (like retry, redirection etc...) #[derive(Debug)] pub struct Client { /// The handle to libcurl binding handle: easy::Easy, /// HTTP version support http2: bool, http3: bool, /// Certificates cache to get SSL certificates on reused libcurl connections. certificates: HashMap, } impl Client { /// Creates HTTP Hurl client. pub fn new() -> Client { let handle = easy::Easy::new(); let version = Version::get(); Client { handle, http2: version.feature_http2(), http3: version.feature_http3(), certificates: HashMap::new(), } } /// Executes an HTTP request `request_spec`, optionally follows redirection and returns a list of [`Call`]. pub fn execute_with_redirect( &mut self, request_spec: &RequestSpec, options: &ClientOptions, logger: &mut Logger, ) -> Result, HttpError> { let mut calls = vec![]; let mut request_spec = request_spec.clone(); let mut options = options.clone(); // Unfortunately, follow-location feature from libcurl can not be used as libcurl returns a // single list of headers for the 2 responses and Hurl needs to keep every header of every // response. let mut redirect_count = 0; loop { let call = self.execute(&request_spec, &options, logger)?; // If we don't follow redirection, we can early exit here. if !options.follow_location { calls.push(call); break; } let request_url = call.request.url.clone(); let status = call.response.status; let redirect_url = self.follow_location(&request_url, &call.response)?; calls.push(call); if redirect_url.is_none() { break; } let redirect_url = redirect_url.unwrap(); logger.debug(""); logger.debug(&format!("=> Redirect to {redirect_url}")); logger.debug(""); redirect_count += 1; if let Count::Finite(max_redirect) = options.max_redirect { if redirect_count > max_redirect { return Err(HttpError::TooManyRedirect); } }; let redirect_method = redirect_method(status, request_spec.method); let mut headers = request_spec.headers; // When following redirection, we filter `AUTHORIZATION` header unless explicitly told // to trust the redirected host with `--location-trusted`. let host_changed = request_url.host() != redirect_url.host(); if host_changed && !options.follow_location_trusted { headers.retain(|h| !h.name_eq(AUTHORIZATION)); options.user = None; } request_spec = RequestSpec { method: redirect_method, url: redirect_url, headers, ..Default::default() }; } Ok(calls) } /// Executes an HTTP request `request_spec`, without following redirection and returns a /// pair of [`Call`]. pub fn execute( &mut self, request_spec: &RequestSpec, options: &ClientOptions, logger: &mut Logger, ) -> Result { // The handle can be mutated in this function: to start from a clean state, we reset it // prior to everything. self.handle.reset(); let (url, method) = self.configure(request_spec, options, logger)?; let start = Instant::now(); let start_dt = Utc::now(); let verbose = options.verbosity.is_some(); let very_verbose = options.verbosity == Some(Verbosity::VeryVerbose); let mut request_headers = HeaderVec::new(); let mut status_lines = vec![]; let mut response_headers = vec![]; let has_body_data = !request_spec.body.bytes().is_empty() || !request_spec.form.is_empty() || !request_spec.multipart.is_empty(); // `request_body` are request body bytes computed by libcurl (the real bytes sent over the wire) // whereas`request_spec_body` are request body bytes provided by Hurl user. For instance, if user uses // a [FormParam] section, `request_body` is empty whereas libcurl sent a url-form encoded list // of key-value. let mut request_body = Vec::::new(); let mut response_body = Vec::::new(); { let mut transfer = self.handle.transfer(); transfer.debug_function(|info_type, data| match info_type { // Return all request headers (not one by one) easy::InfoType::HeaderOut => { let lines = split_lines(data); // Extracts request headers from libcurl debug info. // First line is method/path/version line, last line is empty for line in &lines[1..lines.len() - 1] { if let Some(header) = Header::parse(line) { request_headers.push(header); } } // Logs method, version and request headers now. if verbose { logger.debug_method_version_out(&lines[0]); let headers = request_headers .iter() .map(|h| (h.name.as_str(), h.value.as_str())) .collect::>(); logger.debug_headers_out(&headers); } // If we don't send any data, we log an empty body here instead of relying on // libcurl computing body in `easy::InfoType::DataOut` because libcurl doesn't // call `easy::InfoType::DataOut` if there is no data to send. if !has_body_data && very_verbose { logger.debug_important("Request body:"); log_body(&[], &request_headers, true, logger); } } // We use this callback to get the real body bytes sent by libcurl and logs request // body chunks. easy::InfoType::DataOut => { if very_verbose { logger.debug_important("Request body:"); log_body(data, &request_headers, true, logger); } // Constructs request body from libcurl debug info. request_body.extend(data); } // Curl debug logs easy::InfoType::Text => { let len = data.len(); if very_verbose && len > 0 { let text = str::from_utf8(&data[..len - 1]); if let Ok(text) = text { logger.debug_curl(text); } } } _ => {} })?; transfer.header_function(|h| { if let Some(s) = decode_header(h) { if s.starts_with("HTTP/") { status_lines.push(s); } else { response_headers.push(s); } } true })?; transfer.write_function(|data| { response_body.extend(data); Ok(data.len()) })?; if let Err(e) = transfer.perform() { let code = e.code() as i32; // due to windows build let description = match e.extra_description() { None => e.description().to_string(), Some(s) => s.to_string(), }; return Err(HttpError::Libcurl { code, description }); } } // We perform an additional check on the response size if maximum filesize is specified // because curl can fail to do this under certain circumstances. // See: // - // - // > Note: before curl 8.4.0, when the file size is not known prior to download, for such files // > this option has no effect even if the file transfer ends up being larger than this given limit. if let Some(max_filesize) = options.max_filesize { if response_body.len() as u64 > max_filesize { return Err(HttpError::AllowedResponseSizeExceeded(max_filesize)); } } let status = self.handle.response_code()?; // TODO: explain why status_lines is Vec ? let version = match status_lines.last() { Some(status_line) => self.parse_response_version(status_line)?, None => return Err(HttpError::CouldNotParseResponse), }; let headers = self.parse_response_headers(&response_headers); let length = response_body.len(); let certificate = self.cert_info(logger)?; let duration = start.elapsed(); let stop_dt = start_dt + duration; let timings = Timings::new(&mut self.handle, start_dt, stop_dt); let url = Url::from_str(&url)?; let ip_addr = self.primary_ip()?; let request = Request::new( &method.to_string(), url.clone(), request_headers, request_body, ); let response = Response::new( version, status, headers, response_body, duration, url, certificate, ip_addr, ); if verbose { // FIXME: the cast to u64 seems not necessary. // If we dont cast from u128 and try to format! or println! // we have a segfault on Alpine Docker images and Rust 1.68.0, whereas it was // ok with Rust >= 1.67.0. let duration = duration.as_millis() as u64; logger.debug_important(&format!( "Response: (received {length} bytes in {duration} ms)" )); logger.debug(""); // FIXME: Explain why there may be multiple status line status_lines .iter() .filter(|s| s.starts_with("HTTP/")) .for_each(|s| logger.debug_status_version_in(s.trim())); let headers = response .headers .iter() .map(|h| (h.name.as_str(), h.value.as_str())) .collect::>(); logger.debug_headers_in(&headers); if very_verbose { logger.debug_important("Response body:"); response.log_body(true, logger); logger.debug(""); timings.log(logger); } } Ok(Call { request, response, timings, }) } /// Configure libcurl handle to send a `request_spec`, using `options`. /// If configuration is successful, returns a tuple of the concrete requested URL and method. fn configure( &mut self, request_spec: &RequestSpec, options: &ClientOptions, logger: &mut Logger, ) -> Result<(String, Method), HttpError> { // Activates cookie engine. // See // > It also enables the cookie engine, making libcurl parse and send cookies on subsequent // > requests with this handle. // > By passing the empty string ("") to this option, you enable the cookie // > engine without reading any initial cookies. self.handle .cookie_file(options.cookie_input_file.clone().unwrap_or_default()) .unwrap(); // We force libcurl verbose mode regardless of Hurl verbose option to be able // to capture HTTP request headers in libcurl `debug_function`. That's the only // way to get access to the outgoing headers. self.handle.verbose(true)?; // We check libcurl HTTP version support. let http_version = options.http_version; if (http_version == RequestedHttpVersion::Http2 && !self.http2) || (http_version == RequestedHttpVersion::Http3 && !self.http3) { return Err(HttpError::UnsupportedHttpVersion(http_version)); } if !options.allow_reuse { logger.debug("Force refreshing connections because requested HTTP version change"); } self.handle.fresh_connect(!options.allow_reuse)?; self.handle.forbid_reuse(!options.allow_reuse)?; self.handle.http_version(options.http_version.into())?; self.handle.ip_resolve(options.ip_resolve.into())?; // Activates the access of certificates info chain after a transfer has been executed. self.handle.certinfo(true)?; if !options.connects_to.is_empty() { let connects = to_list(&options.connects_to); self.handle.connect_to(connects)?; } if !options.resolves.is_empty() { let resolves = to_list(&options.resolves); self.handle.resolve(resolves)?; } self.handle.ssl_verify_host(!options.insecure)?; self.handle.ssl_verify_peer(!options.insecure)?; if let Some(cacert_file) = &options.cacert_file { self.handle.cainfo(cacert_file)?; self.handle.ssl_cert_type("PEM")?; } if let Some(client_cert_file) = &options.client_cert_file { match parse_cert_password(client_cert_file) { (cert, Some(password)) => { self.handle.ssl_cert(cert)?; self.handle.key_password(&password)?; } (cert, None) => { self.handle.ssl_cert(cert)?; } } self.handle.ssl_cert_type("PEM")?; } if let Some(client_key_file) = &options.client_key_file { self.handle.ssl_key(client_key_file)?; self.handle.ssl_cert_type("PEM")?; } self.handle.path_as_is(options.path_as_is)?; if let Some(proxy) = &options.proxy { self.handle.proxy(proxy)?; } if let Some(no_proxy) = &options.no_proxy { self.handle.noproxy(no_proxy)?; } if let Some(unix_socket) = &options.unix_socket { self.handle.unix_socket(unix_socket)?; } if let Some(filename) = &options.netrc_file { easy_ext::netrc_file(&mut self.handle, filename)?; self.handle.netrc(if options.netrc_optional { NetRc::Optional } else { NetRc::Required })?; } else if options.netrc_optional { self.handle.netrc(NetRc::Optional)?; } else if options.netrc { self.handle.netrc(NetRc::Required)?; } self.handle.timeout(options.timeout)?; self.handle.connect_timeout(options.connect_timeout)?; if let Some(max_filesize) = options.max_filesize { self.handle.max_filesize(max_filesize)?; } if let Some(max_recv_speed) = options.max_recv_speed { self.handle.max_recv_speed(max_recv_speed.0)?; } if let Some(max_send_speed) = options.max_send_speed { self.handle.max_send_speed(max_send_speed.0)?; } self.set_ssl_options(options.ssl_no_revoke)?; let url = self.generate_url(&request_spec.url, &request_spec.querystring); self.handle.url(url.as_str())?; let method = &request_spec.method; self.set_method(method)?; self.set_cookies(&request_spec.cookies)?; self.set_form(&request_spec.form)?; self.set_multipart(&request_spec.multipart)?; let request_spec_body = &request_spec.body.bytes(); self.set_body(request_spec_body)?; // TODO: do we want to manage the headers with no content? There are two type of no-content // headers: `foo:` and `foo;`. The first one can be used to remove libcurl headers (`Host:`) // while the second one is used to send an empty header. // See let options_headers = options .headers .iter() .map(|h| h.as_str()) .collect::>(); let headers = &request_spec.headers.aggregate_raw_headers(&options_headers); self.set_headers( headers, request_spec.implicit_content_type.as_deref(), options, )?; if let Some(aws_sigv4) = &options.aws_sigv4 { if let Err(e) = self.handle.aws_sigv4(aws_sigv4.as_str()) { return match e.code() { curl_sys::CURLE_UNKNOWN_OPTION => Err(HttpError::LibcurlUnknownOption { option: "aws-sigv4".to_string(), minimum_version: "7.75.0".to_string(), }), _ => Err(e.into()), }; } } if *method == Method("HEAD".to_string()) { self.handle.nobody(true)?; } Ok((url, method.clone())) } /// Generates URL. fn generate_url(&mut self, url: &Url, params: &[Param]) -> String { let url = url.raw(); if params.is_empty() { url } else { let url = if url.ends_with('?') { url } else if url.contains('?') { format!("{url}&") } else { format!("{url}?") }; let s = self.url_encode_params(params); format!("{url}{s}") } } /// Sets HTTP method. fn set_method(&mut self, method: &Method) -> Result<(), HttpError> { self.handle.custom_request(method.to_string().as_str())?; Ok(()) } /// Sets HTTP headers. fn set_headers( &mut self, headers: &HeaderVec, implicit_content_type: Option<&str>, options: &ClientOptions, ) -> Result<(), HttpError> { let mut list = headers.to_curl_headers()?; // If request has no `Content-Type` header, we set it if the content type has been set // implicitly on this request. if !headers.contains_key(CONTENT_TYPE) { if let Some(s) = implicit_content_type { list.append(&format!("{}: {s}", CONTENT_TYPE))?; } else { // We remove default `Content-Type` headers added by curl because we want to // explicitly manage this header. // For instance, with --data option, curl will send a `Content-type: application/x-www-form-urlencoded` // header. From , we can delete // the headers added by libcurl by adding a header with no content. list.append(&format!("{}:", CONTENT_TYPE))?; } } // Workaround for libcurl issue : // When Hurl explicitly sets `Expect:` to remove the header, libcurl will generate // `SignedHeaders` that include `expect` even though the header is not present, causing // some APIs to reject the request. // Therefore, we only remove this header when not in aws_sigv4 mode. if !headers.contains_key(EXPECT) && options.aws_sigv4.is_none() { // We remove default Expect headers added by curl because we want to explicitly manage // this header. list.append(&format!("{}:", EXPECT))?; } if !headers.contains_key(USER_AGENT) { let user_agent = match options.user_agent { Some(ref u) => u.clone(), None => { let pkg_version = env!("CARGO_PKG_VERSION"); format!("hurl/{pkg_version}") } }; list.append(&format!("{}: {user_agent}", USER_AGENT))?; } if let Some(user) = &options.user { if options.aws_sigv4.is_some() { // curl's aws_sigv4 support needs to know the username and password for the // request, as it uses those values to calculate the Authorization header for the // AWS V4 signature. if let Some((username, password)) = user.split_once(':') { self.handle.username(username)?; self.handle.password(password)?; } } else { let user = user.as_bytes(); let authorization = general_purpose::STANDARD.encode(user); if !headers.contains_key(AUTHORIZATION) { list.append(&format!("{}: Basic {authorization}", AUTHORIZATION))?; } } } if options.compressed && !headers.contains_key(ACCEPT_ENCODING) { list.append(&format!("{}: gzip, deflate, br", ACCEPT_ENCODING))?; } self.handle.http_headers(list)?; Ok(()) } /// Sets request cookies. fn set_cookies(&mut self, cookies: &[RequestCookie]) -> Result<(), HttpError> { let s = cookies .iter() .map(|c| c.to_string()) .collect::>() .join("; "); if !s.is_empty() { self.handle.cookie(s.as_str())?; } Ok(()) } /// Sets form params. fn set_form(&mut self, params: &[Param]) -> Result<(), HttpError> { if !params.is_empty() { let s = self.url_encode_params(params); self.handle.post_fields_copy(s.as_bytes())?; } Ok(()) } /// Sets multipart form data. fn set_multipart(&mut self, params: &[MultipartParam]) -> Result<(), HttpError> { if !params.is_empty() { let mut form = easy::Form::new(); for param in params { // TODO: we could remove these `unwrap` if we implement conversion // from libcurl::FormError to HttpError match param { MultipartParam::Param(Param { name, value }) => { form.part(name).contents(value.as_bytes()).add().unwrap(); } MultipartParam::FileParam(FileParam { name, filename, data, content_type, }) => form .part(name) .buffer(filename, data.clone()) .content_type(content_type) .add() .unwrap(), } } self.handle.httppost(form)?; } Ok(()) } /// Sets request body. fn set_body(&mut self, data: &[u8]) -> Result<(), HttpError> { if !data.is_empty() { self.handle.post(true)?; self.handle.post_fields_copy(data)?; } Ok(()) } /// Sets SSL options fn set_ssl_options(&mut self, no_revoke: bool) -> Result<(), HttpError> { let mut ssl_opt = SslOpt::new(); ssl_opt.no_revoke(no_revoke); self.handle.ssl_options(&ssl_opt)?; Ok(()) } /// URL encodes parameters. fn url_encode_params(&mut self, params: &[Param]) -> String { params .iter() .map(|p| { let value = self.handle.url_encode(p.value.as_bytes()); format!("{}={}", p.name, value) }) .collect::>() .join("&") } /// Parses HTTP response version. fn parse_response_version(&mut self, line: &str) -> Result { if line.starts_with("HTTP/1.0") { Ok(HttpVersion::Http10) } else if line.starts_with("HTTP/1.1") { Ok(HttpVersion::Http11) } else if line.starts_with("HTTP/2") { Ok(HttpVersion::Http2) } else if line.starts_with("HTTP/3") { Ok(HttpVersion::Http3) } else { Err(HttpError::CouldNotParseResponse) } } /// Parse headers from libcurl responses. fn parse_response_headers(&mut self, lines: &[String]) -> HeaderVec { let mut headers = HeaderVec::new(); for line in lines { if let Some(header) = Header::parse(line) { headers.push(header); } } headers } /// Get the IP address of the last connection from libcurl fn primary_ip(&mut self) -> Result { match self.handle.primary_ip()? { Some(ip) => Ok(IpAddr::new(ip.to_string())), None => Err(HttpError::NoPrimaryIp), } } /// Retrieves an optional location to follow /// /// You need: /// 1. the option follow_location set to true /// 2. a 3xx response code /// 3. a header Location fn follow_location( &mut self, request_url: &Url, response: &Response, ) -> Result, HttpError> { let response_code = response.status; if !(300..400).contains(&response_code) { return Ok(None); } let Some(location) = response.headers.get(LOCATION) else { return Ok(None); }; let url = request_url.join(&location.value)?; Ok(Some(url)) } /// Returns cookie storage. pub fn cookie_storage(&mut self, logger: &mut Logger) -> Vec { let list = self.handle.cookies().unwrap(); let mut cookies = vec![]; for cookie in list.iter() { let line = str::from_utf8(cookie).unwrap(); if let Ok(cookie) = Cookie::from_str(line) { cookies.push(cookie); } else { logger.warning(&format!("Line <{line}> can not be parsed as cookie")); } } cookies } /// Adds a cookie to the cookie jar. pub fn add_cookie(&mut self, cookie: &Cookie, logger: &mut Logger) { logger.debug(&format!("Add to cookie store <{cookie}> (experimental)")); self.handle .cookie_list(cookie.to_string().as_str()) .unwrap(); } /// Clears cookie storage. pub fn clear_cookie_storage(&mut self, logger: &mut Logger) { logger.debug("Clear cookie storage (experimental)"); self.handle.cookie_list("ALL").unwrap(); } /// Returns curl command-line for the HTTP `request_spec` run by this client. pub fn curl_command_line( &mut self, request_spec: &RequestSpec, context_dir: &ContextDir, output: Option<&Output>, options: &ClientOptions, logger: &mut Logger, ) -> CurlCmd { let cookies = self.cookie_storage(logger); CurlCmd::new(request_spec, &cookies, context_dir, output, options) } /// Returns the SSL certificates information associated to this call. /// /// Certificate information are cached by libcurl handle connection id, in order to get /// SSL information even if libcurl connection is reused (see ). fn cert_info(&mut self, logger: &mut Logger) -> Result, HttpError> { if let Some(cert_info) = easy_ext::cert_info(&self.handle)? { match Certificate::try_from(cert_info) { Ok(value) => { // We try to get the connection id for the libcurl handle and cache the // certificate. Getting a connection id can fail on older libcurl version, we // don't cache the certificate in these cases. if let Ok(conn_id) = easy_ext::conn_id(&self.handle) { self.certificates.insert(conn_id, value.clone()); } Ok(Some(value)) } Err(message) => { logger.warning(&format!("Can not parse certificate - {message}")); Ok(None) } } } else { // We query the cache to see if we have a cached certificate for this connection; // As libcurl 8.2.0+ exposes the connection id through `CURLINFO_CONN_ID`, we don't // raise an error if we can't get a connection id (older version than 8.2.0), and return // a `None` certificate. match easy_ext::conn_id(&self.handle) { Ok(conn_id) => Ok(self.certificates.get(&conn_id).cloned()), Err(_) => Ok(None), } } } } /// Returns the method used for redirecting a request/response with `response_status`. fn redirect_method(response_status: u32, original_method: Method) -> Method { // This replicates curl's behavior match response_status { 301..=303 => Method("GET".to_string()), // Could be only 307 and 308, but curl does this for all 3xx // codes not converted to GET above. _ => original_method, } } /// Returns cookies from both cookies from the cookie storage and the request. pub fn all_cookies(cookie_storage: &[Cookie], request_spec: &RequestSpec) -> Vec { let mut cookies = request_spec.cookies.clone(); cookies.append( &mut cookie_storage .iter() .filter(|c| c.expires != "1") // cookie expired when libcurl set value to 1? .filter(|c| match_cookie(c, &request_spec.url)) .map(|c| RequestCookie { name: c.name.clone(), value: c.value.clone(), }) .collect(), ); cookies } /// Matches cookie for a given URL. pub fn match_cookie(cookie: &Cookie, url: &Url) -> bool { if let Some(domain) = url.domain() { if cookie.include_subdomain == "FALSE" { if cookie.domain != domain { return false; } } else if !domain.ends_with(cookie.domain.as_str()) { return false; } } url.path().starts_with(cookie.path.as_str()) } impl Header { /// Parses an HTTP header line received from the server /// It does not panic. Just returns `None` if it can not be parsed. pub fn parse(line: &str) -> Option
{ match line.find(':') { Some(index) => { let (name, value) = line.split_at(index); Some(Header::new(name.trim(), value[1..].trim())) } None => None, } } } impl HeaderVec { /// Converts this list of [`Header`] to a lib curl header list. fn to_curl_headers(&self) -> Result { let mut curl_headers = List::new(); for header in self { if header.value.is_empty() { curl_headers.append(&format!("{};", header.name))?; } else { curl_headers.append(&format!("{}: {}", header.name, header.value))?; } } Ok(curl_headers) } } /// Splits an array of bytes into HTTP lines (\r\n separator). fn split_lines(data: &[u8]) -> Vec { let mut lines = vec![]; let mut start = 0; let mut i = 0; while i < (data.len() - 1) { if data[i] == 13 && data[i + 1] == 10 { if let Ok(s) = str::from_utf8(&data[start..i]) { lines.push(s.to_string()); } start = i + 2; i += 2; } else { i += 1; } } lines } /// Decodes optionally header value as text with UTF-8 or ISO-8859-1 encoding. pub fn decode_header(data: &[u8]) -> Option { match str::from_utf8(data) { Ok(s) => Some(s.to_string()), Err(_) => match ISO_8859_1.decode(data, DecoderTrap::Strict) { Ok(s) => Some(s), Err(_) => { println!("Error decoding header both UTF-8 and ISO-8859-1 {data:?}"); None } }, } } /// Converts a list of [`String`] to a libcurl's list of strings. fn to_list(items: &[String]) -> List { let mut list = List::new(); items.iter().for_each(|l| list.append(l).unwrap()); list } /// Parses a cert file name, with a potential user provided password, and returns a pair of /// cert file name, password. /// See /// > In the portion of the argument, you must escape the character ":" as "\:" so /// > that it is not recognized as the password delimiter. Similarly, you must escape the character /// > "\" as "\\" so that it is not recognized as an escape character. fn parse_cert_password(cert_and_pass: &str) -> (String, Option) { let mut iter = cert_and_pass.chars(); let mut cert = String::new(); let mut password = String::new(); // The state of the parser: // - `true` if we're parsing the certificate portion of `cert_and_pass` // - `false` if we're parsing the password portion of `cert_and_pass` let mut parse_cert = true; while let Some(c) = iter.next() { if parse_cert { // We're parsing the certificate, do some escaping match c { '\\' => { // We read the next escaped char, if we failed, we're at the end of this string, // the read char is not an escaping \. match iter.next() { Some(c) => cert.push(c), None => { cert.push('\\'); break; } } } ':' if parse_cert => parse_cert = false, c => cert.push(c), } } else { // We have already found a cert/password separator, we don't need to escape anything now // we just update the password password.push(c); } } if parse_cert { (cert, None) } else { (cert, Some(password)) } } impl From for easy::HttpVersion { fn from(value: RequestedHttpVersion) -> Self { match value { RequestedHttpVersion::Default => easy::HttpVersion::Any, RequestedHttpVersion::Http10 => easy::HttpVersion::V10, RequestedHttpVersion::Http11 => easy::HttpVersion::V11, RequestedHttpVersion::Http2 => easy::HttpVersion::V2, RequestedHttpVersion::Http3 => easy::HttpVersion::V3, } } } impl From for easy::IpResolve { fn from(value: IpResolve) -> Self { match value { IpResolve::Default => easy::IpResolve::Any, IpResolve::IpV4 => easy::IpResolve::V4, IpResolve::IpV6 => easy::IpResolve::V6, } } } #[cfg(test)] mod tests { use std::default::Default; use std::path::PathBuf; use super::*; use crate::util::logger::LoggerOptionsBuilder; use crate::util::term::{Stderr, WriteMode}; #[test] fn test_parse_header() { assert_eq!( Header::parse("Foo: Bar\r\n").unwrap(), Header::new("Foo", "Bar") ); assert_eq!( Header::parse("Location: http://localhost:8000/redirected\r\n").unwrap(), Header::new("Location", "http://localhost:8000/redirected") ); assert!(Header::parse("Foo").is_none()); } #[test] fn test_split_lines_header() { let data = b"GET /hello HTTP/1.1\r\nHost: localhost:8000\r\n\r\n"; let lines = split_lines(data); assert_eq!(lines.len(), 3); assert_eq!(lines.first().unwrap().as_str(), "GET /hello HTTP/1.1"); assert_eq!(lines.get(1).unwrap().as_str(), "Host: localhost:8000"); assert_eq!(lines.get(2).unwrap().as_str(), ""); } #[test] fn test_match_cookie() { let cookie = Cookie { domain: "example.com".to_string(), include_subdomain: "FALSE".to_string(), path: "/".to_string(), https: String::new(), expires: String::new(), name: String::new(), value: String::new(), http_only: false, }; assert!(match_cookie( &cookie, &Url::from_str("http://example.com/toto").unwrap() )); assert!(!match_cookie( &cookie, &Url::from_str("http://sub.example.com/tata").unwrap() )); assert!(!match_cookie( &cookie, &Url::from_str("http://toto/tata").unwrap() )); let cookie = Cookie { domain: "example.com".to_string(), include_subdomain: "TRUE".to_string(), path: "/toto".to_string(), https: String::new(), expires: String::new(), name: String::new(), value: String::new(), http_only: false, }; assert!(match_cookie( &cookie, &Url::from_str("http://example.com/toto").unwrap() )); assert!(match_cookie( &cookie, &Url::from_str("http://sub.example.com/toto").unwrap() )); assert!(!match_cookie( &cookie, &Url::from_str("http://example.com/tata").unwrap() )); } #[test] fn test_redirect_method() { // Status of the response to be redirected | method of the original request | method of the new request let data = [ (301, "GET", "GET"), (301, "POST", "GET"), (301, "DELETE", "GET"), (302, "GET", "GET"), (302, "POST", "GET"), (302, "DELETE", "GET"), (303, "GET", "GET"), (303, "POST", "GET"), (303, "DELETE", "GET"), (304, "GET", "GET"), (304, "POST", "POST"), (304, "DELETE", "DELETE"), (308, "GET", "GET"), (308, "POST", "POST"), (308, "DELETE", "DELETE"), ]; for (status, original, redirected) in data { assert_eq!( redirect_method(status, Method(original.to_string())), Method(redirected.to_string()) ); } } #[test] fn command_line_args() { let mut client = Client::new(); let request = RequestSpec { method: Method("GET".to_string()), url: Url::from_str("https://example.org").unwrap(), ..Default::default() }; let context_dir = ContextDir::default(); let file = Output::File(PathBuf::from("/tmp/foo.bin")); let output = Some(&file); let options = ClientOptions { aws_sigv4: Some("aws:amz:sts".to_string()), cacert_file: Some("/etc/cert.pem".to_string()), compressed: true, connects_to: vec!["example.com:443:host-47.example.com:443".to_string()], insecure: true, max_redirect: Count::Finite(10), path_as_is: true, proxy: Some("localhost:3128".to_string()), no_proxy: None, unix_socket: Some("/var/run/example.sock".to_string()), user: Some("user:password".to_string()), user_agent: Some("my-useragent".to_string()), verbosity: Some(Verbosity::VeryVerbose), ..Default::default() }; let logger_options = LoggerOptionsBuilder::default().build(); let stderr = Stderr::new(WriteMode::Immediate); let mut logger = Logger::new(&logger_options, stderr, &[]); let cmd = client.curl_command_line(&request, &context_dir, output, &options, &mut logger); assert_eq!( cmd.to_string(), "curl \ --aws-sigv4 aws:amz:sts \ --cacert /etc/cert.pem \ --compressed \ --connect-to example.com:443:host-47.example.com:443 \ --insecure \ --max-redirs 10 \ --path-as-is \ --proxy 'localhost:3128' \ --unix-socket '/var/run/example.sock' \ --user 'user:password' \ --user-agent 'my-useragent' \ --output /tmp/foo.bin \ 'https://example.org'" ); } #[test] fn parse_cert_option() { assert_eq!(parse_cert_password("foobar"), ("foobar".to_string(), None)); assert_eq!( parse_cert_password("foobar:toto"), ("foobar".to_string(), Some("toto".to_string())) ); assert_eq!( parse_cert_password("foobar:toto:tata"), ("foobar".to_string(), Some("toto:tata".to_string())) ); assert_eq!( parse_cert_password("foobar:"), ("foobar".to_string(), Some(String::new())) ); assert_eq!( parse_cert_password("foobar\\"), ("foobar\\".to_string(), None) ); assert_eq!( parse_cert_password("foo\\:bar\\:baz:toto:tata\\:tutu"), ( "foo:bar:baz".to_string(), Some("toto:tata\\:tutu".to_string()) ) ); assert_eq!( parse_cert_password("foo\\\\:toto\\:tata:tutu"), ("foo\\".to_string(), Some("toto\\:tata:tutu".to_string())) ); } #[test] fn test_to_curl_headers() { let mut headers = HeaderVec::new(); headers.push(Header::new("foo", "a")); headers.push(Header::new("bar", "b")); headers.push(Header::new("baz", "")); let list = headers.to_curl_headers().unwrap(); assert_eq!( list.iter().collect::>(), vec!["foo: a".as_bytes(), "bar: b".as_bytes(), "baz;".as_bytes()] ); } } hurl-6.1.1/src/http/cookie.rs000064400000000000000000000311301046102023000141660ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ //! This module defines an HTTP ResponseCookie, //! namely the cookie returned from the response Set-Cookie header /// Cookie returned from HTTP Response /// It contains arbitrary attributes. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ResponseCookie { pub name: String, pub value: String, pub attributes: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct CookieAttribute { pub name: String, pub value: Option, } /// See const EXPIRES: &str = "Expires"; /// See const DOMAIN: &str = "Domain"; /// See const HTTP_ONLY: &str = "HttpOnly"; /// See const MAX_AGE: &str = "Max-Age"; /// See const PATH: &str = "Path"; /// See const SAME_SITE: &str = "SameSite"; /// See const SECURE: &str = "Secure"; impl ResponseCookie { /// Parses value from Set-Cookie header into a `ResponseCookie`. /// /// See pub fn parse(s: &str) -> Option { if let Some(index) = s.find('=') { let (name, remaining) = s.split_at(index); let mut tokens: Vec<&str> = remaining[1..].split(';').collect(); let value = tokens.remove(0); let attributes: Vec = tokens .iter() .filter_map(|&s2| CookieAttribute::parse(s2.to_string())) .collect(); Some(ResponseCookie { name: name.to_string(), value: value.to_string(), attributes, }) } else { None } } /// Returns the optional Expires attribute as `String` type. pub fn expires(&self) -> Option { self.attr_as_str(EXPIRES) } /// Returns the optional Max-Age attribute as `i64` type. /// /// If the value is not a valid integer, the attribute is simply ignored pub fn max_age(&self) -> Option { self.attr_as_i64(MAX_AGE) } /// Returns the optional Domain attribute as `String` type. pub fn domain(&self) -> Option { self.attr_as_str(DOMAIN) } /// Returns the optional Path attribute as `String` type. pub fn path(&self) -> Option { self.attr_as_str(PATH) } /// Return true if the Secure attribute is present. pub fn has_secure(&self) -> bool { self.attr_as_bool(SECURE) } /// Return true if the HttpOnly attribute is present. pub fn has_httponly(&self) -> bool { self.attr_as_bool(HTTP_ONLY) } /// Returns the optional SameSite attribute as `String` type. pub fn samesite(&self) -> Option { self.attr_as_str(SAME_SITE) } /// Converts a cookie attribute value named `name` into a string. fn attr_as_str(&self, name: &str) -> Option { for attr in &self.attributes { if attr.name.to_lowercase() == name.to_lowercase() { return attr.value.clone(); } } None } /// Converts a cookie attribute value named `name` into a boolean. fn attr_as_bool(&self, name: &str) -> bool { for attr in &self.attributes { if attr.name.to_lowercase() == name.to_lowercase() && attr.value.is_none() { return true; } } false } /// Converts a cookie attribute value named `name` into an integer. fn attr_as_i64(&self, name: &str) -> Option { for attr in &self.attributes { if attr.name.to_lowercase() == name.to_lowercase() { if let Some(v) = &attr.value { if let Ok(v2) = v.as_str().parse::() { return Some(v2); } } } } None } } impl CookieAttribute { fn parse(s: String) -> Option { if s.is_empty() { None } else { let tokens: Vec<&str> = s.split('=').collect(); Some(CookieAttribute { name: tokens.first().unwrap().to_string().trim().to_string(), value: tokens.get(1).map(|e| e.to_string()), }) } } } #[cfg(test)] pub mod tests { use super::*; #[test] fn test_parse_cookie_attribute() { assert_eq!( CookieAttribute::parse("Expires=Wed, 21 Oct 2015 07:28:00 GMT".to_string()).unwrap(), CookieAttribute { name: "Expires".to_string(), value: Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()) } ); assert_eq!( CookieAttribute::parse("HttpOnly".to_string()).unwrap(), CookieAttribute { name: "HttpOnly".to_string(), value: None } ); assert_eq!( CookieAttribute::parse("httponly".to_string()).unwrap(), CookieAttribute { name: "httponly".to_string(), value: None } ); assert_eq!(CookieAttribute::parse(String::new()), None); } #[test] fn test_session_cookie() { let cookie = ResponseCookie { name: "sessionId".to_string(), value: "38afes7a8".to_string(), attributes: vec![], }; assert_eq!( ResponseCookie::parse("sessionId=38afes7a8").unwrap(), cookie ); assert_eq!(cookie.expires(), None); assert_eq!(cookie.max_age(), None); assert_eq!(cookie.domain(), None); assert_eq!(cookie.path(), None); assert!(!cookie.has_secure()); assert!(!cookie.has_httponly()); assert_eq!(cookie.samesite(), None); } #[test] fn test_permanent_cookie() { let cookie = ResponseCookie { name: "id".to_string(), value: "a3fWa".to_string(), attributes: vec![CookieAttribute { name: "Expires".to_string(), value: Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()), }], }; assert_eq!( ResponseCookie::parse("id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT").unwrap(), cookie ); assert_eq!( cookie.expires(), Some("Wed, 21 Oct 2015 07:28:00 GMT".to_string()) ); assert_eq!(cookie.max_age(), None); assert_eq!(cookie.domain(), None); assert_eq!(cookie.path(), None); assert!(!cookie.has_secure()); assert!(!cookie.has_httponly()); assert_eq!(cookie.samesite(), None); } #[test] fn test_permanent2_cookie() { let cookie = ResponseCookie { name: "id".to_string(), value: "a3fWa".to_string(), attributes: vec![CookieAttribute { name: "Max-Age".to_string(), value: Some("2592000".to_string()), }], }; assert_eq!( ResponseCookie::parse("id=a3fWa; Max-Age=2592000").unwrap(), cookie ); assert_eq!(cookie.expires(), None); assert_eq!(cookie.max_age(), Some(2592000)); assert_eq!(cookie.domain(), None); assert_eq!(cookie.path(), None); assert!(!cookie.has_secure()); assert!(!cookie.has_httponly()); assert_eq!(cookie.samesite(), None); } #[test] fn test_lsid_cookie() { let cookie = ResponseCookie { name: "LSID".to_string(), value: "DQAAAK…Eaem_vYg".to_string(), attributes: vec![ CookieAttribute { name: "Path".to_string(), value: Some("/accounts".to_string()), }, CookieAttribute { name: "Expires".to_string(), value: Some("Wed, 13 Jan 2021 22:23:01 GMT".to_string()), }, CookieAttribute { name: "Secure".to_string(), value: None, }, CookieAttribute { name: "HttpOnly".to_string(), value: None, }, ], }; assert_eq!( ResponseCookie::parse("LSID=DQAAAK…Eaem_vYg; Path=/accounts; Expires=Wed, 13 Jan 2021 22:23:01 GMT; Secure; HttpOnly").unwrap(), cookie ); assert_eq!( cookie.expires(), Some("Wed, 13 Jan 2021 22:23:01 GMT".to_string()) ); assert_eq!(cookie.max_age(), None); assert_eq!(cookie.domain(), None); assert_eq!(cookie.path(), Some("/accounts".to_string())); assert!(cookie.has_secure()); assert!(cookie.has_httponly()); assert_eq!(cookie.samesite(), None); } #[test] fn test_hsid_cookie() { let cookie = ResponseCookie { name: "HSID".to_string(), value: "AYQEVn…DKrdst".to_string(), attributes: vec![ CookieAttribute { name: "Domain".to_string(), value: Some(".foo.com".to_string()), }, CookieAttribute { name: "Path".to_string(), value: Some("/".to_string()), }, CookieAttribute { name: "Expires".to_string(), value: Some("Wed, 13 Jan 2021 22:23:01 GMT".to_string()), }, CookieAttribute { name: "HttpOnly".to_string(), value: None, }, ], }; assert_eq!( ResponseCookie::parse("HSID=AYQEVn…DKrdst; Domain=.foo.com; Path=/; Expires=Wed, 13 Jan 2021 22:23:01 GMT; HttpOnly").unwrap(), cookie ); assert_eq!( cookie.expires(), Some("Wed, 13 Jan 2021 22:23:01 GMT".to_string()) ); assert_eq!(cookie.max_age(), None); assert_eq!(cookie.domain(), Some(".foo.com".to_string())); assert_eq!(cookie.path(), Some("/".to_string())); assert!(!cookie.has_secure()); assert!(cookie.has_httponly()); assert_eq!(cookie.samesite(), None); } #[test] fn test_trailing_semicolon() { assert_eq!( ResponseCookie::parse("xx=yy;").unwrap(), ResponseCookie { name: "xx".to_string(), value: "yy".to_string(), attributes: vec![] } ); } #[test] fn test_invalid_cookie() { assert_eq!(ResponseCookie::parse("xx"), None); } #[test] fn test_cookie_with_invalid_attributes() { let cookie = ResponseCookie { name: "id".to_string(), value: "a3fWa".to_string(), attributes: vec![ CookieAttribute { name: "Secure".to_string(), value: Some("0".to_string()), }, CookieAttribute { name: "Max-Age".to_string(), value: Some(String::new()), }, ], }; assert_eq!( ResponseCookie::parse("id=a3fWa; Secure=0; Max-Age=").unwrap(), cookie ); assert_eq!(cookie.expires(), None); assert_eq!(cookie.max_age(), None); assert_eq!(cookie.domain(), None); assert_eq!(cookie.path(), None); assert!(!cookie.has_secure()); assert!(!cookie.has_httponly()); assert_eq!(cookie.samesite(), None); } } hurl-6.1.1/src/http/core.rs000064400000000000000000000164611046102023000136570ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use core::fmt; use std::str::FromStr; use crate::util::redacted::Redact; /// [Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) returned by /// the server with `Set-Cookie` header, and saved in the cookie storage of the internal HTTP /// engine. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Cookie { /// Defines the host to which the cookie will be sent. pub domain: String, pub include_subdomain: String, /// Indicates the path that must exist in the requested URL for the browser to send the Cookie header. pub path: String, /// Indicates that the cookie is sent to the server only when a request is made with the https: scheme pub https: String, /// Indicates the maximum lifetime of the cookie as an HTTP-date timestamp. pub expires: String, pub name: String, pub value: String, /// Forbids JavaScript from accessing the cookie. pub http_only: bool, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct RequestCookie { pub name: String, pub value: String, } /// A key/value pair used for query params, form params and multipart-form params. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Param { pub name: String, pub value: String, } impl Param { /// Creates a new param pair. pub fn new(name: &str, value: &str) -> Param { Param { name: name.to_string(), value: value.to_string(), } } } impl fmt::Display for Cookie { /// Formats this cookie using Netscape cookie format. /// /// /// /// > The layout of Netscape's cookies.txt file is such that each line contains one name-value /// > pair. An example cookies.txt file may have an entry that looks like this: /// > /// > `.netscape.com TRUE / FALSE 946684799 NETSCAPE_ID 100103` /// > /// > Each line represents a single piece of stored information. A tab is inserted between each /// > of the fields. /// > From left-to-right, here is what each field represents: /// > - domain - The domain that created AND that can read the variable. /// > - flag - A TRUE/FALSE value indicating if all machines within a given domain can access /// > the variable. This value is set automatically by the browser, depending on the value you /// > set for domain. /// > - path - The path within the domain that the variable is valid for. /// > - secure - A TRUE/FALSE value indicating if a secure connection with the domain is /// > needed to access the variable. /// > - expiration - The UNIX time that the variable will expire on. UNIX time is defined as the /// > - number of seconds since Jan 1, 1970 00:00:00 GMT. /// > - name - The name of the variable. /// > - value - The value of the variable. fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, "{}{}\t{}\t{}\t{}\t{}\t{}\t{}", if self.http_only { "#HttpOnly_" } else { "" }, self.domain, self.include_subdomain, self.path, self.https, self.expires, self.name, self.value ) } } impl Redact for Cookie { fn redact(&self, secrets: &[impl AsRef]) -> String { format!( "{}{}\t{}\t{}\t{}\t{}\t{}\t{}", if self.http_only { "#HttpOnly_" } else { "" }, self.domain, self.include_subdomain, self.path, self.https, self.expires, self.name, self.value.redact(secrets) ) } } impl fmt::Display for RequestCookie { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}={}", self.name, self.value) } } impl fmt::Display for Param { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}: {}", self.name, self.value) } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ParseCookieError; impl FromStr for Cookie { type Err = ParseCookieError; fn from_str(s: &str) -> Result { let tokens = s.split_ascii_whitespace().collect::>(); let (http_only, domain) = if let Some(&v) = tokens.first() { if let Some(domain) = v.strip_prefix("#HttpOnly_") { (true, domain.to_string()) } else { (false, v.to_string()) } } else { return Err(ParseCookieError); }; let include_subdomain = if let Some(&v) = tokens.get(1) { v.to_string() } else { return Err(ParseCookieError); }; let path = if let Some(&v) = tokens.get(2) { v.to_string() } else { return Err(ParseCookieError); }; let https = if let Some(&v) = tokens.get(3) { v.to_string() } else { return Err(ParseCookieError); }; let expires = if let Some(&v) = tokens.get(4) { v.to_string() } else { return Err(ParseCookieError); }; let name = if let Some(&v) = tokens.get(5) { v.to_string() } else { return Err(ParseCookieError); }; let value = if let Some(&v) = tokens.get(6) { v.to_string() } else { String::new() }; Ok(Cookie { domain, include_subdomain, path, https, expires, name, value, http_only, }) } } #[cfg(test)] mod tests { use super::*; #[test] pub fn parse_cookie_from_str() { assert_eq!( Cookie::from_str("httpbin.org\tFALSE\t/\tFALSE\t0\tcookie1\tvalueA").unwrap(), Cookie { domain: "httpbin.org".to_string(), include_subdomain: "FALSE".to_string(), path: "/".to_string(), https: "FALSE".to_string(), expires: "0".to_string(), name: "cookie1".to_string(), value: "valueA".to_string(), http_only: false, } ); assert_eq!( Cookie::from_str("localhost\tFALSE\t/\tFALSE\t1\tcookie2\t").unwrap(), Cookie { domain: "localhost".to_string(), include_subdomain: "FALSE".to_string(), path: "/".to_string(), https: "FALSE".to_string(), expires: "1".to_string(), name: "cookie2".to_string(), value: String::new(), http_only: false, } ); assert_eq!(Cookie::from_str("xxx").err().unwrap(), ParseCookieError); } } hurl-6.1.1/src/http/curl_cmd.rs000064400000000000000000001002421046102023000145060ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use core::fmt; use std::collections::HashMap; use std::path::Path; use hurl_core::typing::Count; use crate::http::client::all_cookies; use crate::http::{ Body, ClientOptions, Cookie, FileParam, Header, HeaderVec, IpResolve, Method, MultipartParam, Param, RequestSpec, RequestedHttpVersion, CONTENT_TYPE, }; use crate::runner::Output; use crate::util::path::ContextDir; /// Represents a curl command, with arguments. #[derive(Clone, Debug, PartialEq, Eq)] pub struct CurlCmd { /// The args of this command. args: Vec, } impl fmt::Display for CurlCmd { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.args.join(" ")) } } impl Default for CurlCmd { fn default() -> Self { CurlCmd { args: vec!["curl".to_string()], } } } impl CurlCmd { /// Creates a new curl command, based on an HTTP request, cookies, a context directory, output /// and runner options. pub fn new( request_spec: &RequestSpec, cookies: &[Cookie], context_dir: &ContextDir, output: Option<&Output>, options: &ClientOptions, ) -> Self { let mut args = vec!["curl".to_string()]; let mut params = method_params(request_spec); args.append(&mut params); let options_headers = options .headers .iter() .map(|h| h.as_str()) .collect::>(); let headers = &request_spec.headers.aggregate_raw_headers(&options_headers); let mut params = headers_params( headers, request_spec.implicit_content_type.as_deref(), &request_spec.body, ); args.append(&mut params); let mut params = body_params(request_spec, context_dir); args.append(&mut params); let mut params = cookies_params(request_spec, cookies); args.append(&mut params); let mut params = other_options_params(context_dir, output, options); args.append(&mut params); let mut params = url_param(request_spec); args.append(&mut params); CurlCmd { args } } } /// Returns the curl args corresponding to the HTTP method, from a request spec. fn method_params(request_spec: &RequestSpec) -> Vec { let has_body = !request_spec.multipart.is_empty() || !request_spec.form.is_empty() || !request_spec.body.bytes().is_empty(); request_spec.method.curl_args(has_body) } /// Returns the curl args corresponding to the HTTP headers, from a list of headers, /// an optional implicit content type, and the request body. fn headers_params( headers: &HeaderVec, implicit_content_type: Option<&str>, body: &Body, ) -> Vec { let mut args = vec![]; for header in headers.iter() { args.append(&mut header.curl_args()); } let has_explicit_content_type = headers.contains_key(CONTENT_TYPE); if has_explicit_content_type { return args; } if let Some(content_type) = implicit_content_type { if content_type != "application/x-www-form-urlencoded" && content_type != "multipart/form-data" { args.push("--header".to_string()); args.push(format!("'{}: {content_type}'", CONTENT_TYPE)); } } else if !body.bytes().is_empty() { match body { Body::Text(_) => { args.push("--header".to_string()); args.push(format!("'{}:'", CONTENT_TYPE)); } Body::Binary(_) => { args.push("--header".to_string()); args.push(format!("'{}: application/octet-stream'", CONTENT_TYPE)); } Body::File(_, _) => { args.push("--header".to_string()); args.push(format!("'{}:'", CONTENT_TYPE)); } } } args } /// Returns the curl args corresponding to the request body, from a request spec. fn body_params(request_spec: &RequestSpec, context_dir: &ContextDir) -> Vec { let mut args = vec![]; for param in request_spec.form.iter() { args.push("--data".to_string()); args.push(format!("'{}'", param.curl_arg_escape())); } for param in request_spec.multipart.iter() { args.push("--form".to_string()); args.push(format!("'{}'", param.curl_arg(context_dir))); } if request_spec.body.bytes().is_empty() { return args; } // See and : // // > -d, --data // > ... // > If you start the data with the letter @, the rest should be a file name to read the // > data from, or - if you want curl to read the data from stdin. Posting data from a // > file named 'foobar' would thus be done with -d, --data @foobar. When -d, --data is // > told to read from a file like that, carriage returns and newlines will be stripped // > out. If you do not want the @ character to have a special interpretation use // > --data-raw instead. // > ... // > --data-binary // > // > (HTTP) This posts data exactly as specified with no extra processing whatsoever. // // In summary: if the payload is a file (@foo.bin), we must use --data-binary option in // order to curl to not process the data sent. let param = match request_spec.body { Body::File(_, _) => "--data-binary", _ => "--data", }; args.push(param.to_string()); args.push(request_spec.body.curl_arg(context_dir)); args } /// Returns the curl args corresponding to a list of cookies. fn cookies_params(request_spec: &RequestSpec, cookies: &[Cookie]) -> Vec { let mut args = vec![]; let cookies = all_cookies(cookies, request_spec); if !cookies.is_empty() { args.push("--cookie".to_string()); args.push(format!( "'{}'", cookies .iter() .map(|c| c.to_string()) .collect::>() .join("; ") )); } args } /// Returns the curl args corresponding to run options. fn other_options_params( context_dir: &ContextDir, output: Option<&Output>, options: &ClientOptions, ) -> Vec { let mut args = options.curl_args(); // --output is not an option of the HTTP client, we deal with it here: match output { Some(Output::File(filename)) => { let filename = context_dir.resolved_path(filename); args.push("--output".to_string()); args.push(filename.to_string_lossy().to_string()); } Some(Output::Stdout) => { args.push("--output".to_string()); args.push("-".to_string()); } None => {} } args } /// Returns the curl args corresponding to the URL, from a request spec. fn url_param(request_spec: &RequestSpec) -> Vec { let mut args = vec![]; let querystring = if request_spec.querystring.is_empty() { String::new() } else { let params = request_spec .querystring .iter() .map(|p| p.curl_arg_escape()) .collect::>(); params.join("&") }; let url = if querystring.as_str() == "" { request_spec.url.raw() } else if request_spec.url.raw().contains('?') { format!("{}&{}", request_spec.url.raw(), querystring) } else { format!("{}?{}", request_spec.url.raw(), querystring) }; let url = format!("'{url}'"); // curl support "globbing" // {,},[,] have special meaning to curl, in order to support templating. // We have two options: // - either we encode {,},[,] to %7b,%7d,%5b,%%5d // - or we let the url "as-it" and use curl [`--globoff`](https://curl.se/docs/manpage.html#-g) option. // We're going with the second one! if url.contains('{') || url.contains('}') || url.contains('[') || url.contains(']') { args.push("--globoff".to_string()); } args.push(url); args } fn encode_byte(b: u8) -> String { format!("\\x{b:02x}") } /// Encode bytes to a shell string. fn encode_bytes(bytes: &[u8]) -> String { bytes.iter().map(|b| encode_byte(*b)).collect() } impl Method { /// Returns the curl args for HTTP method, given the request has a body or not. fn curl_args(&self, has_body: bool) -> Vec { match self.0.as_str() { "GET" => { if has_body { vec!["--request".to_string(), "GET".to_string()] } else { vec![] } } "HEAD" => vec!["--head".to_string()], "POST" => { if has_body { vec![] } else { vec!["--request".to_string(), "POST".to_string()] } } s => vec!["--request".to_string(), s.to_string()], } } } impl Header { fn curl_args(&self) -> Vec { let name = &self.name; let value = &self.value; vec![ "--header".to_string(), if self.value.is_empty() { encode_shell_string(&format!("{name};")) } else { encode_shell_string(&format!("{name}: {value}")) }, ] } } impl Param { fn curl_arg_escape(&self) -> String { let name = &self.name; let value = escape_url(&self.value); format!("{name}={value}") } fn curl_arg(&self) -> String { let name = &self.name; let value = &self.value; format!("{name}={value}") } } impl MultipartParam { fn curl_arg(&self, context_dir: &ContextDir) -> String { match self { MultipartParam::Param(param) => param.curl_arg(), MultipartParam::FileParam(FileParam { name, filename, content_type, .. }) => { let path = context_dir.resolved_path(Path::new(filename)); let value = format!("@{};type={}", path.to_string_lossy(), content_type); format!("{name}={value}") } } } } impl Body { fn curl_arg(&self, context_dir: &ContextDir) -> String { match self { Body::Text(s) => encode_shell_string(s), Body::Binary(bytes) => format!("$'{}'", encode_bytes(bytes)), Body::File(_, filename) => { let path = context_dir.resolved_path(Path::new(filename)); format!("'@{}'", path.to_string_lossy()) } } } } impl ClientOptions { /// Returns the list of options for the curl command line equivalent to this [`ClientOptions`]. fn curl_args(&self) -> Vec { let mut arguments = vec![]; if let Some(ref aws_sigv4) = self.aws_sigv4 { arguments.push("--aws-sigv4".to_string()); arguments.push(aws_sigv4.clone()); } if let Some(ref cacert_file) = self.cacert_file { arguments.push("--cacert".to_string()); arguments.push(cacert_file.clone()); } if let Some(ref client_cert_file) = self.client_cert_file { arguments.push("--cert".to_string()); arguments.push(client_cert_file.clone()); } if let Some(ref client_key_file) = self.client_key_file { arguments.push("--key".to_string()); arguments.push(client_key_file.clone()); } if self.compressed { arguments.push("--compressed".to_string()); } if self.connect_timeout != ClientOptions::default().connect_timeout { arguments.push("--connect-timeout".to_string()); arguments.push(self.connect_timeout.as_secs().to_string()); } for connect in self.connects_to.iter() { arguments.push("--connect-to".to_string()); arguments.push(connect.clone()); } if let Some(ref cookie_file) = self.cookie_input_file { arguments.push("--cookie".to_string()); arguments.push(cookie_file.clone()); } match self.http_version { RequestedHttpVersion::Default => {} RequestedHttpVersion::Http10 => arguments.push("--http1.0".to_string()), RequestedHttpVersion::Http11 => arguments.push("--http1.1".to_string()), RequestedHttpVersion::Http2 => arguments.push("--http2".to_string()), RequestedHttpVersion::Http3 => arguments.push("--http3".to_string()), } if self.insecure { arguments.push("--insecure".to_string()); } match self.ip_resolve { IpResolve::Default => {} IpResolve::IpV4 => arguments.push("--ipv4".to_string()), IpResolve::IpV6 => arguments.push("--ipv6".to_string()), } if self.follow_location_trusted { arguments.push("--location-trusted".to_string()); } else if self.follow_location { arguments.push("--location".to_string()); } if let Some(max_filesize) = self.max_filesize { arguments.push("--max-filesize".to_string()); arguments.push(max_filesize.to_string()); } if let Some(max_speed) = self.max_recv_speed { arguments.push("--limit-rate".to_string()); arguments.push(max_speed.to_string()); } // We don't implement --limit-rate for self.max_send_speed as curl limit-rate seems // to limit both upload and download speed. There is no distinct option.. if self.max_redirect != ClientOptions::default().max_redirect { let max_redirect = match self.max_redirect { Count::Finite(n) => n as i32, Count::Infinite => -1, }; arguments.push("--max-redirs".to_string()); arguments.push(max_redirect.to_string()); } if let Some(filename) = &self.netrc_file { arguments.push("--netrc-file".to_string()); arguments.push(format!("'{filename}'")); } if self.netrc_optional { arguments.push("--netrc-optional".to_string()); } if self.netrc { arguments.push("--netrc".to_string()); } if self.path_as_is { arguments.push("--path-as-is".to_string()); } if let Some(ref proxy) = self.proxy { arguments.push("--proxy".to_string()); arguments.push(format!("'{proxy}'")); } for resolve in self.resolves.iter() { arguments.push("--resolve".to_string()); arguments.push(resolve.clone()); } if self.ssl_no_revoke { arguments.push("--ssl-no-revoke".to_string()); } if self.timeout != ClientOptions::default().timeout { arguments.push("--timeout".to_string()); arguments.push(self.timeout.as_secs().to_string()); } if let Some(ref unix_socket) = self.unix_socket { arguments.push("--unix-socket".to_string()); arguments.push(format!("'{unix_socket}'")); } if let Some(ref user) = self.user { arguments.push("--user".to_string()); arguments.push(format!("'{user}'")); } if let Some(ref user_agent) = self.user_agent { arguments.push("--user-agent".to_string()); arguments.push(format!("'{user_agent}'")); } arguments } } fn escape_url(s: &str) -> String { percent_encoding::percent_encode(s.as_bytes(), percent_encoding::NON_ALPHANUMERIC).to_string() } fn encode_shell_string(s: &str) -> String { // $'...' form will be used to encode escaped sequence if escape_mode(s) { let escaped = escape_string(s); format!("$'{escaped}'") } else { format!("'{s}'") } } // the shell string must be in escaped mode ($'...') // if it contains \n, \t or ' fn escape_mode(s: &str) -> bool { for c in s.chars() { if c == '\n' || c == '\t' || c == '\'' { return true; } } false } fn escape_string(s: &str) -> String { let mut escaped_sequences = HashMap::new(); escaped_sequences.insert('\n', "\\n"); escaped_sequences.insert('\t', "\\t"); escaped_sequences.insert('\'', "\\'"); escaped_sequences.insert('\\', "\\\\"); let mut escaped = String::new(); for c in s.chars() { match escaped_sequences.get(&c) { None => escaped.push(c), Some(escaped_seq) => escaped.push_str(escaped_seq), } } escaped } #[cfg(test)] mod tests { use std::path::Path; use std::str::FromStr; use std::time::Duration; use hurl_core::typing::BytesPerSec; use super::*; use crate::http::{HeaderVec, Url}; #[test] fn hello_request_with_default_options() { let mut request = RequestSpec { method: Method("GET".to_string()), url: Url::from_str("http://localhost:8000/hello").unwrap(), ..Default::default() }; let context_dir = &ContextDir::default(); let cookies = vec![]; let options = ClientOptions::default(); let output = None; let cmd = CurlCmd::new(&request, &cookies, context_dir, output.as_ref(), &options); assert_eq!(cmd.to_string(), "curl 'http://localhost:8000/hello'"); // Same requests with some output: let output = Some(Output::new("foo.out")); let cmd = CurlCmd::new(&request, &cookies, context_dir, output.as_ref(), &options); assert_eq!( cmd.to_string(), "curl \ --output foo.out \ 'http://localhost:8000/hello'" ); // With some headers let mut headers = HeaderVec::new(); headers.push(Header::new("User-Agent", "iPhone")); headers.push(Header::new("Foo", "Bar")); request.headers = headers; let cmd = CurlCmd::new(&request, &cookies, context_dir, output.as_ref(), &options); assert_eq!( cmd.to_string(), "curl \ --header 'User-Agent: iPhone' \ --header 'Foo: Bar' \ --output foo.out \ 'http://localhost:8000/hello'" ); // With some cookies: let cookies = vec![ Cookie { domain: "localhost".to_string(), include_subdomain: "TRUE".to_string(), path: "/".to_string(), https: "FALSE".to_string(), expires: "0".to_string(), name: "cookie1".to_string(), value: "valueA".to_string(), http_only: false, }, Cookie { domain: "localhost".to_string(), include_subdomain: "FALSE".to_string(), path: "/".to_string(), https: "FALSE".to_string(), expires: "1".to_string(), name: "cookie2".to_string(), value: String::new(), http_only: true, }, ]; let cmd = CurlCmd::new(&request, &cookies, context_dir, output.as_ref(), &options); assert_eq!( cmd.to_string(), "curl \ --header 'User-Agent: iPhone' \ --header 'Foo: Bar' \ --cookie 'cookie1=valueA' \ --output foo.out \ 'http://localhost:8000/hello'" ); } #[test] fn hello_request_with_options() { let request = RequestSpec { method: Method("GET".to_string()), url: Url::from_str("http://localhost:8000/hello").unwrap(), ..Default::default() }; let context_dir = &ContextDir::default(); let cookies = vec![]; let options = ClientOptions { allow_reuse: true, aws_sigv4: None, cacert_file: None, client_cert_file: None, client_key_file: None, compressed: true, connect_timeout: Duration::from_secs(20), connects_to: vec!["example.com:443:host-47.example.com:443".to_string()], cookie_input_file: Some("cookie_file".to_string()), follow_location: true, follow_location_trusted: false, headers: vec![ "Test-Header-1: content-1".to_string(), "Test-Header-2: content-2".to_string(), "Test-Header-Empty:".to_string(), ], http_version: RequestedHttpVersion::Http10, insecure: true, ip_resolve: IpResolve::IpV6, max_filesize: None, max_recv_speed: Some(BytesPerSec(8000)), max_redirect: Count::Finite(10), max_send_speed: Some(BytesPerSec(8000)), netrc: false, netrc_file: Some("/var/run/netrc".to_string()), netrc_optional: true, path_as_is: true, proxy: Some("localhost:3128".to_string()), no_proxy: None, resolves: vec![ "foo.com:80:192.168.0.1".to_string(), "bar.com:443:127.0.0.1".to_string(), ], ssl_no_revoke: false, timeout: Duration::from_secs(10), unix_socket: Some("/var/run/example.sock".to_string()), user: Some("user:password".to_string()), user_agent: Some("my-useragent".to_string()), verbosity: None, }; let cmd = CurlCmd::new(&request, &cookies, context_dir, None, &options); assert_eq!( cmd.to_string(), "curl \ --header 'Test-Header-1: content-1' \ --header 'Test-Header-2: content-2' \ --header 'Test-Header-Empty;' \ --compressed \ --connect-timeout 20 \ --connect-to example.com:443:host-47.example.com:443 \ --cookie cookie_file \ --http1.0 \ --insecure \ --ipv6 \ --location \ --limit-rate 8000 \ --max-redirs 10 \ --netrc-file '/var/run/netrc' \ --netrc-optional \ --path-as-is \ --proxy 'localhost:3128' \ --resolve foo.com:80:192.168.0.1 \ --resolve bar.com:443:127.0.0.1 \ --timeout 10 \ --unix-socket '/var/run/example.sock' \ --user 'user:password' \ --user-agent 'my-useragent' \ 'http://localhost:8000/hello'" ); } #[test] fn url_with_dot() { let request = RequestSpec { method: Method("GET".to_string()), url: Url::from_str("https://example.org/hello/../to/../your/../file").unwrap(), ..Default::default() }; let context_dir = &ContextDir::default(); let cookies = vec![]; let options = ClientOptions::default(); let output = None; let cmd = CurlCmd::new(&request, &cookies, context_dir, output.as_ref(), &options); assert_eq!( cmd.to_string(), "curl 'https://example.org/hello/../to/../your/../file'" ); } #[test] fn url_with_curl_glob() { let request = RequestSpec { method: Method("GET".to_string()), url: Url::from_str("http://foo.com?param1=value1¶m2={bar}").unwrap(), ..Default::default() }; let context_dir = &ContextDir::default(); let cookies = vec![]; let options = ClientOptions::default(); let output = None; let cmd = CurlCmd::new(&request, &cookies, context_dir, output.as_ref(), &options); assert_eq!( cmd.to_string(), "curl \ --globoff \ 'http://foo.com?param1=value1¶m2={bar}'" ); } #[test] fn query_request() { let mut request = RequestSpec { method: Method("GET".to_string()), url: Url::from_str("http://localhost:8000/querystring-params").unwrap(), querystring: vec![ Param { name: String::from("param1"), value: String::from("value1"), }, Param { name: String::from("param2"), value: String::from("a b"), }, ], ..Default::default() }; let context_dir = &ContextDir::default(); let cookies = vec![]; let options = ClientOptions::default(); let output = None; let cmd = CurlCmd::new(&request, &cookies, context_dir, output.as_ref(), &options); assert_eq!( cmd.to_string(), "curl 'http://localhost:8000/querystring-params?param1=value1¶m2=a%20b'", ); // Add som query param in the URL request.url = Url::from_str("http://localhost:8000/querystring-params?param3=foo¶m4=bar") .unwrap(); let cmd = CurlCmd::new(&request, &cookies, context_dir, output.as_ref(), &options); assert_eq!( cmd.to_string(), "curl 'http://localhost:8000/querystring-params?param3=foo¶m4=bar¶m1=value1¶m2=a%20b'", ); } #[test] fn form_request() { let mut headers = HeaderVec::new(); headers.push(Header::new( "Content-Type", "application/x-www-form-urlencoded", )); let request = RequestSpec { method: Method("POST".to_string()), url: Url::from_str("http://localhost/form-params").unwrap(), headers, form: vec![Param::new("param1", "value1"), Param::new("param2", "a b")], implicit_content_type: Some("multipart/form-data".to_string()), ..Default::default() }; let context_dir = &ContextDir::default(); let cookies = vec![]; let options = ClientOptions::default(); let output = None; let cmd = CurlCmd::new(&request, &cookies, context_dir, output.as_ref(), &options); assert_eq!( cmd.to_string(), "curl \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data 'param1=value1' \ --data 'param2=a%20b' \ 'http://localhost/form-params'" ); } #[test] fn json_request() { let mut headers = HeaderVec::new(); headers.push(Header::new("content-type", "application/vnd.api+json")); let mut request = RequestSpec { method: Method("POST".to_string()), url: Url::from_str("http://localhost/json").unwrap(), headers, body: Body::Text(String::new()), implicit_content_type: Some("application/json".to_string()), ..Default::default() }; let context_dir = &ContextDir::default(); let cookies = vec![]; let options = ClientOptions::default(); let output = None; let cmd = CurlCmd::new(&request, &cookies, context_dir, output.as_ref(), &options); assert_eq!( cmd.to_string(), "curl \ --request POST \ --header 'content-type: application/vnd.api+json' \ 'http://localhost/json'" ); // Add a non-empty body request.body = Body::Text("{\"foo\":\"bar\"}".to_string()); let cmd = CurlCmd::new(&request, &cookies, context_dir, output.as_ref(), &options); assert_eq!( cmd.to_string(), "curl \ --header 'content-type: application/vnd.api+json' \ --data '{\"foo\":\"bar\"}' \ 'http://localhost/json'" ); // Change method request.method = Method("PUT".to_string()); let cmd = CurlCmd::new(&request, &cookies, context_dir, output.as_ref(), &options); assert_eq!( cmd.to_string(), "curl \ --request PUT \ --header 'content-type: application/vnd.api+json' \ --data '{\"foo\":\"bar\"}' \ 'http://localhost/json'" ); } #[test] fn post_binary_file() { let request = RequestSpec { method: Method("POST".to_string()), url: Url::from_str("http://localhost:8000/hello").unwrap(), body: Body::File(b"Hello World!".to_vec(), "foo.bin".to_string()), ..Default::default() }; let context_dir = &ContextDir::default(); let cookies = vec![]; let options = ClientOptions::default(); let output = None; let cmd = CurlCmd::new(&request, &cookies, context_dir, output.as_ref(), &options); assert_eq!( cmd.to_string(), "curl \ --header 'Content-Type:' \ --data-binary '@foo.bin' \ 'http://localhost:8000/hello'" ); } #[test] fn test_encode_byte() { assert_eq!(encode_byte(1), "\\x01".to_string()); assert_eq!(encode_byte(32), "\\x20".to_string()); } #[test] fn header_curl_args() { assert_eq!( Header::new("Host", "example.com").curl_args(), vec!["--header".to_string(), "'Host: example.com'".to_string()] ); assert_eq!( Header::new("If-Match", "\"e0023aa4e\"").curl_args(), vec![ "--header".to_string(), "'If-Match: \"e0023aa4e\"'".to_string() ] ); } #[test] fn param_curl_args() { assert_eq!( Param { name: "param1".to_string(), value: "value1".to_string(), } .curl_arg(), "param1=value1".to_string() ); assert_eq!( Param { name: "param2".to_string(), value: String::new(), } .curl_arg(), "param2=".to_string() ); assert_eq!( Param { name: "param3".to_string(), value: "a=b".to_string(), } .curl_arg_escape(), "param3=a%3Db".to_string() ); assert_eq!( Param { name: "param4".to_string(), value: "1,2,3".to_string(), } .curl_arg_escape(), "param4=1%2C2%2C3".to_string() ); } #[test] fn test_encode_body() { let current_dir = Path::new("/tmp"); let file_root = Path::new("/tmp"); let context_dir = ContextDir::new(current_dir, file_root); assert_eq!( Body::Text("hello".to_string()).curl_arg(&context_dir), "'hello'".to_string() ); if cfg!(unix) { assert_eq!( Body::File(vec![], "filename".to_string()).curl_arg(&context_dir), "'@/tmp/filename'".to_string() ); } assert_eq!( Body::Binary(vec![1, 2, 3]).curl_arg(&context_dir), "$'\\x01\\x02\\x03'".to_string() ); } #[test] fn test_encode_shell_string() { assert_eq!(encode_shell_string("hello"), "'hello'"); assert_eq!(encode_shell_string("\\n"), "'\\n'"); assert_eq!(encode_shell_string("'"), "$'\\''"); assert_eq!(encode_shell_string("\\'"), "$'\\\\\\''"); assert_eq!(encode_shell_string("\n"), "$'\\n'"); } #[test] fn test_escape_string() { assert_eq!(escape_string("hello"), "hello"); assert_eq!(escape_string("\\n"), "\\\\n"); assert_eq!(escape_string("'"), "\\'"); assert_eq!(escape_string("\\'"), "\\\\\\'"); assert_eq!(escape_string("\n"), "\\n"); } #[test] fn test_escape_mode() { assert!(!escape_mode("hello")); assert!(!escape_mode("\\")); assert!(escape_mode("'")); assert!(escape_mode("\n")); } } hurl-6.1.1/src/http/debug.rs000064400000000000000000000053761046102023000140200ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use encoding::DecoderTrap; use crate::http::{mimetype, HeaderVec}; use crate::util::logger::Logger; /// Logs a buffer of bytes representing an HTTP request or response `body`. /// If the body is kind of text, we log all the text lines. If we can't detect that this is a text /// body (using Content-Type header in `headers`), we print the first 64 bytes. /// TODO: this function does not manage any kind of compression so we can only use for an HTTP /// request. For an HTTP response, see `[crate::http::Response::log_body]`. /// If `debug` is true, logs are printed using debug (with * prefix), otherwise logs are printed /// in info. pub fn log_body(body: &[u8], headers: &HeaderVec, debug: bool, logger: &mut Logger) { if let Some(content_type) = headers.content_type() { if !mimetype::is_kind_of_text(content_type) { log_bytes(body, 64, debug, logger); return; } } // Decode body as text: let encoding = match headers.character_encoding() { Ok(encoding) => encoding, Err(_) => { log_bytes(body, 64, debug, logger); return; } }; match encoding.decode(body, DecoderTrap::Strict) { Ok(text) => log_text(&text, debug, logger), Err(_) => log_bytes(body, 64, debug, logger), } } /// Debug log text. pub fn log_text(text: &str, debug: bool, logger: &mut Logger) { if text.is_empty() { if debug { logger.debug(""); } else { logger.info(""); } } else { let lines = text.split('\n'); if debug { lines.for_each(|l| logger.debug(l)); } else { lines.for_each(|l| logger.info(l)); } } } /// Debug log `bytes` with a maximum size of `max` bytes. pub fn log_bytes(bytes: &[u8], max: usize, debug: bool, logger: &mut Logger) { let bytes = if bytes.len() > max { &bytes[..max] } else { bytes }; let log = if bytes.is_empty() { String::new() } else { format!("Bytes <{}...>", hex::encode(bytes)) }; if debug { logger.debug(&log); } else { logger.info(&log); } } hurl-6.1.1/src/http/easy_ext.rs000064400000000000000000000327471046102023000145550ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::ffi::{CStr, CString}; use std::ptr; use std::time::Duration; use curl::easy::Easy; use curl::Error; use curl_sys::{curl_certinfo, curl_off_t, curl_slist, CURLINFO, CURLOPT_NETRC_FILE}; /// Some definitions not present in curl-sys const CURLINFO_OFF_T: CURLINFO = 0x600000; const CURLINFO_TOTAL_TIME_T: CURLINFO = CURLINFO_OFF_T + 50; const CURLINFO_NAMELOOKUP_TIME_T: CURLINFO = CURLINFO_OFF_T + 51; const CURLINFO_CONNECT_TIME_T: CURLINFO = CURLINFO_OFF_T + 52; const CURLINFO_PRETRANSFER_TIME_T: CURLINFO = CURLINFO_OFF_T + 53; const CURLINFO_STARTTRANSFER_TIME_T: CURLINFO = CURLINFO_OFF_T + 54; const CURLINFO_APPCONNECT_TIME_T: CURLINFO = CURLINFO_OFF_T + 56; const CURLINFO_CONN_ID: CURLINFO = CURLINFO_OFF_T + 64; /// Represents certificate information. /// `data` has format "name:content"; #[derive(Clone)] pub struct CertInfo { pub data: Vec, } /// Returns the information of the first certificate in the certificates chain. pub fn cert_info(easy: &Easy) -> Result, Error> { unsafe { let mut certinfo = ptr::null_mut::(); let rc = curl_sys::curl_easy_getinfo(easy.raw(), curl_sys::CURLINFO_CERTINFO, &mut certinfo); cvt(easy, rc)?; if certinfo.is_null() { return Ok(None); } let count = (*certinfo).num_of_certs; if count <= 0 { return Ok(None); } let slist = *((*certinfo).certinfo.offset(0)); let data = to_list(slist); Ok(Some(CertInfo { data })) } } /// Returns the connection identifier use by this libcurl handle. pub fn conn_id(easy: &Easy) -> Result { unsafe { let conn_id: curl_off_t = 0; let rc = curl_sys::curl_easy_getinfo(easy.raw(), CURLINFO_CONN_ID, &conn_id); cvt(easy, rc)?; Ok(conn_id) } } // Timing of a typical HTTP exchange (over TLS 1.2 connection) from libcurl // (courtesy of // ========================================================================= // // ┌───────────┐ ┌──────────────┐ ┌──────────────┐ // │ Client │ │ DNS Server │ │ Web Server │ // └─────┬─────┘ └──────┬───────┘ └──────┬───────┘ // │ │ │ // ┌ 0s ├────── DNS Request ─────►│ │ // DNS │ │ │ DNS Resolver │ // Lookup < │ │ e.g. 1.1.1.1 │ // │ │◄───── DNS Response ─────┘ │ // └ time_namelookup 1.510s │ │ // ┌ ├────────────────── SYN ────────────────────►│ // TCP < │ │ // Handshake └ time_connect 1.757s │◄────────────── SYN/ACK ───────────────────┤ // ┌ │ │ // │ │ │ // │ ├────────────────── ACK ────────────────────►│ // │ ├────────────── ClientHello ────────────────►│ // │ │ │ // │ │◄───────────── ServerHello ─────────────────┤ // SSL < │ Certificate │ // Handshake │ │ │ // │ ├───────────── ClientKeyExch, ──────────────►│ // │ │ ChangeCipherSpec │ // │ │ │ // │ │◄────────── ChangeCipherSpec ───────────────┤ // └ time_appconnect 2.256s │ Finished │ // ┌ time_pretransfer 2.259s ├─────────────── HTTP GET ──────────────────►│ // │ │ │ // Wait < │ │ // │ │ │ // └ time_starttransfer 2.506s │ │ // ┌ │◄───────────────────────────────────────────┤ // Data │ │◄─────────────── Response ──────────────────┤ // Transfer < │ ... │ // │ │◄───────────────────────────────────────────┤ // └ time_total 3.001s │ │ // ▼ ▼ /// Get the name lookup time. /// /// Returns the total time in microseconds from the start until the name resolving was completed. /// /// Corresponds to [`CURLINFO_NAMELOOKUP_TIME_T`] and may return an error if the /// option isn't supported. pub fn namelookup_time_t(easy: &mut Easy) -> Result { getopt_off_t(easy, CURLINFO_NAMELOOKUP_TIME_T).map(microseconds_to_duration) } /// Get the time until connect. /// /// Returns the total time in microseconds from the start until the connection to the remote host (or proxy) was completed. /// /// Corresponds to [`CURLINFO_CONNECT_TIME_T`] and may return an error if the /// option isn't supported. pub fn connect_time_t(easy: &mut Easy) -> Result { getopt_off_t(easy, CURLINFO_CONNECT_TIME_T).map(microseconds_to_duration) } /// Get the time until the SSL/SSH handshake is completed. /// /// Returns the total time in microseconds it took from the start until the SSL/SSH /// connect/handshake to the remote host was completed. This time is most often /// very near to the [`pretransfer_time_t`] time, except for cases such as /// HTTP pipelining where the pretransfer time can be delayed due to waits in /// line for the pipeline and more. /// /// Corresponds to [`CURLINFO_APPCONNECT_TIME_T`] and may return an error if the /// option isn't supported. pub fn appconnect_time_t(easy: &mut Easy) -> Result { getopt_off_t(easy, CURLINFO_APPCONNECT_TIME_T).map(microseconds_to_duration) } /// Get the time until the file transfer start. /// /// Returns the total time in microseconds it took from the start until the file /// transfer is just about to begin. This includes all pre-transfer commands /// and negotiations that are specific to the particular protocol(s) involved. /// It does not involve the sending of the protocol- specific request that /// triggers a transfer. /// /// Corresponds to [`CURLINFO_PRETRANSFER_TIME`] and may return an error if the /// option isn't supported. pub fn pretransfer_time_t(easy: &mut Easy) -> Result { getopt_off_t(easy, CURLINFO_PRETRANSFER_TIME_T).map(microseconds_to_duration) } /// Get the time in microseconds until the first byte is received. /// /// Returns the total time it took from the start until the first /// byte is received by libcurl. This includes [`pretransfer_time_t`] and /// also the time the server needs to calculate the result. /// /// Corresponds to [`CURLINFO_STARTTRANSFER_TIME`] and may return an error if the /// option isn't supported. pub fn starttransfer_time_t(easy: &mut Easy) -> Result { getopt_off_t(easy, CURLINFO_STARTTRANSFER_TIME_T).map(microseconds_to_duration) } /// Get total time of previous transfer /// /// Returns the total time in microseconds for the previous transfer, /// including name resolving, TCP connect etc. /// /// Corresponds to [`CURLINFO_TOTAL_TIME_T`] and may return an error if the /// option isn't supported. pub fn total_time_t(easy: &mut Easy) -> Result { getopt_off_t(easy, CURLINFO_TOTAL_TIME_T).map(microseconds_to_duration) } /// Read .netrc information from a file. pub fn netrc_file(easy: &mut Easy, filename: &str) -> Result<(), Error> { let filename = CString::new(filename)?; cvt(easy, unsafe { curl_sys::curl_easy_setopt(easy.raw(), CURLOPT_NETRC_FILE, filename.as_ptr()) }) } /// Converts an instance of libcurl linked list [`curl_slist`] to a vec of [`String`]. fn to_list(slist: *mut curl_slist) -> Vec { let mut data = vec![]; let mut cur = slist; loop { if cur.is_null() { break; } unsafe { let ret = CStr::from_ptr((*cur).data).to_bytes(); let value = String::from_utf8_lossy(ret); data.push(value.to_string()); cur = (*cur).next; } } data } /// Check if the return code `rc` is OK, and returns an error if not. fn cvt(easy: &Easy, rc: curl_sys::CURLcode) -> Result<(), Error> { if rc == curl_sys::CURLE_OK { return Ok(()); } let mut err = Error::new(rc); if let Some(msg) = easy.take_error_buf() { err.set_extra(msg); } Err(err) } fn getopt_off_t(easy: &mut Easy, opt: CURLINFO) -> Result { unsafe { let mut p = 0 as curl_off_t; let rc = curl_sys::curl_easy_getinfo(easy.raw(), opt, &mut p); cvt(easy, rc)?; Ok(p) } } fn microseconds_to_duration(microseconds: i64) -> Duration { Duration::from_micros(microseconds as u64) } // // Iterator based implementation more similar to curl crates List implementation. // // See // pub struct CertInfo2 { // raw: *mut curl_certinfo, // } // // // An iterator over CertInfo2 // pub struct Iter<'a> { // me: &'a CertInfo2, // cur: u32, // } // // pub unsafe fn from_raw(raw: *mut curl_certinfo) -> CertInfo2 { // CertInfo2 { raw } // } // // impl CertInfo2 { // pub fn new() -> CertInfo2 { // CertInfo2 { // raw: ptr::null_mut(), // } // } // // pub fn iter(&self) -> Iter { // Iter { // me: self, // cur: 0, // } // } // } // // impl<'a> IntoIterator for &'a CertInfo2 { // type Item = *mut curl_slist; // type IntoIter = Iter<'a>; // // fn into_iter(self) -> Iter<'a> { // self.iter() // } // } // // impl<'a> Iterator for Iter<'a> { // type Item = *mut curl_slist; // // fn next(&mut self) -> Option<*mut curl_slist> { // unsafe { // if self.cur >= (*self.me.raw).num_of_certs as u32 { // return None // } // let slist = *((*self.me.raw).certinfo.offset(self.cur as isize)); // self.cur += 1; // Some(slist) // } // } // } #[cfg(test)] mod tests { use std::ffi::CString; use std::ptr; use super::to_list; #[test] fn convert_curl_slist_to_vec() { let mut slist = ptr::null_mut(); unsafe { for value in ["foo", "bar", "baz"] { let str = CString::new(value).unwrap(); slist = curl_sys::curl_slist_append(slist, str.as_ptr()); } } assert_eq!( to_list(slist), vec!["foo".to_string(), "bar".to_string(), "baz".to_string()] ); unsafe { curl_sys::curl_slist_free_all(slist); } } } hurl-6.1.1/src/http/error.rs000064400000000000000000000106421046102023000140530ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use crate::http::RequestedHttpVersion; #[derive(Clone, Debug, PartialEq, Eq)] pub enum HttpError { CouldNotParseResponse, CouldNotUncompressResponse { description: String, }, InvalidCharset { charset: String, }, InvalidDecoding { charset: String, }, Libcurl { code: i32, description: String, }, LibcurlUnknownOption { option: String, minimum_version: String, }, NoPrimaryIp, TooManyRedirect, UnsupportedContentEncoding { description: String, }, UnsupportedHttpVersion(RequestedHttpVersion), /// Request URL is invalid (URL and reason) InvalidUrl(String, String), /// The maximum response size has been exceeded. /// This error can be raised even if libcurl has been configured to respect a given maximum /// file size. AllowedResponseSizeExceeded(u64), } impl From for HttpError { fn from(err: curl::Error) -> Self { let code = err.code() as i32; let description = err.description().to_string(); HttpError::Libcurl { code, description } } } impl HttpError { pub fn description(&self) -> String { match self { HttpError::AllowedResponseSizeExceeded(_) => "HTTP connection".to_string(), HttpError::CouldNotParseResponse => "HTTP connection".to_string(), HttpError::CouldNotUncompressResponse { .. } => "Decompression error".to_string(), HttpError::InvalidCharset { .. } => "Invalid charset".to_string(), HttpError::InvalidDecoding { .. } => "Invalid decoding".to_string(), HttpError::InvalidUrl(..) => "Invalid URL".to_string(), HttpError::Libcurl { .. } => "HTTP connection".to_string(), HttpError::LibcurlUnknownOption { .. } => "HTTP connection".to_string(), HttpError::NoPrimaryIp => "HTTP connection".to_string(), HttpError::TooManyRedirect => "HTTP connection".to_string(), HttpError::UnsupportedContentEncoding { .. } => "Decompression error".to_string(), HttpError::UnsupportedHttpVersion(_) => "Unsupported HTTP version".to_string(), } } pub fn message(&self) -> String { match self { HttpError::AllowedResponseSizeExceeded(max_size) => { format!("exceeded the maximum allowed file size ({max_size} bytes)") } HttpError::CouldNotParseResponse => "could not parse Response".to_string(), HttpError::CouldNotUncompressResponse { description } => { format!("could not uncompress response with {description}") } HttpError::InvalidCharset { charset } => { format!("the charset '{charset}' is not valid") } HttpError::InvalidDecoding { charset } => { format!("the body can not be decoded with charset '{charset}'") } HttpError::InvalidUrl(url, reason) => { format!("invalid URL <{url}> ({reason})").to_string() } HttpError::Libcurl { code, description } => format!("({code}) {description}"), HttpError::LibcurlUnknownOption { option, minimum_version, } => format!("Option {option} requires libcurl version {minimum_version} or higher"), HttpError::NoPrimaryIp => "No primary IP found in response".to_string(), HttpError::TooManyRedirect => "too many redirect".to_string(), HttpError::UnsupportedHttpVersion(version) => { format!("{version} is not supported, check --version").to_string() } HttpError::UnsupportedContentEncoding { description } => { format!("compression {description} is not supported").to_string() } } } } hurl-6.1.1/src/http/header.rs000064400000000000000000000152571046102023000141610ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use core::fmt; use std::slice::Iter; /// See pub const ACCEPT_ENCODING: &str = "Accept-Encoding"; /// See pub const AUTHORIZATION: &str = "Authorization"; /// See pub const COOKIE: &str = "Cookie"; /// See pub const CONTENT_ENCODING: &str = "Content-Encoding"; /// See pub const CONTENT_TYPE: &str = "Content-Type"; /// See pub const EXPECT: &str = "Expect"; /// See pub const LOCATION: &str = "Location"; /// See pub const SET_COOKIE: &str = "Set-Cookie"; /// See pub const USER_AGENT: &str = "User-Agent"; /// Represents an HTTP header. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Header { pub name: String, pub value: String, } impl fmt::Display for Header { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}: {}", self.name, self.value) } } impl Header { /// Creates an HTTP header with this `name`and `value`. pub fn new(name: &str, value: &str) -> Self { Header { name: name.to_string(), value: value.to_string(), } } /// Returns `true` if this HTTP header name is equal to `name`. /// /// An HTTP header consists of a case-insensitive name. pub fn name_eq(&self, name: &str) -> bool { self.name.to_lowercase() == name.to_lowercase() } } /// Represents an ordered list of [`Header`]. /// The headers are sorted by insertion order. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct HeaderVec { headers: Vec
, } impl HeaderVec { /// Creates an empty [`HeaderVec`]. pub fn new() -> Self { HeaderVec::default() } /// Returns a reference to the header associated with `name`. /// /// If there are multiple headers associated with `name`, then the first one is returned. /// Use [HeaderVec::get_all] to get all values associated with a given key. pub fn get(&self, name: &str) -> Option<&Header> { self.headers.iter().find(|h| h.name_eq(name)) } /// Returns a list of header associated with `name`. pub fn get_all(&self, name: &str) -> Vec<&Header> { self.headers.iter().filter(|h| h.name_eq(name)).collect() } /// Returns true if there is at least one header with the specified `name`. pub fn contains_key(&self, name: &str) -> bool { self.headers.iter().any(|h| h.name_eq(name)) } /// Retains only the header specified by the predicate. pub fn retain(&mut self, mut f: F) where F: FnMut(&Header) -> bool, { self.headers.retain(|h| f(h)); } /// Returns an iterator over all the headers. pub fn iter(&self) -> impl Iterator { self.headers.iter() } /// Returns the number of headers stored in the list. /// /// This number represents the total numbers of header, including header with the same name and /// different values. pub fn len(&self) -> usize { self.headers.len() } /// Returns true if there is no header. pub fn is_empty(&self) -> bool { self.headers.len() == 0 } /// Push a new `header` into the headers list. pub fn push(&mut self, header: Header) { self.headers.push(header); } /// Returns all headers values. pub fn values(&self, name: &str) -> Vec<&str> { self.get_all(name) .iter() .map(|h| h.value.as_str()) .collect::>() } } impl<'a> IntoIterator for &'a HeaderVec { type Item = &'a Header; type IntoIter = Iter<'a, Header>; fn into_iter(self) -> Self::IntoIter { self.headers.iter() } } #[cfg(test)] mod tests { use crate::http::header::HeaderVec; use crate::http::Header; #[test] fn test_simple_header_map() { let mut headers = HeaderVec::new(); headers.push(Header::new("foo", "xxx")); headers.push(Header::new("bar", "yyy0")); headers.push(Header::new("bar", "yyy1")); headers.push(Header::new("bar", "yyy2")); headers.push(Header::new("baz", "zzz")); assert_eq!(headers.len(), 5); assert!(!headers.is_empty()); assert_eq!(headers.get("foo"), Some(&Header::new("foo", "xxx"))); assert_eq!(headers.get("FOO"), Some(&Header::new("foo", "xxx"))); assert_eq!(headers.get("bar"), Some(&Header::new("bar", "yyy0"))); assert_eq!(headers.get("qux"), None); assert_eq!( headers.get_all("bar"), vec![ &Header::new("bar", "yyy0"), &Header::new("bar", "yyy1"), &Header::new("bar", "yyy2"), ] ); assert_eq!(headers.get_all("BAZ"), vec![&Header::new("baz", "zzz")]); assert_eq!(headers.get_all("qux"), Vec::<&Header>::new()); assert!(headers.contains_key("FOO")); assert!(!headers.contains_key("fuu")); headers.retain(|h| h.name_eq("Bar")); assert_eq!(headers.len(), 3); } #[test] fn test_iter() { let data = [("foo", "xxx"), ("bar", "yyy0"), ("baz", "yyy1")]; let mut headers = HeaderVec::new(); data.iter() .for_each(|(name, value)| headers.push(Header::new(name, value))); // Test iter() for (i, h) in headers.iter().enumerate() { assert_eq!(h.name, data[i].0); assert_eq!(h.value, data[i].1); } // Test into_iter() for (i, h) in (&headers).into_iter().enumerate() { assert_eq!(h.name, data[i].0); assert_eq!(h.value, data[i].1); } } } hurl-6.1.1/src/http/headers_helper.rs000064400000000000000000000130331046102023000156710ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use encoding::EncodingRef; use crate::http::header::CONTENT_ENCODING; use crate::http::response_decoding::ContentEncoding; use crate::http::{mimetype, Header, HeaderVec, HttpError, CONTENT_TYPE}; impl HeaderVec { /// Returns optional Content-type header value. pub fn content_type(&self) -> Option<&str> { self.get(CONTENT_TYPE).map(|h| h.value.as_str()) } /// Returns character encoding from this list of headers. /// /// If no character encoding can be found, returns UTF-8. pub fn character_encoding(&self) -> Result { match self.content_type() { Some(content_type) => match mimetype::charset(content_type) { Some(charset) => match encoding::label::encoding_from_whatwg_label(&charset) { None => Err(HttpError::InvalidCharset { charset }), Some(enc) => Ok(enc), }, None => Ok(encoding::all::UTF_8), }, None => Ok(encoding::all::UTF_8), } } /// Returns list of content encoding from HTTP response headers. /// /// See pub fn content_encoding(&self) -> Result, HttpError> { for header in self { if header.name_eq(CONTENT_ENCODING) { let mut encodings = vec![]; for value in header.value.split(',') { let encoding = ContentEncoding::parse(value.trim())?; encodings.push(encoding); } return Ok(encodings); } } Ok(vec![]) } /// Aggregates the headers from `self` and `raw_headers` /// /// Returns the aggregated `HeaderVec` pub fn aggregate_raw_headers(&self, raw_headers: &[&str]) -> HeaderVec { let mut headers = self.clone(); // TODO: use another function that [`Header::parse`] because [`Header::parse`] is for // parsing headers line coming from a server (and not from options header) let to_aggregate = raw_headers.iter().filter_map(|h| Header::parse(h)); for header in to_aggregate { headers.push(header); } headers } } #[cfg(test)] mod tests { use crate::http::response_decoding::ContentEncoding; use crate::http::{Header, HeaderVec}; #[test] fn content_type_basic() { let mut headers = HeaderVec::new(); headers.push(Header::new("Host", "localhost:8000")); headers.push(Header::new("Accept", "*/*")); headers.push(Header::new("User-Agent", "hurl/1.0")); headers.push(Header::new("content-type", "application/json")); assert_eq!(headers.content_type(), Some("application/json")); let mut headers = HeaderVec::new(); headers.push(Header::new("foo", "bar")); assert_eq!(headers.content_type(), None); } #[test] fn content_encoding() { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Encoding", "deflate, gzip")); assert_eq!( headers.content_encoding(), Ok(vec![ContentEncoding::Deflate, ContentEncoding::Gzip]) ); } #[test] fn character_encoding() { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Type", "text/html; charset=utf-8")); assert_eq!(headers.character_encoding().unwrap().name(), "utf-8"); let mut headers = HeaderVec::new(); headers.push(Header::new("content-type", "text/plain; charset=us-ascii")); assert_eq!(headers.character_encoding().unwrap().name(), "windows-1252"); let mut headers = HeaderVec::new(); headers.push(Header::new("content-type", "text/plain")); assert_eq!(headers.character_encoding().unwrap().name(), "utf-8"); } #[test] fn aggregate_raw_headers() { let mut headers = HeaderVec::new(); headers.push(Header::new("Host", "localhost:8000")); headers.push(Header::new("Repeated-Header", "original")); let raw_headers = &[ "User-Agent: hurl/6.1.0", "Invalid-Header", "Repeated-Header: aggregated-1", "Repeated-Header: aggregated-2", ]; let aggregated = headers.aggregate_raw_headers(raw_headers); assert_eq!( aggregated.get("Host"), Some(&Header::new("Host", "localhost:8000")) ); assert_eq!( aggregated.get("User-Agent"), Some(&Header::new("User-Agent", "hurl/6.1.0")) ); assert_eq!(aggregated.get("Invalid-Header"), None); assert_eq!( aggregated.get_all("Repeated-Header"), vec![ &Header::new("Repeated-Header", "original"), &Header::new("Repeated-Header", "aggregated-1"), &Header::new("Repeated-Header", "aggregated-2") ] ); } } hurl-6.1.1/src/http/ip.rs000064400000000000000000000027661046102023000133420ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::fmt::{Display, Formatter}; use std::net::AddrParseError; use std::str::FromStr; /// An IP address, either IPv4 or IPv6. /// /// The `raw` field of this structure comes from libcurl `as is`. We keep it as a /// [`String`] instead of a [`std::net::IpAddr`] to not make any assumptions /// of the address format. We don't want to invalidate an HTTP exchange and raise a /// runtime error because of an unusual format coming from libcurl. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct IpAddr { raw: String, } impl IpAddr { pub fn new(raw: String) -> IpAddr { IpAddr { raw } } #[allow(dead_code)] fn to_ip_addr(&self) -> Result { std::net::IpAddr::from_str(&self.raw) } } impl Display for IpAddr { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.raw) } } hurl-6.1.1/src/http/mimetype.rs000064400000000000000000001033371046102023000145570ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ // TODO: maybe add a proper MimeType enum (see ) // as implementation / api example. use regex::Regex; /// Returns true if binary data with this `content_type` can be decoded as text. pub fn is_kind_of_text(content_type: &str) -> bool { let content_type = content_type.trim().to_lowercase(); content_type.contains("text/") || is_json(&content_type) || is_xml(&content_type) } /// Returns true if this `content_type` is HTML. pub fn is_html(content_type: &str) -> bool { let content_type = content_type.trim().to_lowercase(); content_type.starts_with("text/html") } /// Returns true if this `content_type` is HTML. pub fn is_xml(content_type: &str) -> bool { let content_type = content_type.trim().to_lowercase(); let patterns = [ r"^text/xml", r"^application/xml", r"^application/[a-z0-9-_.]+[+.]xml", ]; patterns .iter() .any(|p| Regex::new(p).unwrap().is_match(&content_type)) } /// Returns true if this `content_type` is JSON. pub fn is_json(content_type: &str) -> bool { let content_type = content_type.trim().to_lowercase(); let patterns = [r"^application/json", r"^application/[a-z0-9-_.]+[+.-]json"]; patterns .iter() .any(|p| Regex::new(p).unwrap().is_match(&content_type)) } /// Extracts charset from mime-type String pub fn charset(mime_type: &str) -> Option { let parts = mime_type.trim().split(';'); for part in parts { let param = part.trim().split('=').collect::>(); if param.len() == 2 && param[0].trim().eq_ignore_ascii_case("charset") { return Some(param[1].trim().to_string()); } } None } #[cfg(test)] pub mod tests { use super::*; #[test] fn test_charset() { assert_eq!( charset("text/plain; charset=utf-8"), Some("utf-8".to_string()) ); assert_eq!( charset("text/plain; charset=ISO-8859-1"), Some("ISO-8859-1".to_string()) ); assert_eq!(charset("text/plain;"), None); assert_eq!( charset("text/plain; CHARSET=ISO-8859-1"), Some("ISO-8859-1".to_string()) ); assert_eq!( charset("text/plain; version=0.0.4; charset=utf-8; escaping=values"), Some("utf-8".to_string()) ); } // Dataset for mimetypes issued from #[test] fn test_is_json() { let mime_types = [ "application/3gppHal+json", "application/3gppHalForms+json", "application/ace+json", "application/activity+json", "application/aif+json", "application/alto-cdni+json", "application/alto-cdnifilter+json", "application/alto-costmap+json", "application/alto-costmapfilter+json", "application/alto-directory+json", "application/alto-endpointprop+json", "application/alto-endpointpropparams+json", "application/alto-endpointcost+json", "application/alto-endpointcostparams+json", "application/alto-error+json", "application/alto-networkmapfilter+json", "application/alto-networkmap+json", "application/alto-propmap+json", "application/alto-propmapparams+json", "application/alto-tips+json", "application/alto-tipsparams+json", "application/alto-updatestreamcontrol+json", "application/alto-updatestreamparams+json", "application/atsc-rdt+json", "application/calendar+json", "application/captive+json", "application/city+json", "application/coap-group+json", "application/csvm+json", "application/cwl+json", "application/dicom+json", "application/dns+json", "application/elm+json", "application/EmergencyCallData.LegacyESN+json", "application/expect-ct-report+json", "application/fhir+json", "application/geo+json", "application/geo+json-seq", "application/geopose+json", "application/geoxacml+json", "application/jf2feed+json", "application/jose+json", "application/jrd+json", "application/jscalendar+json", "application/jscontact+json", "application/json", "application/json-patch+json", "application/json-seq", "application/jsonpath", "application/jwk+json", "application/jwk-set+json", "application/ld+json", "application/linkset+json", "application/manifest+json", "application/merge-patch+json", "application/mud+json", "application/ppsp-tracker+json", "application/problem+json", "application/prs.implied-object+json", "application/prs.implied-object+json-seq", "application/pvd+json", "application/rdap+json", "application/reputon+json", "application/sarif-external-properties+json", "application/sarif+json", "application/scim+json", "application/senml-etch+json", "application/senml+json", "application/sensml+json", "application/spdx+json", "application/stix+json", "application/taxii+json", "application/td+json", "application/tlsrpt+json", "application/tm+json", "application/trust-chain+json", "application/vcard+json", "application/vnd.acm.addressxfer+json", "application/vnd.acm.chatbot+json", "application/vnd.amadeus+json", "application/vnd.apache.thrift.json", "application/vnd.api+json", "application/vnd.aplextor.warrp+json", "application/vnd.apothekende.reservation+json", "application/vnd.artisan+json", "application/vnd.avalon+json", "application/vnd.bbf.usp.msg+json", "application/vnd.bekitzur-stech+json", "application/vnd.byu.uapi+json", "application/vnd.capasystems-pg+json", "application/vnd.cncf.helm.config.v1+json", "application/vnd.collection.doc+json", "application/vnd.collection+json", "application/vnd.collection.next+json", "application/vnd.coreos.ignition+json", "application/vnd.cryptii.pipe+json", "application/vnd.cyclonedx+json", "application/vnd.datapackage+json", "application/vnd.dataresource+json", "application/vnd.document+json", "application/vnd.drive+json", "application/vnd.eclipse.ditto+json", "application/vnd.eu.kasparian.car+json", "application/vnd.futoin+json", "application/vnd.gentics.grd+json", "application/vnd.geo+json", "application/vnd.gnu.taler.exchange+json", "application/vnd.gnu.taler.merchant+json", "application/vnd.hal+json", "application/vnd.hc+json", "application/vnd.heroku+json", "application/vnd.hyper-item+json", "application/vnd.hyper+json", "application/vnd.hyperdrive+json", "application/vnd.ims.lis.v2.result+json", "application/vnd.ims.lti.v2.toolconsumerprofile+json", "application/vnd.ims.lti.v2.toolproxy.id+json", "application/vnd.ims.lti.v2.toolproxy+json", "application/vnd.ims.lti.v2.toolsettings+json", "application/vnd.ims.lti.v2.toolsettings.simple+json", "application/vnd.ipld.dag-json", "application/vnd.las.las+json", "application/vnd.leap+json", "application/vnd.mason+json", "application/vnd.micro+json", "application/vnd.miele+json", "application/vnd.nacamar.ybrid+json", "application/vnd.nato.bindingdataobject+json", "application/vnd.nearst.inv+json", "application/vnd.oai.workflows+json", "application/vnd.oci.image.manifest.v1+json", "application/vnd.oftn.l10n+json", "application/vnd.oma.lwm2m+json", "application/vnd.openvpi.dspx+json", "application/vnd.oracle.resource+json", "application/vnd.pagerduty+json", "application/vnd.restful+json", "application/vnd.seis+json", "application/vnd.shootproof+json", "application/vnd.shopkick+json", "application/vnd.siren+json", "application/vnd.syft+json", "application/vnd.tableschema+json", "application/vnd.think-cell.ppttc+json", "application/vnd.uic.osdm+json", "application/vnd.vel+json", "application/vnd.veritone.aion+json", "application/vnd.xacml+json", "application/voucher-cms+json", "application/webpush-options+json", "application/yang-data+json", "application/yang-patch+json", "application/yang-sid+json", ]; for mime in &mime_types { assert!(is_json(mime), "{mime} is a not a JSON mime type"); } } #[test] fn test_is_xml() { let mime_types = [ "application/3gpdash-qoe-report+xml", "application/3gpp-ims+xml", "application/atom+xml", "application/atomcat+xml", "application/atomdeleted+xml", "application/atomsvc+xml", "application/atsc-dwd+xml", "application/atsc-held+xml", "application/atsc-rsat+xml", "application/auth-policy+xml", "application/automationml-aml+xml", "application/beep+xml", "application/calendar+xml", "application/ccmp+xml", "application/ccxml+xml", "application/cda+xml", "application/cea-2018+xml", "application/cellml+xml", "application/clue_info+xml", "application/clue+xml", "application/cnrp+xml", "application/conference-info+xml", "application/cpl+xml", "application/csta+xml", "application/CSTAdata+xml", "application/dash+xml", "application/dash-patch+xml", "application/davmount+xml", "application/dialog-info+xml", "application/dicom+xml", "application/dskpp+xml", "application/dssc+xml", "application/elm+xml", "application/EmergencyCallData.cap+xml", "application/EmergencyCallData.Comment+xml", "application/EmergencyCallData.Control+xml", "application/EmergencyCallData.DeviceInfo+xml", "application/EmergencyCallData.ProviderInfo+xml", "application/EmergencyCallData.ServiceInfo+xml", "application/EmergencyCallData.SubscriberInfo+xml", "application/EmergencyCallData.VEDS+xml", "application/emma+xml", "application/emotionml+xml", "application/epp+xml", "application/fdt+xml", "application/fhir+xml", "application/framework-attributes+xml", "application/geoxacml+xml", "application/gml+xml", "application/held+xml", "application/hl7v2+xml", "application/ibe-key-request+xml", "application/ibe-pkg-reply+xml", "application/im-iscomposing+xml", "application/inkml+xml", "application/its+xml", "application/kpml-request+xml", "application/kpml-response+xml", "application/lgr+xml", "application/load-control+xml", "application/lost+xml", "application/lostsync+xml", "application/mads+xml", "application/marcxml+xml", "application/mathml+xml", "application/mathml-content+xml", "application/mathml-presentation+xml", "application/mbms-associated-procedure-description+xml", "application/mbms-deregister+xml", "application/mbms-envelope+xml", "application/mbms-msk-response+xml", "application/mbms-msk+xml", "application/mbms-protection-description+xml", "application/mbms-reception-report+xml", "application/mbms-register-response+xml", "application/mbms-register+xml", "application/mbms-schedule+xml", "application/mbms-user-service-description+xml", "application/media_control+xml", "application/media-policy-dataset+xml", "application/mediaservercontrol+xml", "application/metalink4+xml", "application/mets+xml", "application/mmt-aei+xml", "application/mmt-usd+xml", "application/mods+xml", "application/mrb-consumer+xml", "application/mrb-publish+xml", "application/msc-ivr+xml", "application/msc-mixer+xml", "application/nlsml+xml", "application/odm+xml", "application/oebps-package+xml", "application/opc-nodeset+xml", "application/p2p-overlay+xml", "application/patch-ops-error+xml", "application/pidf-diff+xml", "application/pidf+xml", "application/pls+xml", "application/poc-settings+xml", "application/problem+xml", "application/provenance+xml", "application/prs.implied-document+xml", "application/prs.xsf+xml", "application/pskc+xml", "application/rdf+xml", "application/route-apd+xml", "application/route-s-tsid+xml", "application/route-usd+xml", "application/reginfo+xml", "application/resource-lists-diff+xml", "application/resource-lists+xml", "application/rfc+xml", "application/rlmi+xml", "application/rls-services+xml", "application/samlassertion+xml", "application/samlmetadata+xml", "application/sbml+xml", "application/scaip+xml", "application/senml+xml", "application/sensml+xml", "application/sep+xml", "application/shf+xml", "application/simple-filter+xml", "application/smil+xml", "application/soap+xml", "application/sparql-results+xml", "application/spirits-event+xml", "application/srgs+xml", "application/sru+xml", "application/ssml+xml", "application/swid+xml", "application/tei+xml", "application/thraud+xml", "application/ttml+xml", "application/urc-grpsheet+xml", "application/urc-ressheet+xml", "application/urc-targetdesc+xml", "application/urc-uisocketdesc+xml", "application/vcard+xml", "application/vnd.1000minds.decision-model+xml", "application/vnd.3gpp.access-transfer-events+xml", "application/vnd.3gpp.bsf+xml", "application/vnd.3gpp.crs+xml", "application/vnd.3gpp.current-location-discovery+xml", "application/vnd.3gpp.GMOP+xml", "application/vnd.3gpp.mcdata-affiliation-command+xml", "application/vnd.3gpp.mcdata-info+xml", "application/vnd.3gpp.mcdata-msgstore-ctrl-request+xml", "application/vnd.3gpp.mcdata-regroup+xml", "application/vnd.3gpp.mcdata-service-config+xml", "application/vnd.3gpp.mcdata-ue-config+xml", "application/vnd.3gpp.mcdata-user-profile+xml", "application/vnd.3gpp.mcptt-affiliation-command+xml", "application/vnd.3gpp.mcptt-floor-request+xml", "application/vnd.3gpp.mcptt-info+xml", "application/vnd.3gpp.mcptt-location-info+xml", "application/vnd.3gpp.mcptt-mbms-usage-info+xml", "application/vnd.3gpp.mcptt-regroup+xml", "application/vnd.3gpp.mcptt-service-config+xml", "application/vnd.3gpp.mcptt-signed+xml", "application/vnd.3gpp.mcptt-ue-config+xml", "application/vnd.3gpp.mcptt-ue-init-config+xml", "application/vnd.3gpp.mcptt-user-profile+xml", "application/vnd.3gpp.mcvideo-affiliation-command+xml", "application/vnd.3gpp.mcvideo-affiliation-info+xml", "application/vnd.3gpp.mcvideo-info+xml", "application/vnd.3gpp.mcvideo-location-info+xml", "application/vnd.3gpp.mcvideo-mbms-usage-info+xml", "application/vnd.3gpp.mcvideo-regroup+xml", "application/vnd.3gpp.mcvideo-service-config+xml", "application/vnd.3gpp.mcvideo-transmission-request+xml", "application/vnd.3gpp.mcvideo-ue-config+xml", "application/vnd.3gpp.mcvideo-user-profile+xml", "application/vnd.3gpp.mid-call+xml", "application/vnd.3gpp.pinapp-info+xml", "application/vnd.3gpp-prose-pc3a+xml", "application/vnd.3gpp-prose-pc3ach+xml", "application/vnd.3gpp-prose-pc3ch+xml", "application/vnd.3gpp-prose-pc8+xml", "application/vnd.3gpp-prose+xml", "application/vnd.3gpp.seal-group-doc+xml", "application/vnd.3gpp.seal-info+xml", "application/vnd.3gpp.seal-location-info+xml", "application/vnd.3gpp.seal-mbms-usage-info+xml", "application/vnd.3gpp.seal-network-QoS-management-info+xml", "application/vnd.3gpp.seal-ue-config-info+xml", "application/vnd.3gpp.seal-unicast-info+xml", "application/vnd.3gpp.seal-user-profile-info+xml", "application/vnd.3gpp.sms+xml", "application/vnd.3gpp.srvcc-ext+xml", "application/vnd.3gpp.SRVCC-info+xml", "application/vnd.3gpp.state-and-event-info+xml", "application/vnd.3gpp.ussd+xml", "application/vnd.3gpp.vae-info+xml", "application/vnd.3gpp2.bcmcsinfo+xml", "application/vnd.adobe.xdp+xml", "application/vnd.amundsen.maze+xml", "application/vnd.apple.installer+xml", "application/vnd.avistar+xml", "application/vnd.balsamiq.bmml+xml", "application/vnd.biopax.rdf+xml", "application/vnd.c3voc.schedule+xml", "application/vnd.chemdraw+xml", "application/vnd.citationstyles.style+xml", "application/vnd.criticaltools.wbs+xml", "application/vnd.ctct.ws+xml", "application/vnd.cyan.dean.root+xml", "application/vnd.cyclonedx+xml", "application/vnd.dece.ttml+xml", "application/vnd.dm.delegation+xml", "application/vnd.dvb.dvbisl+xml", "application/vnd.dvb.notif-aggregate-root+xml", "application/vnd.dvb.notif-container+xml", "application/vnd.dvb.notif-generic+xml", "application/vnd.dvb.notif-ia-msglist+xml", "application/vnd.dvb.notif-ia-registration-request+xml", "application/vnd.dvb.notif-ia-registration-response+xml", "application/vnd.dvb.notif-init+xml", "application/vnd.emclient.accessrequest+xml", "application/vnd.eprints.data+xml", "application/vnd.eszigno3+xml", "application/vnd.etsi.aoc+xml", "application/vnd.etsi.cug+xml", "application/vnd.etsi.iptvcommand+xml", "application/vnd.etsi.iptvdiscovery+xml", "application/vnd.etsi.iptvprofile+xml", "application/vnd.etsi.iptvsad-bc+xml", "application/vnd.etsi.iptvsad-cod+xml", "application/vnd.etsi.iptvsad-npvr+xml", "application/vnd.etsi.iptvservice+xml", "application/vnd.etsi.iptvsync+xml", "application/vnd.etsi.iptvueprofile+xml", "application/vnd.etsi.mcid+xml", "application/vnd.etsi.overload-control-policy-dataset+xml", "application/vnd.etsi.pstn+xml", "application/vnd.etsi.sci+xml", "application/vnd.etsi.simservs+xml", "application/vnd.etsi.tsl+xml", "application/vnd.fujifilm.fb.jfi+xml", "application/vnd.gentoo.catmetadata+xml", "application/vnd.gentoo.pkgmetadata+xml", "application/vnd.geocube+xml", "application/vnd.google-earth.kml+xml", "application/vnd.gov.sk.e-form+xml", "application/vnd.gov.sk.xmldatacontainer+xml", "application/vnd.gpxsee.map+xml", "application/vnd.hal+xml", "application/vnd.HandHeld-Entertainment+xml", "application/vnd.informedcontrol.rms+xml", "application/vnd.infotech.project+xml", "application/vnd.iptc.g2.catalogitem+xml", "application/vnd.iptc.g2.conceptitem+xml", "application/vnd.iptc.g2.knowledgeitem+xml", "application/vnd.iptc.g2.newsitem+xml", "application/vnd.iptc.g2.newsmessage+xml", "application/vnd.iptc.g2.packageitem+xml", "application/vnd.iptc.g2.planningitem+xml", "application/vnd.irepository.package+xml", "application/vnd.las.las+xml", "application/vnd.liberty-request+xml", "application/vnd.llamagraphics.life-balance.exchange+xml", "application/vnd.marlin.drm.actiontoken+xml", "application/vnd.marlin.drm.conftoken+xml", "application/vnd.marlin.drm.license+xml", "application/vnd.mozilla.xul+xml", "application/vnd.ms-office.activeX+xml", "application/vnd.ms-playready.initiator+xml", "application/vnd.ms-PrintDeviceCapabilities+xml", "application/vnd.ms-PrintSchemaTicket+xml", "application/vnd.nato.bindingdataobject+xml", "application/vnd.nokia.conml+xml", "application/vnd.nokia.iptv.config+xml", "application/vnd.nokia.landmark+xml", "application/vnd.nokia.landmarkcollection+xml", "application/vnd.nokia.n-gage.ac+xml", "application/vnd.nokia.pcd+xml", "application/vnd.oipf.contentaccessdownload+xml", "application/vnd.oipf.contentaccessstreaming+xml", "application/vnd.oipf.dae.svg+xml", "application/vnd.oipf.dae.xhtml+xml", "application/vnd.oipf.mippvcontrolmessage+xml", "application/vnd.oipf.spdiscovery+xml", "application/vnd.oipf.spdlist+xml", "application/vnd.oipf.ueprofile+xml", "application/vnd.oipf.userprofile+xml", "application/vnd.oma.bcast.associated-procedure-parameter+xml", "application/vnd.oma.bcast.drm-trigger+xml", "application/vnd.oma.bcast.imd+xml", "application/vnd.oma.bcast.notification+xml", "application/vnd.oma.bcast.sgdd+xml", "application/vnd.oma.bcast.smartcard-trigger+xml", "application/vnd.oma.bcast.sprov+xml", "application/vnd.oma.cab-address-book+xml", "application/vnd.oma.cab-feature-handler+xml", "application/vnd.oma.cab-pcc+xml", "application/vnd.oma.cab-subs-invite+xml", "application/vnd.oma.cab-user-prefs+xml", "application/vnd.oma.dd2+xml", "application/vnd.oma.drm.risd+xml", "application/vnd.oma.group-usage-list+xml", "application/vnd.oma.pal+xml", "application/vnd.oma.poc.detailed-progress-report+xml", "application/vnd.oma.poc.final-report+xml", "application/vnd.oma.poc.groups+xml", "application/vnd.oma.poc.invocation-descriptor+xml", "application/vnd.oma.poc.optimized-progress-report+xml", "application/vnd.oma.scidm.messages+xml", "application/vnd.oma.xcap-directory+xml", "application/vnd.omads-email+xml", "application/vnd.omads-file+xml", "application/vnd.omads-folder+xml", "application/vnd.openblox.game+xml", "application/vnd.openstreetmap.data+xml", "application/vnd.openxmlformats-officedocument.custom-properties+xml", "application/vnd.openxmlformats-officedocument.customXmlProperties+xml", "application/vnd.openxmlformats-officedocument.drawing+xml", "application/vnd.openxmlformats-officedocument.drawingml.chart+xml", "application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml", "application/vnd.openxmlformats-officedocument.drawingml.diagramColors+xml", "application/vnd.openxmlformats-officedocument.drawingml.diagramData+xml", "application/vnd.openxmlformats-officedocument.drawingml.diagramLayout+xml", "application/vnd.openxmlformats-officedocument.drawingml.diagramStyle+xml", "application/vnd.openxmlformats-officedocument.extended-properties+xml", "application/vnd.openxmlformats-officedocument.presentationml.commentAuthors+xml", "application/vnd.openxmlformats-officedocument.presentationml.comments+xml", "application/vnd.openxmlformats-officedocument.presentationml.handoutMaster+xml", "application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml", "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml", "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml", "application/vnd.openxmlformats-officedocument.presentationml.presProps+xml", "application/vnd.openxmlformats-officedocument.presentationml.slide+xml", "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml", "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml", "application/vnd.openxmlformats-officedocument.presentationml.slideshow.main+xml", "application/vnd.openxmlformats-officedocument.presentationml.slideUpdateInfo+xml", "application/vnd.openxmlformats-officedocument.presentationml.tableStyles+xml", "application/vnd.openxmlformats-officedocument.presentationml.tags+xml", "application/vnd.openxmlformats-officedocument.presentationml.template.main+xml", "application/vnd.openxmlformats-officedocument.presentationml.viewProps+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.calcChain+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.connections+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.dialogsheet+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.externalLink+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.queryTable+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.revisionHeaders+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.revisionLog+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.tableSingleCells+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.userNames+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.volatileDependencies+xml", "application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml", "application/vnd.openxmlformats-officedocument.theme+xml", "application/vnd.openxmlformats-officedocument.themeOverride+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml", "application/vnd.openxmlformats-package.core-properties+xml", "application/vnd.openxmlformats-package.digital-signature-xmlsignature+xml", "application/vnd.openxmlformats-package.relationships+xml", "application/vnd.otps.ct-kip+xml", "application/vnd.paos.xml", "application/vnd.poc.group-advertisement+xml", "application/vnd.pwg-xhtml-print+xml", "application/vnd.radisys.moml+xml", "application/vnd.radisys.msml-audit-conf+xml", "application/vnd.radisys.msml-audit-conn+xml", "application/vnd.radisys.msml-audit-dialog+xml", "application/vnd.radisys.msml-audit-stream+xml", "application/vnd.radisys.msml-audit+xml", "application/vnd.radisys.msml-conf+xml", "application/vnd.radisys.msml-dialog-base+xml", "application/vnd.radisys.msml-dialog-fax-detect+xml", "application/vnd.radisys.msml-dialog-fax-sendrecv+xml", "application/vnd.radisys.msml-dialog-group+xml", "application/vnd.radisys.msml-dialog-speech+xml", "application/vnd.radisys.msml-dialog-transform+xml", "application/vnd.radisys.msml-dialog+xml", "application/vnd.radisys.msml+xml", "application/vnd.recordare.musicxml+xml", "application/vnd.route66.link66+xml", "application/vnd.software602.filler.form+xml", "application/vnd.solent.sdkm+xml", "application/vnd.sun.wadl+xml", "application/vnd.sycle+xml", "application/vnd.syncml.dmddf+xml", "application/vnd.syncml.dmtnds+xml", "application/vnd.syncml.dm+xml", "application/vnd.syncml+xml", "application/vnd.tmd.mediaflex.api+xml", "application/vnd.uoml+xml", "application/vnd.wv.csp+xml", "application/vnd.wv.ssp+xml", "application/vnd.xmi+xml", "application/vnd.yamaha.openscoreformat.osfpvg+xml", "application/vnd.zzazz.deck+xml", "application/voicexml+xml", "application/watcherinfo+xml", "application/wsdl+xml", "application/wspolicy+xml", "application/xacml+xml", "application/xcap-att+xml", "application/xcap-caps+xml", "application/xcap-diff+xml", "application/xcap-el+xml", "application/xcap-error+xml", "application/xcap-ns+xml", "application/xcon-conference-info-diff+xml", "application/xcon-conference-info+xml", "application/xenc+xml", "application/xhtml+xml", "application/xliff+xml", "application/xml", "application/xml-dtd", "application/xml-external-parsed-entity", "application/xml-patch+xml", "application/xmpp+xml", "application/xop+xml", "application/xslt+xml", "application/xv+xml", "application/yang-data+xml", "application/yang-patch+xml", "application/yin+xml", ]; for mime in &mime_types { assert!(is_xml(mime), "{mime} is a not a XML mime type"); } } } hurl-6.1.1/src/http/mod.rs000064400000000000000000000037701046102023000135050ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ //! Various HTTP structures like requests, responses, cookies etc. //! //! The Hurl HTTP engine is not public. It's a wrapper around libcurl and only the models //! returned by an HTTP exchange are exposed. pub use self::call::Call; pub use self::certificate::Certificate; pub(crate) use self::client::Client; pub use self::cookie::{CookieAttribute, ResponseCookie}; pub use self::core::Cookie; pub(crate) use self::core::{Param, RequestCookie}; pub use self::curl_cmd::CurlCmd; pub(crate) use self::error::HttpError; pub use self::header::{ Header, HeaderVec, ACCEPT_ENCODING, AUTHORIZATION, CONTENT_TYPE, COOKIE, EXPECT, USER_AGENT, }; pub(crate) use self::options::{ClientOptions, Verbosity}; pub use self::request::{IpResolve, Request, RequestedHttpVersion}; pub(crate) use self::request_spec::{Body, FileParam, Method, MultipartParam, RequestSpec}; pub use self::response::{HttpVersion, Response}; #[cfg(test)] pub use self::tests::*; pub use self::timings::Timings; pub use self::url::Url; pub use self::version::libcurl_version_info; mod call; mod certificate; mod client; mod cookie; mod core; mod curl_cmd; mod debug; mod easy_ext; mod error; mod header; mod headers_helper; mod ip; mod mimetype; mod options; mod request; mod request_spec; mod response; mod response_cookie; mod response_debug; mod response_decoding; #[cfg(test)] mod tests; mod timings; mod timings_debug; mod url; mod version; hurl-6.1.1/src/http/options.rs000064400000000000000000000071251046102023000144170ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::time::Duration; use hurl_core::typing::{BytesPerSec, Count}; use crate::http::request::RequestedHttpVersion; use crate::http::IpResolve; #[derive(Debug, Clone)] pub struct ClientOptions { /// Allow reusing internal connections, `true` by default. Setting this to `false` forces the /// HTTP client to use a new HTTP connection, and also marks this new connection as not reusable. /// Under the hood, this activates libcurl [`CURLOPT_FRESH_CONNECT`](https://curl.se/libcurl/c/CURLOPT_FRESH_CONNECT.html) /// and [`CURLOPT_FORBID_REUSE`](https://curl.se/libcurl/c/CURLOPT_FORBID_REUSE.html). pub allow_reuse: bool, pub aws_sigv4: Option, pub cacert_file: Option, pub client_cert_file: Option, pub client_key_file: Option, pub compressed: bool, pub connect_timeout: Duration, pub connects_to: Vec, pub cookie_input_file: Option, pub follow_location: bool, pub follow_location_trusted: bool, pub headers: Vec, pub http_version: RequestedHttpVersion, pub insecure: bool, pub ip_resolve: IpResolve, pub max_filesize: Option, pub max_recv_speed: Option, pub max_redirect: Count, pub max_send_speed: Option, pub netrc: bool, pub netrc_file: Option, pub netrc_optional: bool, pub no_proxy: Option, pub path_as_is: bool, pub proxy: Option, pub resolves: Vec, pub ssl_no_revoke: bool, pub timeout: Duration, pub unix_socket: Option, pub user: Option, pub user_agent: Option, pub verbosity: Option, } // FIXME/ we could implement copy here #[derive(Clone, Debug, PartialEq, Eq)] pub enum Verbosity { Verbose, VeryVerbose, } impl Default for ClientOptions { fn default() -> Self { ClientOptions { allow_reuse: true, aws_sigv4: None, cacert_file: None, client_cert_file: None, client_key_file: None, compressed: false, connect_timeout: Duration::from_secs(300), connects_to: vec![], cookie_input_file: None, follow_location: false, follow_location_trusted: false, headers: vec![], http_version: RequestedHttpVersion::default(), insecure: false, ip_resolve: IpResolve::default(), max_filesize: None, max_recv_speed: None, max_redirect: Count::Finite(50), max_send_speed: None, netrc: false, netrc_file: None, netrc_optional: false, no_proxy: None, path_as_is: false, proxy: None, resolves: vec![], ssl_no_revoke: false, timeout: Duration::from_secs(300), unix_socket: None, user: None, user_agent: None, verbosity: None, } } } hurl-6.1.1/src/http/request.rs000064400000000000000000000134501046102023000144120ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::fmt; use crate::http::header::{HeaderVec, COOKIE}; use crate::http::url::Url; use crate::http::RequestCookie; /// Represents a runtime HTTP request. /// This is a real request, that has been executed by our HTTP client. /// It's different from `crate::http::RequestSpec` which is the request asked to be executed by our /// user. For instance, in the request spec, headers implicitly added by curl are not present, while /// they will be present in the [`Request`] instances. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Request { /// Absolute URL. pub url: Url, /// Method. pub method: String, /// List of HTTP headers. pub headers: HeaderVec, /// Response body bytes. pub body: Vec, } #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] pub enum RequestedHttpVersion { /// The effective HTTP version will be chosen by libcurl #[default] Default, Http10, Http11, Http2, Http3, } impl fmt::Display for RequestedHttpVersion { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let value = match self { RequestedHttpVersion::Default => "HTTP (default)", RequestedHttpVersion::Http10 => "HTTP/1.0", RequestedHttpVersion::Http11 => "HTTP/1.1", RequestedHttpVersion::Http2 => "HTTP/2", RequestedHttpVersion::Http3 => "HTTP/3", }; write!(f, "{value}") } } #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] pub enum IpResolve { /// Default, can use addresses of all IP versions that your system allows. #[default] Default, IpV4, IpV6, } impl Request { /// Creates a new request. pub fn new(method: &str, url: Url, headers: HeaderVec, body: Vec) -> Self { Request { url, method: method.to_string(), headers, body, } } /// Returns a list of request headers cookie. /// /// see pub fn cookies(&self) -> Vec { self.headers .get_all(COOKIE) .iter() .flat_map(|h| parse_cookies(h.value.as_str().trim())) .collect() } } fn parse_cookies(s: &str) -> Vec { s.split(';').map(|t| parse_cookie(t.trim())).collect() } fn parse_cookie(s: &str) -> RequestCookie { match s.find('=') { Some(i) => RequestCookie { name: s.split_at(i).0.to_string(), value: s.split_at(i + 1).1.to_string(), }, None => RequestCookie { name: s.to_string(), value: String::new(), }, } } #[cfg(test)] mod tests { use super::*; use crate::http::{Header, RequestCookie}; fn hello_request() -> Request { let mut headers = HeaderVec::new(); headers.push(Header::new("Host", "localhost:8000")); headers.push(Header::new("Accept", "*/*")); headers.push(Header::new("User-Agent", "hurl/1.0")); headers.push(Header::new("content-type", "application/json")); let url = "http://localhost:8000/hello".parse().unwrap(); Request::new("GET", url, headers, vec![]) } fn query_string_request() -> Request { let url = "http://localhost:8000/querystring-params?param1=value1¶m2=¶m3=a%3Db¶m4=1%2C2%2C3".parse().unwrap(); Request::new("GET", url, HeaderVec::new(), vec![]) } fn cookies_request() -> Request { let mut headers = HeaderVec::new(); headers.push(Header::new("Cookie", "cookie1=value1; cookie2=value2")); let url = "http://localhost:8000/cookies".parse().unwrap(); Request::new("GET", url, headers, vec![]) } #[test] fn test_content_type() { assert_eq!( hello_request().headers.content_type(), Some("application/json") ); assert_eq!(query_string_request().headers.content_type(), None); assert_eq!(cookies_request().headers.content_type(), None); } #[test] fn test_cookies() { assert!(hello_request().cookies().is_empty()); assert_eq!( cookies_request().cookies(), vec![ RequestCookie { name: "cookie1".to_string(), value: "value1".to_string(), }, RequestCookie { name: "cookie2".to_string(), value: "value2".to_string(), }, ] ); } #[test] fn test_parse_cookies() { assert_eq!( parse_cookies("cookie1=value1; cookie2=value2"), vec![ RequestCookie { name: "cookie1".to_string(), value: "value1".to_string(), }, RequestCookie { name: "cookie2".to_string(), value: "value2".to_string(), }, ] ); } #[test] fn test_parse_cookie() { assert_eq!( parse_cookie("cookie1=value1"), RequestCookie { name: "cookie1".to_string(), value: "value1".to_string(), }, ); } } hurl-6.1.1/src/http/request_spec.rs000064400000000000000000000063121046102023000154230ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use core::fmt; use crate::http::header::HeaderVec; use crate::http::{Param, RequestCookie, Url}; /// Represents the HTTP request asked to be executed by our user (different from the runtime /// executed HTTP request [`crate::http::Request`]. #[derive(Clone, Debug, PartialEq, Eq)] pub struct RequestSpec { pub method: Method, pub url: Url, pub headers: HeaderVec, pub querystring: Vec, pub form: Vec, pub multipart: Vec, pub cookies: Vec, pub body: Body, /// This is the implicit content type of the request: this content type is implicitly set when /// the request use a "typed" body: form, JSON, multipart, multiline string with hint. This /// implicit content type can be different from the user provided one through the `headers` /// field. pub implicit_content_type: Option, } impl Default for RequestSpec { fn default() -> Self { RequestSpec { method: Method("GET".to_string()), url: Url::default(), headers: HeaderVec::new(), querystring: vec![], form: vec![], multipart: vec![], cookies: vec![], body: Body::Binary(vec![]), implicit_content_type: None, } } } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Method(pub String); #[derive(Clone, Debug, PartialEq, Eq)] pub enum MultipartParam { Param(Param), FileParam(FileParam), } #[derive(Clone, Debug, PartialEq, Eq)] pub struct FileParam { pub name: String, pub filename: String, pub data: Vec, pub content_type: String, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum Body { Text(String), Binary(Vec), File(Vec, String), } impl Body { pub fn bytes(&self) -> Vec { match self { Body::Text(s) => s.as_bytes().to_vec(), Body::Binary(bs) => bs.clone(), Body::File(bs, _) => bs.clone(), } } } impl fmt::Display for Method { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.0) } } impl fmt::Display for MultipartParam { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { MultipartParam::Param(param) => write!(f, "{param}"), MultipartParam::FileParam(param) => write!(f, "{param}"), } } } impl fmt::Display for FileParam { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, "{}: file,{}; {}", self.name, self.filename, self.content_type ) } } hurl-6.1.1/src/http/response.rs000064400000000000000000000060261046102023000145610ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::fmt; use std::time::Duration; use crate::http::certificate::Certificate; use crate::http::ip::IpAddr; use crate::http::{HeaderVec, Url}; /// Represents a runtime HTTP response. /// This is a real response, that has been executed by our HTTP client. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Response { pub version: HttpVersion, pub status: u32, pub headers: HeaderVec, pub body: Vec, pub duration: Duration, pub url: Url, /// The end-user certificate, in the response certificate chain pub certificate: Option, pub ip_addr: IpAddr, } impl Response { /// Creates a new HTTP response #[allow(clippy::too_many_arguments)] pub fn new( version: HttpVersion, status: u32, headers: HeaderVec, body: Vec, duration: Duration, url: Url, certificate: Option, ip_addr: IpAddr, ) -> Self { Response { version, status, headers, body, duration, url, certificate, ip_addr, } } } /// Represents the HTTP version of a HTTP transaction. /// See #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum HttpVersion { Http10, Http11, Http2, Http3, } impl fmt::Display for HttpVersion { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let value = match self { HttpVersion::Http10 => "HTTP/1.0", HttpVersion::Http11 => "HTTP/1.1", HttpVersion::Http2 => "HTTP/2", HttpVersion::Http3 => "HTTP/3", }; write!(f, "{value}") } } #[cfg(test)] mod tests { use super::*; use crate::http::Header; #[test] fn get_header_values() { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Length", "12")); let response = Response { version: HttpVersion::Http10, status: 200, headers, body: vec![], duration: Default::default(), url: "http://localhost".parse().unwrap(), certificate: None, ip_addr: Default::default(), }; assert_eq!(response.headers.values("Content-Length"), vec!["12"]); assert!(response.headers.values("Unknown").is_empty()); } } hurl-6.1.1/src/http/response_cookie.rs000064400000000000000000000022151046102023000161060ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use crate::http::header::SET_COOKIE; use crate::http::{Response, ResponseCookie}; impl Response { pub fn cookies(&self) -> Vec { self.headers .get_all(SET_COOKIE) .iter() .filter_map(|h| ResponseCookie::parse(&h.value)) .collect() } /// Returns optional cookies from response. pub fn get_cookie(&self, name: &str) -> Option { self.cookies() .into_iter() .find(|cookie| cookie.name == name) } } hurl-6.1.1/src/http/response_debug.rs000064400000000000000000000045351046102023000157320ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::text::{Format, Style, StyledString}; use crate::http::{debug, mimetype, Response}; use crate::util::logger::Logger; impl Response { /// Log a response body as text if possible, or a slice of body bytes. pub fn log_body(&self, debug: bool, logger: &mut Logger) { // We try to decode the HTTP body as text if the response has a text kind content type. // If it ok, we print each line of the body in debug format. Otherwise, we // print the body first 64 bytes. if let Some(content_type) = self.headers.content_type() { if !mimetype::is_kind_of_text(content_type) { debug::log_bytes(&self.body, 64, debug, logger); return; } } match self.text() { Ok(text) => debug::log_text(&text, debug, logger), Err(_) => debug::log_bytes(&self.body, 64, debug, logger), } } pub fn log_info_all(&self, logger: &mut Logger) { let status_line = self.get_status_line_headers(logger.color); logger.info(&status_line); self.log_body(false, logger); logger.info(""); } /// Returns status, version and HTTP headers from this HTTP response. pub fn get_status_line_headers(&self, color: bool) -> String { let mut s = StyledString::new(); s.push_with( &format!("{} {}\n", self.version, self.status), Style::new().green().bold(), ); for header in &self.headers { s.push_with(&header.name, Style::new().cyan().bold()); s.push(&format!(": {}\n", header.value)); } if color { s.to_string(Format::Ansi) } else { s.to_string(Format::Plain) } } } hurl-6.1.1/src/http/response_decoding.rs000064400000000000000000000325751046102023000164250ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ /// /// Decompresses body response /// using the Content-Encoding response header /// /// See https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Encoding use std::io::prelude::*; use encoding::DecoderTrap; use crate::http::{mimetype, HttpError, Response}; #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum ContentEncoding { /// A format using the Brotli algorithm structure (defined in RFC 7932). Brotli, /// A format using the Lempel-Ziv coding (LZ77), with a 32-bit CRC. Gzip, /// Using the zlib structure (defined in RFC 1950) with the deflate compression algorithm. Deflate, /// No encoding. Identity, } impl ContentEncoding { /// Returns an encoding from an HTTP header value `s`. pub fn parse(s: &str) -> Result { match s { "br" => Ok(ContentEncoding::Brotli), "gzip" => Ok(ContentEncoding::Gzip), "deflate" => Ok(ContentEncoding::Deflate), "identity" => Ok(ContentEncoding::Identity), v => Err(HttpError::UnsupportedContentEncoding { description: v.to_string(), }), } } /// Decompresses `data` bytes. pub fn decode(&self, data: &[u8]) -> Result, HttpError> { match self { ContentEncoding::Identity => Ok(data.to_vec()), ContentEncoding::Gzip => uncompress_gzip(data), ContentEncoding::Deflate => uncompress_zlib(data), ContentEncoding::Brotli => uncompress_brotli(data), } } } impl Response { /// Returns response body as text. pub fn text(&self) -> Result { let content_encodings = self.headers.content_encoding()?; let body = if content_encodings.is_empty() { &self.body } else { &self.uncompress_body()? }; let character_encoding = self.headers.character_encoding()?; match character_encoding.decode(body, DecoderTrap::Strict) { Ok(s) => Ok(s), Err(_) => Err(HttpError::InvalidDecoding { charset: character_encoding.name().to_string(), }), } } /// Returns true if response is an HTML response. pub fn is_html(&self) -> bool { self.headers.content_type().is_some_and(mimetype::is_html) } /// Returns true if response is a JSON response. pub fn is_json(&self) -> bool { self.headers.content_type().is_some_and(mimetype::is_json) } /// Returns true if response is a XML response. pub fn is_xml(&self) -> bool { self.headers.content_type().is_some_and(mimetype::is_xml) } /// Decompresses HTTP body response. pub fn uncompress_body(&self) -> Result, HttpError> { let encodings = self.headers.content_encoding()?; let mut data = self.body.clone(); for encoding in &encodings { data = encoding.decode(&data)?; } Ok(data) } } /// Decompresses Brotli compressed `data`. fn uncompress_brotli(data: &[u8]) -> Result, HttpError> { let buffer_size = 4096; let mut reader = brotli::Decompressor::new(data, buffer_size); let mut buf = Vec::new(); match reader.read_to_end(&mut buf) { Ok(_) => Ok(buf), Err(_) => Err(HttpError::CouldNotUncompressResponse { description: "brotli".to_string(), }), } } /// Decompresses GZip compressed `data`. fn uncompress_gzip(data: &[u8]) -> Result, HttpError> { let mut decoder = match libflate::gzip::Decoder::new(data) { Ok(v) => v, Err(_) => { return Err(HttpError::CouldNotUncompressResponse { description: "gzip".to_string(), }) } }; let mut buf = Vec::new(); match decoder.read_to_end(&mut buf) { Ok(_) => Ok(buf), Err(_) => Err(HttpError::CouldNotUncompressResponse { description: "gzip".to_string(), }), } } /// Decompresses Zlib compressed `data`. fn uncompress_zlib(data: &[u8]) -> Result, HttpError> { let mut decoder = match libflate::zlib::Decoder::new(data) { Ok(v) => v, Err(_) => { return Err(HttpError::CouldNotUncompressResponse { description: "zlib".to_string(), }) } }; let mut buf = Vec::new(); match decoder.read_to_end(&mut buf) { Ok(_) => Ok(buf), Err(_) => Err(HttpError::CouldNotUncompressResponse { description: "zlib".to_string(), }), } } #[cfg(test)] pub mod tests { use super::*; use crate::http::{Header, HeaderVec, HttpVersion, Response}; fn default_response() -> Response { Response { version: HttpVersion::Http10, status: 200, headers: HeaderVec::new(), body: vec![], duration: Default::default(), url: "http://localhost".parse().unwrap(), certificate: None, ip_addr: Default::default(), } } #[test] fn test_parse_content_encoding() { assert_eq!( ContentEncoding::parse("br").unwrap(), ContentEncoding::Brotli ); assert_eq!( ContentEncoding::parse("xx").err().unwrap(), HttpError::UnsupportedContentEncoding { description: "xx".to_string() } ); } #[test] fn test_content_encoding() { let response = default_response(); assert_eq!(response.headers.content_encoding().unwrap(), vec![]); let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Encoding", "xx")); let response = Response { headers, ..default_response() }; assert_eq!( response.headers.content_encoding().err().unwrap(), HttpError::UnsupportedContentEncoding { description: "xx".to_string() } ); let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Encoding", "br")); let response = Response { headers, ..default_response() }; assert_eq!( response.headers.content_encoding().unwrap(), vec![ContentEncoding::Brotli] ); } #[test] fn test_multiple_content_encoding() { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Encoding", "br, identity")); let response = Response { headers, ..default_response() }; assert_eq!( response.headers.content_encoding().unwrap(), vec![ContentEncoding::Brotli, ContentEncoding::Identity] ); } #[test] fn test_uncompress_body() { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Encoding", "br")); let response = Response { headers, body: vec![ 0x21, 0x2c, 0x00, 0x04, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x21, 0x03, ], ..default_response() }; assert_eq!(response.uncompress_body().unwrap(), b"Hello World!"); let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Encoding", "br, identity")); let response = Response { headers, body: vec![ 0x21, 0x2c, 0x00, 0x04, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x21, 0x03, ], ..default_response() }; assert_eq!(response.uncompress_body().unwrap(), b"Hello World!"); let response = Response { body: b"Hello World!".to_vec(), ..default_response() }; assert_eq!(response.uncompress_body().unwrap(), b"Hello World!"); } #[test] fn test_uncompress_brotli() { let data = [ 0x21, 0x2c, 0x00, 0x04, 0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x20, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x21, 0x03, ]; assert_eq!(uncompress_brotli(&data[..]).unwrap(), b"Hello World!"); } #[test] fn test_uncompress_gzip() { let data = [ 0x1f, 0x8b, 0x08, 0x08, 0xa7, 0x52, 0x85, 0x5f, 0x00, 0x03, 0x64, 0x61, 0x74, 0x61, 0x2e, 0x74, 0x78, 0x74, 0x00, 0xf3, 0x48, 0xcd, 0xc9, 0xc9, 0x57, 0x08, 0xcf, 0x2f, 0xca, 0x49, 0x51, 0x04, 0x00, 0xa3, 0x1c, 0x29, 0x1c, 0x0c, 0x00, 0x00, 0x00, ]; assert_eq!(uncompress_gzip(&data[..]).unwrap(), b"Hello World!"); } #[test] fn test_uncompress_zlib() { let data = [ 0x78, 0x9c, 0xf3, 0x48, 0xcd, 0xc9, 0xc9, 0x57, 0x08, 0xcf, 0x2f, 0xca, 0x49, 0x51, 0x04, 0x00, 0x1c, 0x49, 0x04, 0x3e, ]; assert_eq!(uncompress_zlib(&data[..]).unwrap(), b"Hello World!"); } #[test] fn test_uncompress_error() { let data = [0x21]; assert_eq!( uncompress_brotli(&data[..]).err().unwrap(), HttpError::CouldNotUncompressResponse { description: "brotli".to_string() } ); assert_eq!( uncompress_gzip(&data[..]).err().unwrap(), HttpError::CouldNotUncompressResponse { description: "gzip".to_string() } ); } fn hello_response() -> Response { Response { body: b"Hello World!".to_vec(), ..default_response() } } fn utf8_encoding_response() -> Response { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Type", "text/plain; charset=utf-8")); Response { headers, body: vec![0x63, 0x61, 0x66, 0xc3, 0xa9], ..default_response() } } fn latin1_encoding_response() -> Response { let mut headers = HeaderVec::new(); headers.push(Header::new( "Content-Type", "text/plain; charset=ISO-8859-1", )); Response { headers, body: vec![0x63, 0x61, 0x66, 0xe9], ..default_response() } } #[test] pub fn test_content_type() { assert_eq!(hello_response().headers.content_type(), None); assert_eq!( utf8_encoding_response().headers.content_type(), Some("text/plain; charset=utf-8") ); assert_eq!( latin1_encoding_response().headers.content_type(), Some("text/plain; charset=ISO-8859-1") ); } #[test] pub fn test_character_encoding() { assert_eq!( hello_response() .headers .character_encoding() .unwrap() .name(), "utf-8" ); assert_eq!( utf8_encoding_response() .headers .character_encoding() .unwrap() .name(), "utf-8" ); assert_eq!( latin1_encoding_response() .headers .character_encoding() .unwrap() .name(), "windows-1252" ); } #[test] pub fn test_text() { assert_eq!(hello_response().text().unwrap(), "Hello World!".to_string()); assert_eq!(utf8_encoding_response().text().unwrap(), "café".to_string()); assert_eq!( latin1_encoding_response().text().unwrap(), "café".to_string() ); } #[test] pub fn test_invalid_charset() { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Type", "test/plain; charset=xxx")); assert_eq!( Response { headers, body: b"Hello World!".to_vec(), ..default_response() } .headers .character_encoding() .err() .unwrap(), HttpError::InvalidCharset { charset: "xxx".to_string() } ); } #[test] pub fn test_invalid_decoding() { assert_eq!( Response { body: vec![0x63, 0x61, 0x66, 0xe9], ..default_response() } .text() .err() .unwrap(), HttpError::InvalidDecoding { charset: "utf-8".to_string() } ); let mut headers = HeaderVec::new(); headers.push(Header::new( "Content-Type", "text/plain; charset=ISO-8859-1", )); assert_eq!( Response { headers, body: vec![0x63, 0x61, 0x66, 0xc3, 0xa9], ..default_response() } .text() .unwrap(), "café".to_string() ); } } hurl-6.1.1/src/http/tests/mod.rs000064400000000000000000000113631046102023000146440ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ //! Some [`Request`]/[`Response`] used by tests. use std::str::FromStr; use crate::http::{ Header, HeaderVec, HttpVersion, Method, Param, RequestCookie, RequestSpec, Response, Url, }; fn default_response() -> Response { Response { version: HttpVersion::Http10, status: 200, headers: HeaderVec::new(), body: vec![], duration: Default::default(), url: Url::from_str("http://localhost").unwrap(), certificate: None, ip_addr: Default::default(), } } pub fn hello_http_request() -> RequestSpec { RequestSpec { method: Method("GET".to_string()), url: Url::from_str("http://localhost:8000/hello").unwrap(), ..Default::default() } } pub fn json_http_response() -> Response { Response { body: String::into_bytes( r#" { "success":false, "errors": [ { "id": "error1"}, {"id": "error2"} ], "duration": 1.5 } "# .to_string(), ), ..default_response() } } pub fn xml_two_users_http_response() -> Response { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Type", "text/html; charset=utf-8")); headers.push(Header::new("Content-Length", "12")); Response { headers, body: String::into_bytes( r#" Bob Bill "# .to_string(), ), ..default_response() } } pub fn xml_three_users_http_response() -> Response { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Type", "text/html; charset=utf-8")); headers.push(Header::new("Content-Length", "12")); Response { headers, body: String::into_bytes( r#" Bob Bill Bruce "# .to_string(), ), ..default_response() } } pub fn hello_http_response() -> Response { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Type", "text/html; charset=utf-8")); headers.push(Header::new("Content-Length", "12")); Response { headers, body: String::into_bytes(String::from("Hello World!")), ..default_response() } } pub fn bytes_http_response() -> Response { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Type", "application/octet-stream")); headers.push(Header::new("Content-Length", "1")); Response { headers, body: vec![255], ..default_response() } } pub fn html_http_response() -> Response { let mut headers = HeaderVec::new(); headers.push(Header::new("Content-Type", "application/octet-stream")); Response { headers, body: String::into_bytes(String::from( "
", )), ..default_response() } } pub fn query_http_request() -> RequestSpec { RequestSpec { method: Method("GET".to_string()), url: Url::from_str("http://localhost:8000/querystring-params").unwrap(), querystring: vec![ Param { name: String::from("param1"), value: String::from("value1"), }, Param { name: String::from("param2"), value: String::from("a b"), }, ], ..Default::default() } } pub fn custom_http_request() -> RequestSpec { let mut headers = HeaderVec::new(); headers.push(Header::new("User-Agent", "iPhone")); headers.push(Header::new("Foo", "Bar")); RequestSpec { method: Method("GET".to_string()), url: Url::from_str("http://localhost/custom").unwrap(), headers, cookies: vec![ RequestCookie { name: String::from("theme"), value: String::from("light"), }, RequestCookie { name: String::from("sessionToken"), value: String::from("abc123"), }, ], ..Default::default() } } hurl-6.1.1/src/http/timings.rs000064400000000000000000000052511046102023000143740ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::time::Duration; use chrono::{DateTime, Utc}; use curl::easy::Easy; use crate::http::easy_ext; /// Timing information for an HTTP transfer (see ). // See [`easy_ext::namelookup_time_t`], [`easy_ext::connect_time_t`], [`easy_ext::app_connect_time_t`], // [`easy_ext::pre_transfer_time_t`], [`easy_ext::start_transfer_time_t`] and [`easy_ext::total_time_t`] // for fields definition. #[derive(Clone, Debug, PartialEq, Eq, Default)] pub struct Timings { pub begin_call: DateTime, pub end_call: DateTime, pub name_lookup: Duration, pub connect: Duration, pub app_connect: Duration, pub pre_transfer: Duration, pub start_transfer: Duration, pub total: Duration, } impl Timings { pub fn new(easy: &mut Easy, begin_call: DateTime, end_call: DateTime) -> Self { // We try the *_t timing function of libcurl (available for libcurl >= 7.61.0) // returning timing in nanoseconds, or fallback to timing function returning seconds // if *_t are not available. let name_lookup = easy_ext::namelookup_time_t(easy) .or(easy.namelookup_time()) .unwrap_or_default(); let connect = easy_ext::connect_time_t(easy) .or(easy.connect_time()) .unwrap_or_default(); let app_connect = easy_ext::appconnect_time_t(easy) .or(easy.appconnect_time()) .unwrap_or_default(); let pre_transfer = easy_ext::pretransfer_time_t(easy) .or(easy.pretransfer_time()) .unwrap_or_default(); let start_transfer = easy_ext::starttransfer_time_t(easy) .or(easy.starttransfer_time()) .unwrap_or_default(); let total = easy_ext::total_time_t(easy) .or(easy.total_time()) .unwrap_or_default(); Timings { begin_call, end_call, name_lookup, connect, app_connect, pre_transfer, start_transfer, total, } } } hurl-6.1.1/src/http/timings_debug.rs000064400000000000000000000027751046102023000155520ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use crate::http::Timings; use crate::util::logger::Logger; impl Timings { /// Logs the response timings information. pub fn log(&self, logger: &mut Logger) { logger.debug_important("Timings:"); logger.debug(&format!("begin: {}", self.begin_call)); logger.debug(&format!("end: {}", self.end_call)); logger.debug(&format!("namelookup: {} µs", self.name_lookup.as_micros())); logger.debug(&format!("connect: {} µs", self.connect.as_micros())); logger.debug(&format!("app_connect: {} µs", self.app_connect.as_micros())); logger.debug(&format!( "pre_transfer: {} µs", self.pre_transfer.as_micros() )); logger.debug(&format!( "start_transfer: {} µs", self.start_transfer.as_micros() )); logger.debug(&format!("total: {} µs", self.total.as_micros())); } } hurl-6.1.1/src/http/url.rs000064400000000000000000000151131046102023000135220ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::fmt; use std::str::FromStr; use regex::Regex; use crate::http::{HttpError, Param}; /// A parsed URL. #[derive(Clone, Eq, PartialEq, Debug)] pub struct Url { /// The input url from the user raw: String, /// A structured URL (implementation). inner: url::Url, } impl Default for Url { fn default() -> Self { Url::from_str("https://localhost").unwrap() } } impl Url { pub fn raw(&self) -> String { self.raw.clone() } /// Returns a list of query parameters (values are URL decoded). pub fn query_params(&self) -> Vec { self.inner .query_pairs() .map(|(k, v)| Param::new(&k, &v)) .collect() } pub fn host(&self) -> String { self.inner .host() .expect("HTTP and HTTPS URL must have a domain") .to_string() } pub fn domain(&self) -> Option { self.inner.domain().map(|s| s.to_string()) } pub fn path(&self) -> String { self.inner.path().to_string() } /// Parse a string `input` as an URL, with this URL as the base URL. pub fn join(&self, input: &str) -> Result { let new_inner = self.inner.join(input); let new_inner = match new_inner { Ok(u) => u, Err(_) => { return Err(HttpError::InvalidUrl( self.inner.to_string(), format!("Can not use relative path '{input}'"), )) } }; new_inner.as_str().parse() } } /// Extracting scheme from `url` /// /// The parse method from the url crate does not seem to parse url without scheme /// For example, "localhost:8000" is parsed with its scheme set to "localhost" /// fn scheme(url: &str) -> Option { let re = Regex::new("^([a-z]+://).*").unwrap(); if let Some(caps) = re.captures(url) { let scheme = &caps[1]; Some(scheme.to_string()) } else { None } } impl FromStr for Url { type Err = HttpError; /// Parses an absolute URL from a string. fn from_str(value: &str) -> Result { match scheme(value) { None => { return Err(HttpError::InvalidUrl( value.to_string(), "Missing scheme or ".to_string(), )); } Some(scheme) => { if scheme != "http://" && scheme != "https://" { return Err(HttpError::InvalidUrl( value.to_string(), "Only and schemes are supported".to_string(), )); } } } let raw = value.to_string(); let inner = url::Url::parse(value) .map_err(|e| HttpError::InvalidUrl(raw.to_string(), e.to_string()))?; Ok(Url { raw, inner }) } } impl fmt::Display for Url { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.inner) } } #[cfg(test)] mod tests { use std::str::FromStr; use super::Url; use crate::http::url::scheme; use crate::http::{HttpError, Param}; #[test] fn parse_url_ok() { let urls = [ "http://localhost:8000/hello", "http://localhost:8000/querystring-params?param1=value1¶m2=¶m3=a%3Db¶m4=1%2C2%2C3", "http://localhost:8000/cookies", "http://localhost", "https://localhost:8000", "http://localhost:8000/path-as-is/../resource" ]; for url in urls { assert!(Url::from_str(url).is_ok()); } } #[test] fn query_params() { let url: Url = "http://localhost:8000/hello".parse().unwrap(); assert_eq!(url.query_params(), vec![]); let url: Url = "http://localhost:8000/querystring-params?param1=value1¶m2=¶m3=a%3Db¶m4=1%2C2%2C3".parse().unwrap(); assert_eq!( url.query_params(), vec![ Param::new("param1", "value1"), Param::new("param2", ""), Param::new("param3", "a=b"), Param::new("param4", "1,2,3"), ] ); } #[test] fn test_join() { let base: Url = "http://example.net/foo/index.html".parse().unwrap(); // Test join with absolute assert_eq!( base.join("http://bar.com/redirected").unwrap(), "http://bar.com/redirected".parse().unwrap() ); // Test join with relative assert_eq!( base.join("/redirected").unwrap(), "http://example.net/redirected".parse().unwrap() ); assert_eq!( base.join("../bar/index.html").unwrap(), "http://example.net/bar/index.html".parse().unwrap() ); // Scheme relative URL assert_eq!( base.join("//example.org/baz/index.html").unwrap(), "http://example.org/baz/index.html".parse().unwrap() ); } #[test] fn test_parsing_error() { assert_eq!( Url::from_str("localhost:8000").err().unwrap(), HttpError::InvalidUrl( "localhost:8000".to_string(), "Missing scheme or ".to_string() ) ); assert_eq!( Url::from_str("file://localhost:8000").err().unwrap(), HttpError::InvalidUrl( "file://localhost:8000".to_string(), "Only and schemes are supported".to_string() ) ); } #[test] fn test_extract_scheme() { assert!(scheme("localhost:8000").is_none()); assert!(scheme("http1://localhost:8000").is_none()); assert!(scheme("://localhost:8000").is_none()); assert_eq!(scheme("file://data").unwrap(), "file://".to_string()); assert_eq!(scheme("http://data").unwrap(), "http://".to_string()); } } hurl-6.1.1/src/http/version.rs000064400000000000000000000106121046102023000144040ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::collections::HashMap; #[derive(Clone, Debug, PartialEq, Eq)] pub struct CurlVersionInfo { pub host: String, pub libraries: Vec, pub features: Vec, } /// Returns the libraries and features of libcurl. /// /// Output should be similar to `curl --version` /// - /// - pub fn libcurl_version_info() -> CurlVersionInfo { let version = curl::Version::get(); let host = version.host().to_string(); let mut libraries = vec![format!("libcurl/{}", version.version())]; if let Some(s) = version.ssl_version() { libraries.push(s.to_string()); } if let Some(s) = version.libz_version() { libraries.push(format!("zlib/{s}")); } if let Some(s) = version.brotli_version() { libraries.push(format!("brotli/{s}")); } if let Some(s) = version.zstd_version() { libraries.push(format!("zstd/{s}")); } if let Some(s) = version.ares_version() { libraries.push(format!("c-ares/{s}")); } if let Some(s) = version.libidn_version() { libraries.push(format!("libidn2/{s}")); } if let Some(s) = version.iconv_version_num() { if s != 0 { libraries.push(format!("iconv/{s}")); } } if let Some(s) = version.libssh_version() { libraries.push(s.to_string()); } if let Some(s) = version.nghttp2_version() { libraries.push(format!("nghttp2/{s}")); } if let Some(s) = version.quic_version() { libraries.push(format!("quic/{s}")); } if let Some(s) = version.hyper_version() { libraries.push(format!("hyper/{s}")); } if let Some(s) = version.gsasl_version() { libraries.push(format!("libgsal/{s}")); } // FIXME: some flags are not present in crates curl-rust. // See https://github.com/alexcrichton/curl-rust/issues/464 // See https://github.com/curl/curl/blob/master/include/curl/curl.h for all curl flags // See https://github.com/alexcrichton/curl-rust/blob/main/curl-sys/lib.rs for curl-rust flags // Not defined in curl-rust: // - CURL_VERSION_GSSAPI (1<<17) // - CURL_VERSION_KERBEROS5 (1<<18) // - CURL_VERSION_PSL (1<<20) // - CURL_VERSION_HTTPS_PROXY (1<<21) // - CURL_VERSION_MULTI_SSL (1<<22) // - CURL_VERSION_THREADSAFE (1<<30) let all_features = HashMap::from([ ("AsynchDNS", version.feature_async_dns()), ("Debug", version.feature_debug()), ("IDN", version.feature_idn()), ("IPv6", version.feature_ipv6()), ("Largefile", version.feature_largefile()), ("Unicode", version.feature_unicode()), ("SSPI", version.feature_sspi()), ("SPNEGO", version.feature_spnego()), ("NTLM", version.feature_ntlm()), ("NTLM_WB", version.feature_ntlm_wb()), ("SSL", version.feature_ssl()), ("libz", version.feature_libz()), ("brotli", version.feature_brotli()), ("zstd", version.feature_zstd()), ("CharConv", version.feature_conv()), ("TLS-SRP", version.feature_tlsauth_srp()), ("HTTP2", version.feature_http2()), ("HTTP3", version.feature_http3()), ("UnixSockets", version.feature_unix_domain_socket()), ("alt-svc", version.feature_altsvc()), ("HSTS", version.feature_hsts()), ("gsasl", version.feature_gsasl()), ("GSS-Negotiate", version.feature_gss_negotiate()), ]); let mut features: Vec = vec![]; for (k, v) in all_features.iter() { if *v { features.push(k.to_string()); } } features.sort_by_key(|k| k.to_lowercase()); CurlVersionInfo { host, libraries, features, } } hurl-6.1.1/src/json/mod.rs000064400000000000000000000013211046102023000134650ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ //! Serialize / Deserialize a [`crate::runner::HurlResult`] to JSON. mod result; mod value; hurl-6.1.1/src/json/result.rs000064400000000000000000000355021046102023000142340ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::fs::File; use std::io; use std::io::Write; use std::path::{Path, PathBuf}; use chrono::SecondsFormat; use hurl_core::ast::SourceInfo; use hurl_core::error::{DisplaySourceError, OutputFormat}; use hurl_core::input::Input; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::http::{ Call, Certificate, Cookie, Header, HttpVersion, Param, Request, RequestCookie, Response, ResponseCookie, Timings, }; use crate::runner::{AssertResult, CaptureResult, EntryResult, HurlResult}; use crate::util::redacted::Redact; impl HurlResult { /// Serializes an [`HurlResult`] to a JSON representation. /// /// Note: `content` is passed to this method to save asserts and errors messages (with lines /// and columns). This parameter will be removed soon and the original content will be /// accessible through the [`HurlResult`] instance. /// An optional directory `response_dir` can be used to save HTTP response. /// `secrets` strings are redacted from the JSON fields. pub fn to_json( &self, content: &str, filename: &Input, response_dir: Option<&Path>, secrets: &[&str], ) -> Result { let result = HurlResultJson::from_result(self, content, filename, response_dir, secrets)?; let value = serde_json::to_value(result)?; Ok(value) } /// Checks if a JSON value can be deserialized to a `HurlResult` instance. /// This method can be used to check if the schema of the `value` is conform to /// a `HurlResult`. pub fn is_deserializable(value: &serde_json::Value) -> bool { serde_json::from_value::(value.clone()).is_ok() } } /// These structures represent the JSON schema used to serialize an [`HurlResult`] to JSON. #[derive(Deserialize, Serialize)] struct HurlResultJson { filename: String, entries: Vec, success: bool, time: u64, cookies: Vec, } #[derive(Deserialize, Serialize)] struct EntryResultJson { index: usize, line: usize, calls: Vec, captures: Vec, asserts: Vec, time: u64, curl_cmd: String, } #[derive(Deserialize, Serialize)] struct CookieJson { domain: String, include_subdomain: String, path: String, https: String, expires: String, name: String, value: String, } #[derive(Deserialize, Serialize)] struct CallJson { request: RequestJson, response: ResponseJson, timings: TimingsJson, } #[derive(Deserialize, Serialize)] struct CaptureJson { name: String, value: serde_json::Value, } #[derive(Deserialize, Serialize)] struct AssertJson { success: bool, #[serde(skip_serializing_if = "Option::is_none")] message: Option, line: usize, } #[derive(Deserialize, Serialize)] struct RequestJson { method: String, url: String, headers: Vec, cookies: Vec, query_string: Vec, } #[derive(Deserialize, Serialize)] struct ResponseJson { http_version: String, status: u32, headers: Vec, cookies: Vec, #[serde(skip_serializing_if = "Option::is_none")] certificate: Option, #[serde(skip_serializing_if = "Option::is_none")] body: Option, } #[derive(Deserialize, Serialize)] struct TimingsJson { begin_call: String, end_call: String, name_lookup: u64, connect: u64, app_connect: u64, pre_transfer: u64, start_transfer: u64, total: u64, } #[derive(Deserialize, Serialize)] struct HeaderJson { name: String, value: String, } #[derive(Deserialize, Serialize)] struct RequestCookieJson { name: String, value: String, } #[derive(Deserialize, Serialize)] struct ParamJson { name: String, value: String, } #[derive(Deserialize, Serialize)] struct ResponseCookieJson { name: String, value: String, #[serde(skip_serializing_if = "Option::is_none")] expires: Option, // FIXME: maybe max_age should be u64 #[serde(skip_serializing_if = "Option::is_none")] max_age: Option, #[serde(skip_serializing_if = "Option::is_none")] domain: Option, #[serde(skip_serializing_if = "Option::is_none")] path: Option, #[serde(skip_serializing_if = "Option::is_none")] secure: Option, #[serde(skip_serializing_if = "Option::is_none", rename = "httponly")] http_only: Option, #[serde(skip_serializing_if = "Option::is_none", rename = "same_site")] same_site: Option, } #[derive(Deserialize, Serialize)] struct CertificateJson { subject: String, issuer: String, start_date: String, expire_date: String, serial_number: String, } impl HurlResultJson { fn from_result( result: &HurlResult, content: &str, filename: &Input, response_dir: Option<&Path>, secrets: &[&str], ) -> Result { let entries = result .entries .iter() .map(|e| EntryResultJson::from_entry(e, content, filename, response_dir, secrets)) .collect::, _>>()?; let cookies = result .cookies .iter() .map(|c| CookieJson::from_cookie(c, secrets)) .collect::>(); Ok(HurlResultJson { filename: filename.to_string(), entries, success: result.success, time: result.duration.as_millis() as u64, cookies, }) } } impl EntryResultJson { fn from_entry( entry: &EntryResult, content: &str, filename: &Input, response_dir: Option<&Path>, secrets: &[&str], ) -> Result { let calls = entry .calls .iter() .map(|c| CallJson::from_call(c, response_dir, secrets)) .collect::, _>>()?; let captures = entry .captures .iter() .map(|c| CaptureJson::from_capture(c, secrets)) .collect::>(); let asserts = entry .asserts .iter() .map(|a| AssertJson::from_assert(a, content, filename, entry.source_info, secrets)) .collect::>(); Ok(EntryResultJson { index: entry.entry_index, line: entry.source_info.start.line, calls, captures, asserts, time: entry.transfer_duration.as_millis() as u64, curl_cmd: entry.curl_cmd.to_string().redact(secrets), }) } } impl CookieJson { fn from_cookie(c: &Cookie, secrets: &[&str]) -> Self { CookieJson { domain: c.domain.clone(), include_subdomain: c.include_subdomain.clone(), path: c.path.clone(), https: c.https.clone(), expires: c.expires.clone(), name: c.name.clone(), value: c.value.redact(secrets), } } } impl CallJson { fn from_call( call: &Call, response_dir: Option<&Path>, secrets: &[&str], ) -> Result { let request = RequestJson::from_request(&call.request, secrets); let response = ResponseJson::from_response(&call.response, response_dir, secrets)?; let timings = TimingsJson::from_timings(&call.timings); Ok(CallJson { request, response, timings, }) } } impl RequestJson { fn from_request(request: &Request, secrets: &[&str]) -> Self { let headers = request .headers .iter() .map(|h| HeaderJson::from_header(h, secrets)) .collect::>(); let cookies = request .cookies() .iter() .map(|c| RequestCookieJson::from_cookie(c, secrets)) .collect::>(); let query_string = request .url .query_params() .iter() .map(|p| ParamJson::from_param(p, secrets)) .collect::>(); RequestJson { method: request.method.clone(), url: request.url.to_string().redact(secrets), headers, cookies, query_string, } } } impl ResponseJson { fn from_response( response: &Response, response_dir: Option<&Path>, secrets: &[&str], ) -> Result { let http_version = match response.version { HttpVersion::Http10 => "HTTP/1.0", HttpVersion::Http11 => "HTTP/1.1", HttpVersion::Http2 => "HTTP/2", HttpVersion::Http3 => "HTTP/3", }; let headers = response .headers .iter() .map(|h| HeaderJson::from_header(h, secrets)) .collect::>(); let cookies = response .cookies() .iter() .map(|c| ResponseCookieJson::from_cookie(c, secrets)) .collect::>(); let certificate = response .certificate .as_ref() .map(CertificateJson::from_certificate); let body = match response_dir { Some(response_dir) => { // FIXME: we save the filename and the parent dir: this feature is used in the // context of the JSON report where the response are stored: // // ``` // response_dir // ├── report.json // └── store // ├── 1fe9d647-5689-4130-b4ea-dc120c2536ba_response.html // ├── 35f49c69-15f9-43df-a672-a1ff5f68c935_response.json // ... // └── ce7f1326-2e2a-46e9-befd-ee0d85084814_response.json // ``` // we want the `body` field to reference the relative path of a response compared // to `report.json`. let file = write_response(response, response_dir)?; let parent = response_dir.components().last().unwrap(); let parent: &Path = parent.as_ref(); Some(format!("{}/{}", parent.display(), file.display())) } None => None, }; Ok(ResponseJson { http_version: http_version.to_string(), status: response.status, headers, cookies, certificate, body, }) } } impl TimingsJson { fn from_timings(timings: &Timings) -> Self { TimingsJson { begin_call: timings .begin_call .to_rfc3339_opts(SecondsFormat::Micros, true), end_call: timings .end_call .to_rfc3339_opts(SecondsFormat::Micros, true), name_lookup: timings.name_lookup.as_micros() as u64, connect: timings.connect.as_micros() as u64, app_connect: timings.app_connect.as_micros() as u64, pre_transfer: timings.pre_transfer.as_micros() as u64, start_transfer: timings.start_transfer.as_micros() as u64, total: timings.total.as_micros() as u64, } } } impl HeaderJson { fn from_header(h: &Header, secrets: &[&str]) -> Self { HeaderJson { name: h.name.clone(), value: h.value.redact(secrets), } } } impl RequestCookieJson { fn from_cookie(c: &RequestCookie, secrets: &[&str]) -> Self { RequestCookieJson { name: c.name.clone(), value: c.value.redact(secrets), } } } impl ParamJson { fn from_param(p: &Param, secrets: &[&str]) -> Self { ParamJson { name: p.name.clone(), value: p.value.redact(secrets), } } } impl ResponseCookieJson { fn from_cookie(c: &ResponseCookie, secrets: &[&str]) -> Self { ResponseCookieJson { name: c.name.clone(), value: c.value.redact(secrets), expires: c.expires(), max_age: c.max_age().map(|m| m.to_string()), domain: c.domain(), path: c.path(), secure: if c.has_secure() { Some(true) } else { None }, http_only: if c.has_httponly() { Some(true) } else { None }, same_site: c.samesite(), } } } impl CertificateJson { fn from_certificate(c: &Certificate) -> Self { CertificateJson { subject: c.subject.clone(), issuer: c.issuer.clone(), start_date: c.start_date.to_string(), expire_date: c.expire_date.to_string(), serial_number: c.serial_number.to_string(), } } } impl CaptureJson { fn from_capture(c: &CaptureResult, secrets: &[&str]) -> Self { CaptureJson { name: c.name.clone(), value: c.value.to_json(secrets), } } } impl AssertJson { fn from_assert( a: &AssertResult, content: &str, filename: &Input, entry_src_info: SourceInfo, secrets: &[&str], ) -> Self { let message = a.error().map(|err| { err.to_string( &filename.to_string(), content, Some(entry_src_info), OutputFormat::Plain, ) }); let message = message.map(|m| m.redact(secrets)); AssertJson { success: a.error().is_none(), message, line: a.line(), } } } /// Write the HTTP `response` body to directory `dir`. fn write_response(response: &Response, dir: &Path) -> Result { let extension = if response.is_json() { Some("json") } else if response.is_xml() { Some("xml") } else if response.is_html() { Some("html") } else { None }; let id = Uuid::new_v4(); let relative_path = format!("{id}_response"); let relative_path = Path::new(&relative_path); let relative_path = match extension { Some(ext) => relative_path.with_extension(ext), None => relative_path.to_path_buf(), }; let path = dir.join(relative_path.clone()); let mut file = File::create(path)?; file.write_all(&response.body)?; Ok(relative_path) } hurl-6.1.1/src/json/value.rs000064400000000000000000000100141046102023000140210ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::str::FromStr; use base64::engine::general_purpose; use base64::Engine; use crate::runner::{Number, Value}; use crate::util::redacted::Redact; /// Serializes a [`Value`] to JSON, used in captures serialization. /// /// Natural JSON types are used to represent captures: if a [`Value::List`] is captured, /// the serialized data will be a JSON list. /// `secrets` are redacted from string values. impl Value { pub fn to_json(&self, secrets: &[&str]) -> serde_json::Value { match self { Value::Bool(v) => serde_json::Value::Bool(*v), Value::Date(v) => serde_json::Value::String(v.to_string()), Value::Number(v) => v.to_json(), Value::String(s) => serde_json::Value::String(s.redact(secrets)), Value::List(values) => { let values = values.iter().map(|v| v.to_json(secrets)).collect(); serde_json::Value::Array(values) } Value::Object(key_values) => { let mut map = serde_json::Map::new(); for (key, value) in key_values { map.insert(key.to_string(), value.to_json(secrets)); } serde_json::Value::Object(map) } Value::Nodeset(size) => { // For nodeset, we don't have a "native" JSON representation to use as a serialized // format. As a fallback, we serialize with a `type` field: // // ```json // { // "type": "nodeset", // "size": 4, // } // ``` let mut map = serde_json::Map::new(); let size = *size as i64; map.insert( "type".to_string(), serde_json::Value::String("nodeset".to_string()), ); map.insert("size".to_string(), serde_json::Value::from(size)); serde_json::Value::Object(map) } Value::Bytes(v) => { let encoded = general_purpose::STANDARD.encode(v); serde_json::Value::String(encoded) } Value::Null => serde_json::Value::Null, Value::Regex(value) => serde_json::Value::String(value.to_string()), Value::Unit => { // Like nodeset, we don't have a "native" JSON representation for the unit type, // we use a general fallback with `type` field let mut map = serde_json::Map::new(); map.insert( "type".to_string(), serde_json::Value::String("unit".to_string()), ); serde_json::Value::Object(map) } } } } impl Number { /// Serializes a number to JSON. /// /// Numbers that are representable in JSON use the number JSON type, while big number /// will be serialized as string. pub fn to_json(&self) -> serde_json::Value { match self { Number::Integer(v) => serde_json::Value::Number(serde_json::Number::from(*v)), Number::Float(f) => { serde_json::Value::Number(serde_json::Number::from_f64(*f).unwrap()) } Number::BigInteger(s) => { let number = serde_json::Number::from_str(s).unwrap(); serde_json::Value::Number(number) } } } } hurl-6.1.1/src/jsonpath/ast.rs000064400000000000000000000043101046102023000143530ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ // https://cburgmer.github.io/json-path-comparison/ // https://goessner.net/articles/JsonPath/ // https://jsonpath.com/ #[derive(Clone, Debug, PartialEq, Eq)] pub struct Query { pub selectors: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum Selector { Wildcard, NameChild(String), ArrayIndex(usize), // one unique index ArrayIndices(Vec), // two or more indexes (separated by comma) ArraySlice(Slice), ArrayWildcard, Filter(Predicate), RecursiveWildcard, RecursiveKey(String), } // For the time-being // use simple slice start:end (without the step) #[derive(Clone, Debug, PartialEq, Eq)] pub struct Slice { pub start: Option, pub end: Option, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct Predicate { pub key: Vec, pub func: PredicateFunc, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum PredicateFunc { KeyExist, EqualBool(bool), EqualString(String), NotEqualString(String), Equal(Number), NotEqual(Number), GreaterThan(Number), GreaterThanOrEqual(Number), LessThan(Number), LessThanOrEqual(Number), } // Number // - without rounding // - Equalable #[derive(Clone, Debug, PartialEq, Eq)] pub struct Number { pub int: i64, pub decimal: u64, } impl Number { pub fn to_f64(&self) -> f64 { self.int as f64 + self.decimal as f64 / 1_000_000_000_000_000_000.0 } } #[cfg(test)] mod tests { use super::*; #[test] pub fn test_number() { assert!((Number { int: 1, decimal: 0 }.to_f64() - 1.0).abs() < 0.0000001); } } hurl-6.1.1/src/jsonpath/eval/mod.rs000064400000000000000000000015531046102023000153000ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ pub mod query; mod selector; #[derive(Clone, Debug, PartialEq, Eq)] pub enum JsonpathResult { SingleEntry(serde_json::Value), // returned by a "definite" path Collection(Vec), // returned by a "indefinite" path } hurl-6.1.1/src/jsonpath/eval/query.rs000064400000000000000000000173031046102023000156660ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use crate::jsonpath::ast::Query; use crate::jsonpath::JsonpathResult; impl Query { /// Eval a JSONPath `Query` for a `serde_json::Value` input. /// It returns an Option<`JsonResultPath`>. pub fn eval(&self, value: &serde_json::Value) -> Option { let mut result = JsonpathResult::SingleEntry(value.clone()); for selector in &self.selectors { match result.clone() { JsonpathResult::SingleEntry(value) => { result = selector.eval(&value)?; } JsonpathResult::Collection(values) => { let mut elements = vec![]; for value in values { if let Some(value) = selector.eval(&value) { match value { JsonpathResult::SingleEntry(new_value) => { elements.push(new_value); } JsonpathResult::Collection(mut new_values) => { elements.append(&mut new_values); } } } result = JsonpathResult::Collection(elements.clone()); } } } } Some(result) } } #[cfg(test)] mod tests { use serde_json::json; use crate::jsonpath::ast::{Number, Predicate, PredicateFunc, Query, Selector}; use crate::jsonpath::JsonpathResult; pub fn json_root() -> serde_json::Value { json!({ "store": json_store() }) } pub fn json_store() -> serde_json::Value { json!({ "book": json_books(), "bicycle": [ ] }) } pub fn json_books() -> serde_json::Value { json!([ json_first_book(), json_second_book(), json_third_book(), json_fourth_book() ]) } pub fn json_first_book() -> serde_json::Value { json!({ "category": "reference", "published": false, "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95 }) } pub fn json_second_book() -> serde_json::Value { json!({ "category": "fiction", "published": false, "author": "Evelyn Waugh", "title": "Sword of Honour", "price": 12.99 }) } pub fn json_third_book() -> serde_json::Value { json!({ "category": "fiction", "published": true, "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 }) } pub fn json_fourth_book() -> serde_json::Value { json!({ "category": "fiction", "published": false, "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 }) } #[test] pub fn test_query() { assert_eq!( Query { selectors: vec![] }.eval(&json_root()).unwrap(), JsonpathResult::SingleEntry(json_root()) ); assert_eq!( Query { selectors: vec![Selector::NameChild("store".to_string())] } .eval(&json_root()) .unwrap(), JsonpathResult::SingleEntry(json_store()) ); let query = Query { selectors: vec![ Selector::NameChild("store".to_string()), Selector::NameChild("book".to_string()), Selector::ArrayIndex(0), Selector::NameChild("title".to_string()), ], }; assert_eq!( query.eval(&json_root()).unwrap(), JsonpathResult::SingleEntry(json!("Sayings of the Century")) ); // $.store.book[?(@.price<10)].title let query = Query { selectors: vec![ Selector::NameChild("store".to_string()), Selector::NameChild("book".to_string()), Selector::Filter(Predicate { key: vec!["price".to_string()], func: PredicateFunc::LessThan(Number { int: 10, decimal: 0, }), }), Selector::NameChild("title".to_string()), ], }; assert_eq!( query.eval(&json_root()).unwrap(), JsonpathResult::Collection(vec![json!("Sayings of the Century"), json!("Moby Dick")]) ); // $.store.book[?(@.published==true)].title let query = Query { selectors: vec![ Selector::NameChild("store".to_string()), Selector::NameChild("book".to_string()), Selector::Filter(Predicate { key: vec!["published".to_string()], func: PredicateFunc::EqualBool(true), }), Selector::NameChild("title".to_string()), ], }; assert_eq!( query.eval(&json_root()).unwrap(), JsonpathResult::Collection(vec![json!("Moby Dick")]) ); // $.store.book[?(@.published==false)].title let query = Query { selectors: vec![ Selector::NameChild("store".to_string()), Selector::NameChild("book".to_string()), Selector::Filter(Predicate { key: vec!["published".to_string()], func: PredicateFunc::EqualBool(false), }), Selector::NameChild("title".to_string()), ], }; assert_eq!( query.eval(&json_root()).unwrap(), JsonpathResult::Collection(vec![ json!("Sayings of the Century"), json!("Sword of Honour"), json!("The Lord of the Rings") ]) ); // $..author let query = Query { selectors: vec![Selector::RecursiveKey("author".to_string())], }; assert_eq!( query.eval(&json_root()).unwrap(), JsonpathResult::Collection(vec![ json!("Nigel Rees"), json!("Evelyn Waugh"), json!("Herman Melville"), json!("J. R. R. Tolkien") ]) ); // $.store.book[*].author let query = Query { selectors: vec![ Selector::NameChild("store".to_string()), Selector::NameChild("book".to_string()), Selector::ArrayWildcard, Selector::NameChild("author".to_string()), ], }; assert_eq!( query.eval(&json_root()).unwrap(), JsonpathResult::Collection(vec![ json!("Nigel Rees"), json!("Evelyn Waugh"), json!("Herman Melville"), json!("J. R. R. Tolkien") ]) ); } } hurl-6.1.1/src/jsonpath/eval/selector.rs000064400000000000000000000351061046102023000163420ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use crate::jsonpath::ast::{Predicate, PredicateFunc, Selector, Slice}; use crate::jsonpath::JsonpathResult; impl Selector { pub fn eval(&self, root: &serde_json::Value) -> Option { match self { // Selectors returning single JSON node ("finite") Selector::NameChild(field) => root .get(field) .map(|result| JsonpathResult::SingleEntry(result.clone())), Selector::ArrayIndex(index) => root .get(index) .map(|result| JsonpathResult::SingleEntry(result.clone())), // Selectors returning a collection ("indefinite") Selector::Wildcard | Selector::ArrayWildcard => { let mut elements = vec![]; if let serde_json::Value::Array(values) = root { for value in values { elements.push(value.clone()); } } else if let serde_json::Value::Object(key_values) = root { for value in key_values.values() { elements.push(value.clone()); } } Some(JsonpathResult::Collection(elements)) } Selector::ArraySlice(Slice { start, end }) => { let mut elements = vec![]; if let serde_json::Value::Array(values) = root { for (i, value) in values.iter().enumerate() { if let Some(n) = start { let n = if *n < 0 { values.len() as i64 + n } else { *n }; if (i as i64) < n { continue; } } if let Some(n) = end { let n = if *n < 0 { values.len() as i64 + n } else { *n }; if (i as i64) >= n { continue; } } elements.push(value.clone()); } } Some(JsonpathResult::Collection(elements)) } Selector::RecursiveKey(key) => { let mut elements = vec![]; match root { serde_json::Value::Object(ref obj) => { if let Some(elem) = obj.get(key.as_str()) { elements.push(elem.clone()); } for value in obj.values() { if let Some(JsonpathResult::Collection(mut values)) = Selector::RecursiveKey(key.clone()).eval(value) { elements.append(&mut values); } } } serde_json::Value::Array(values) => { for value in values { if let Some(JsonpathResult::Collection(mut values)) = Selector::RecursiveKey(key.clone()).eval(value) { elements.append(&mut values); } } } _ => {} } Some(JsonpathResult::Collection(elements)) } Selector::RecursiveWildcard => { let mut elements = vec![]; match root { serde_json::Value::Object(map) => { for elem in map.values() { elements.push(elem.clone()); if let Some(JsonpathResult::Collection(mut values)) = Selector::RecursiveWildcard.eval(elem) { elements.append(&mut values); } } } serde_json::Value::Array(values) => { for elem in values { elements.push(elem.clone()); if let Some(JsonpathResult::Collection(mut values)) = Selector::RecursiveWildcard.eval(elem) { elements.append(&mut values); } } } _ => {} } Some(JsonpathResult::Collection(elements)) } Selector::Filter(predicate) => { let elements = match root { serde_json::Value::Array(elements) => elements .iter() .filter(|&e| predicate.eval(e.clone())) .cloned() .collect(), _ => vec![], }; Some(JsonpathResult::Collection(elements)) } Selector::ArrayIndices(indexes) => { let mut values = vec![]; for index in indexes { if let Some(value) = root.get(index) { values.push(value.clone()); } } Some(JsonpathResult::Collection(values)) } } } } impl Predicate { pub fn eval(&self, elem: serde_json::Value) -> bool { match elem { serde_json::Value::Object(_) => { if let Some(value) = extract_value(elem, self.key.clone()) { match (value, self.func.clone()) { (_, PredicateFunc::KeyExist) => true, (serde_json::Value::Number(v), PredicateFunc::Equal(ref num)) => { (v.as_f64().unwrap() - num.to_f64()).abs() < f64::EPSILON } (serde_json::Value::Number(v), PredicateFunc::GreaterThan(ref num)) => { v.as_f64().unwrap() > num.to_f64() } ( serde_json::Value::Number(v), PredicateFunc::GreaterThanOrEqual(ref num), ) => v.as_f64().unwrap() >= num.to_f64(), (serde_json::Value::Number(v), PredicateFunc::LessThan(ref num)) => { v.as_f64().unwrap() < num.to_f64() } (serde_json::Value::Number(v), PredicateFunc::LessThanOrEqual(ref num)) => { v.as_f64().unwrap() <= num.to_f64() } (serde_json::Value::String(v), PredicateFunc::EqualString(ref s)) => { v == *s } (serde_json::Value::String(v), PredicateFunc::NotEqualString(ref s)) => { v != *s } (serde_json::Value::Number(v), PredicateFunc::NotEqual(ref num)) => { (v.as_f64().unwrap() - num.to_f64()).abs() >= f64::EPSILON } (serde_json::Value::Bool(v), PredicateFunc::EqualBool(ref s)) => v == *s, _ => false, } } else { false } } _ => false, } } } fn extract_value(obj: serde_json::Value, key_path: Vec) -> Option { let mut path = key_path; let mut value = obj; loop { if path.is_empty() { break; } let key = path.remove(0); match value.get(key) { None => return None, Some(v) => value = v.clone(), } } Some(value) } #[cfg(test)] mod tests { use serde_json::json; use super::*; use crate::jsonpath::ast::Number; pub fn json_root() -> serde_json::Value { json!({ "store": json_store() }) } pub fn json_store() -> serde_json::Value { json!({ "book": json_books(), "bicycle": [ ] }) } pub fn json_books() -> serde_json::Value { json!([ json_first_book(), json_second_book(), json_third_book(), json_fourth_book() ]) } pub fn json_first_book() -> serde_json::Value { json!({ "category": "reference", "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95 }) } pub fn json_second_book() -> serde_json::Value { json!({ "category": "fiction", "author": "Evelyn Waugh", "title": "Sword of Honour", "price": 12.99 }) } pub fn json_third_book() -> serde_json::Value { json!({ "category": "fiction", "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 }) } pub fn json_fourth_book() -> serde_json::Value { json!({ "category": "fiction", "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 }) } #[test] pub fn test_selector_name_child() { assert_eq!( Selector::NameChild("author".to_string()) .eval(&json_first_book()) .unwrap(), JsonpathResult::SingleEntry(json!("Nigel Rees")) ); assert!(Selector::NameChild("undefined".to_string()) .eval(&json_first_book()) .is_none(),); } #[test] pub fn test_selector_array_index() { assert_eq!( Selector::ArrayIndex(0).eval(&json_books()).unwrap(), JsonpathResult::SingleEntry(json_first_book()) ); assert_eq!( Selector::ArrayIndices(vec![1, 2]) .eval(&json_books()) .unwrap(), JsonpathResult::Collection(vec![json_second_book(), json_third_book()]) ); } #[test] pub fn test_selector_array_wildcard() { assert_eq!( Selector::ArrayWildcard.eval(&json_books()).unwrap(), JsonpathResult::Collection(vec![ json_first_book(), json_second_book(), json_third_book(), json_fourth_book() ]) ); } #[test] pub fn test_selector_array_slice() { assert_eq!( Selector::ArraySlice(Slice { start: None, end: Some(2), }) .eval(&json_books()) .unwrap(), JsonpathResult::Collection(vec![json_first_book(), json_second_book(),]) ); } #[test] pub fn test_recursive_key() { assert_eq!( Selector::RecursiveKey("author".to_string()) .eval(&json_root()) .unwrap(), JsonpathResult::Collection(vec![ json!("Nigel Rees"), json!("Evelyn Waugh"), json!("Herman Melville"), json!("J. R. R. Tolkien") ]) ); } // tests from https://cburgmer.github.io/json-path-comparison #[test] pub fn test_array_index() { let value = json!(["first", "second", "third", "forth", "fifth"]); assert_eq!( Selector::ArrayIndex(2).eval(&value).unwrap(), JsonpathResult::SingleEntry(json!("third")) ); assert_eq!( Selector::ArrayIndices(vec![2, 3]).eval(&value).unwrap(), JsonpathResult::Collection(vec![json!("third"), json!("forth")]) ); } #[test] pub fn test_predicate() { assert!(Predicate { key: vec!["key".to_string()], func: PredicateFunc::KeyExist, } .eval(json!({"key": "value"}))); assert!(Predicate { key: vec!["key".to_string()], func: PredicateFunc::EqualString("value".to_string()), } .eval(json!({"key": "value"}))); assert!(!Predicate { key: vec!["key".to_string()], func: PredicateFunc::EqualString("value".to_string()), } .eval(json!({"key": "some"}))); assert!(Predicate { key: vec!["key".to_string()], func: PredicateFunc::Equal(Number { int: 1, decimal: 0 }), } .eval(json!({"key": 1}))); assert!(!Predicate { key: vec!["key".to_string()], func: PredicateFunc::Equal(Number { int: 1, decimal: 0 }), } .eval(json!({"key": 2}))); assert!(!Predicate { key: vec!["key".to_string()], func: PredicateFunc::Equal(Number { int: 1, decimal: 0 }), } .eval(json!({"key": "1"}))); assert!(Predicate { key: vec!["key".to_string()], func: PredicateFunc::LessThan(Number { int: 10, decimal: 0, }), } .eval(json!({"key": 1}))); assert!(Predicate { key: vec!["key".to_string()], func: PredicateFunc::EqualBool(true), } .eval(json!({"key": true}))); assert!(Predicate { key: vec!["key".to_string()], func: PredicateFunc::EqualBool(false), } .eval(json!({"key": false}))); } #[test] pub fn test_extract_value() { assert_eq!( extract_value(json!({"key": 1}), vec!["key".to_string()]).unwrap(), json!(1) ); assert!(extract_value(json!({"key": 1}), vec!["unknown".to_string()]).is_none()); assert_eq!( extract_value( json!({"key1": {"key2": 1}}), vec!["key1".to_string(), "key2".to_string()] ) .unwrap(), json!(1) ); } } hurl-6.1.1/src/jsonpath/jsonpath.grammar000064400000000000000000000021461046102023000164210ustar 00000000000000query = "$" selector* # # selector # only for array? # ..book[0] first book if book an array selector = name-child-selector | array-index-selector | filter-selector | recursive-key-selector name-child-selector = "[" string-value "]" array-index-selector = "[" integer "]" filter-selector = "[?(" predicate ")]" recursive-key-selector = ".." key-name # # predicate # @.price<10 # predicate = predicate-key predicate-func predicate-key = "@." key-name predicate-func = key-exist-predicate-func | equal-string-predicate-func | notequal-string-predicate-func | equal-number-predicate-func | notequal-number-predicate-func | greater-than-predicate-func | greater-or-equal-than-predicate-func equal-string-predicate-func = "=" string-value equal-number-predicate-func- = "=" number notequal-string-predicate-func = "!=" string-value notequal-number-predicate-func = "!=" number # # Primitives # key-name = string-value = "'" "'" number = hurl-6.1.1/src/jsonpath/mod.rs000064400000000000000000000053671046102023000143600ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ //! JSONPath specs. //! //! There is no proper specifications for JSONPath. //! The de-facto one is still . //! //! Hurl will try to follow this one as closely as possible //! //! There are a few edge cases for which several implementations differ. //! //! We describe below the behaviour that we expect in Hurl. //! //! Specify a field key in a subscript operator: `$['name']`. //! The key must be enclosed within single quotes only. //! The following expressions will not be valid: `$["name"]` and `$[name]`. //! //! Accessing a key containing a single quote must be escape: `$['\'']`. //! Key with unicode are supported: `$['✈']` //! //! Any character within these quote won't have a specific meaning: //! - `$['*']` selects the element with key '*'. It is different from `$[*]` which selects all elements //! - `$['.']` selects the element with key '.'. //! //! The dot notation is usually more readable the bracket notation //! but it is more limited in terms of allowed characters. //! The following characters are allowed: //! - alphanumeric //! - _ (underscore) //! //! Filters can be applied to element of an array with the `?(@.key PREDICATE)` notation. //! The key can specify one or more levels. //! For example, `.price.US` specify field 'US' in an object for the field price. //! The predicate if not present just checks the key existence. //! //! The Hurl API for evaluating a jsonpath expression does not always return a collection (as defined in the jsonpath spec). //! It returns an optional value, which is either a collection or a single value (scalar). //! Note that other implementations (such as the Java lib ) also distinguish between node value (definite path) and collection (indefinite path). //! //! Note that the only selectors returning a scalar are: //! - array index selector (`$.store.book[2]`) //! - object key selector (`$.store.bicycle.color/$.store.bicycle['color']`) //! //! This will make testing the value a bit easier. //! pub use self::eval::JsonpathResult; pub use self::parser::parse; mod ast; mod eval; mod parser; #[cfg(test)] mod tests; hurl-6.1.1/src/jsonpath/parser/error.rs000064400000000000000000000027601046102023000162200ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::reader::Pos; pub type ParseResult = Result; #[derive(Clone, Debug, PartialEq, Eq)] pub struct ParseError { pub pos: Pos, pub recoverable: bool, pub kind: ParseErrorKind, } impl ParseError { pub fn new(pos: Pos, recoverable: bool, kind: ParseErrorKind) -> Self { ParseError { pos, recoverable, kind, } } } #[derive(Clone, Debug, PartialEq, Eq)] pub enum ParseErrorKind { Expecting(String), } impl hurl_core::combinator::ParseError for ParseError { fn is_recoverable(&self) -> bool { self.recoverable } fn to_recoverable(self) -> Self { ParseError { recoverable: true, ..self } } fn to_non_recoverable(self) -> Self { ParseError { recoverable: false, ..self } } } hurl-6.1.1/src/jsonpath/parser/mod.rs000064400000000000000000000012701046102023000156410ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ pub use self::parse::parse; mod error; mod parse; mod primitives; hurl-6.1.1/src/jsonpath/parser/parse.rs000064400000000000000000000531541046102023000162040ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::combinator::{choice, zero_or_more}; use hurl_core::reader::Reader; use crate::jsonpath::ast::{Predicate, PredicateFunc, Query, Selector, Slice}; use crate::jsonpath::parser::error::{ParseError, ParseErrorKind, ParseResult}; use crate::jsonpath::parser::primitives::{ boolean, integer, key_name, key_path, literal, natural, number, string_value, try_literal, whitespace, }; pub fn parse(s: &str) -> Result { let mut reader = Reader::new(s); query(&mut reader) } fn query(reader: &mut Reader) -> ParseResult { literal("$", reader)?; let selectors = zero_or_more(selector, reader)?; if !reader.is_eof() { let kind = ParseErrorKind::Expecting("eof".to_string()); let error = ParseError::new(reader.cursor().pos, false, kind); return Err(error); } Ok(Query { selectors }) } fn selector(reader: &mut Reader) -> ParseResult { choice( &[ selector_filter, selector_wildcard, selector_recursive_wildcard, selector_recursive_key, selector_array_index_or_array_indices, selector_array_wildcard, selector_array_slice, selector_object_key_bracket, selector_object_key, ], reader, ) } fn selector_array_index_or_array_indices(reader: &mut Reader) -> Result { let initial_state = reader.cursor(); try_left_bracket(reader)?; let mut indexes = vec![]; let i = match natural(reader) { Err(e) => { let error = ParseError::new(e.pos, true, e.kind); return Err(error); } Ok(v) => v, }; indexes.push(i); loop { let start = reader.cursor(); if try_literal(",", reader).is_ok() { let i = match natural(reader) { Err(e) => { return Err(ParseError::new(e.pos, true, e.kind)); } Ok(v) => v, }; indexes.push(i); } else { reader.seek(start); break; } } // you will have a ':' for a slice // TODO: combine array index, indices and slice in the same function if let Err(e) = try_literal("]", reader) { reader.seek(initial_state); return Err(ParseError::new(reader.cursor().pos, true, e.kind)); } let selector = if indexes.len() == 1 { Selector::ArrayIndex(*indexes.first().unwrap()) } else { Selector::ArrayIndices(indexes) }; Ok(selector) } fn selector_array_wildcard(reader: &mut Reader) -> Result { try_left_bracket(reader)?; try_literal("*", reader)?; literal("]", reader)?; Ok(Selector::ArrayWildcard) } fn selector_array_slice(reader: &mut Reader) -> Result { try_left_bracket(reader)?; let save = reader.cursor(); let start = match integer(reader) { Err(_) => { reader.seek(save); None } Ok(v) => Some(v), }; if try_literal(":", reader).is_err() { let kind = ParseErrorKind::Expecting(":".to_string()); let error = ParseError::new(save.pos, true, kind); return Err(error); }; let save = reader.cursor(); let end = match integer(reader) { Err(_) => { reader.seek(save); None } Ok(v) => Some(v), }; literal("]", reader)?; Ok(Selector::ArraySlice(Slice { start, end })) } fn selector_filter(reader: &mut Reader) -> Result { try_left_bracket(reader)?; try_literal("?(", reader)?; let pred = predicate(reader)?; literal(")]", reader)?; Ok(Selector::Filter(pred)) } fn selector_object_key_bracket(reader: &mut Reader) -> Result { try_left_bracket(reader)?; match string_value(reader) { Err(_) => { let kind = ParseErrorKind::Expecting("value string".to_string()); let error = ParseError::new(reader.cursor().pos, true, kind); Err(error) } Ok(v) => { literal("]", reader)?; Ok(Selector::NameChild(v)) } } } fn selector_object_key(reader: &mut Reader) -> Result { if reader.peek() != Some('.') { let kind = ParseErrorKind::Expecting("[ or .".to_string()); let error = ParseError::new(reader.cursor().pos, true, kind); return Err(error); }; _ = reader.read(); let s = reader.read_while(|c| c.is_alphanumeric() || c == '_' || c == '-'); if s.is_empty() { let kind = ParseErrorKind::Expecting("empty value".to_string()); let error = ParseError::new(reader.cursor().pos, false, kind); return Err(error); } Ok(Selector::NameChild(s)) } fn selector_wildcard(reader: &mut Reader) -> Result { try_literal(".*", reader)?; Ok(Selector::Wildcard) } fn selector_recursive_wildcard(reader: &mut Reader) -> Result { try_literal("..*", reader)?; Ok(Selector::RecursiveWildcard) } fn selector_recursive_key(reader: &mut Reader) -> Result { try_literal("..", reader)?; let k = key_name(reader)?; Ok(Selector::RecursiveKey(k)) } fn try_left_bracket(reader: &mut Reader) -> Result<(), ParseError> { let start = reader.cursor(); if literal(".[", reader).is_err() { reader.seek(start); try_literal("[", reader)?; } Ok(()) } fn predicate(reader: &mut Reader) -> ParseResult { // predicate always on key? // TODO parsing key-value // ?(@.key=='value') // @<3 => assume number => plan it in your ast => ValueEqualInt should be used for that // KeyValueEqualInt // @.key Exist(Key) // @.key==value Equal(Key,Value) // @.key>=value GreaterThanOrEqual(Key, Value) literal("@.", reader)?; // assume key value for the time being let key = key_path(reader)?; let save = reader.cursor(); let func = match predicate_func(reader) { Ok(f) => f, Err(_) => { reader.seek(save); PredicateFunc::KeyExist } }; Ok(Predicate { key, func }) } fn predicate_func(reader: &mut Reader) -> ParseResult { choice( &[ equal_number_predicate_func, greater_than_predicate_func, greater_than_or_equal_predicate_func, less_than_predicate_func, less_than_or_equal_predicate_func, equal_boolean_predicate_func, equal_string_predicate_func, notequal_string_predicate_func, notequal_number_func, ], reader, ) } fn equal_number_predicate_func(reader: &mut Reader) -> ParseResult { try_literal("==", reader)?; whitespace(reader); let num = number(reader)?; Ok(PredicateFunc::Equal(num)) } fn equal_boolean_predicate_func(reader: &mut Reader) -> ParseResult { try_literal("==", reader)?; whitespace(reader); let boolean = boolean(reader)?; Ok(PredicateFunc::EqualBool(boolean)) } fn greater_than_predicate_func(reader: &mut Reader) -> ParseResult { try_literal(">", reader)?; whitespace(reader); let num = number(reader)?; Ok(PredicateFunc::GreaterThan(num)) } fn greater_than_or_equal_predicate_func(reader: &mut Reader) -> ParseResult { try_literal(">=", reader)?; whitespace(reader); let num = number(reader)?; Ok(PredicateFunc::GreaterThanOrEqual(num)) } fn less_than_predicate_func(reader: &mut Reader) -> ParseResult { try_literal("<", reader)?; whitespace(reader); let num = number(reader)?; Ok(PredicateFunc::LessThan(num)) } fn less_than_or_equal_predicate_func(reader: &mut Reader) -> ParseResult { try_literal("<=", reader)?; whitespace(reader); let num = number(reader)?; Ok(PredicateFunc::LessThanOrEqual(num)) } fn equal_string_predicate_func(reader: &mut Reader) -> ParseResult { try_literal("==", reader)?; whitespace(reader); let s = string_value(reader)?; Ok(PredicateFunc::EqualString(s)) } fn notequal_string_predicate_func(reader: &mut Reader) -> ParseResult { try_literal("!=", reader)?; whitespace(reader); let s = string_value(reader)?; Ok(PredicateFunc::NotEqualString(s)) } fn notequal_number_func(reader: &mut Reader) -> ParseResult { try_literal("!=", reader)?; whitespace(reader); let num = number(reader)?; Ok(PredicateFunc::NotEqual(num)) } #[cfg(test)] mod tests { use hurl_core::reader::Pos; // tests from https://cburgmer.github.io/json-path-comparison use super::*; use crate::jsonpath::ast::Number; #[test] pub fn test_try_left_bracket() { let mut reader = Reader::new("xxx"); let error = try_left_bracket(&mut reader).err().unwrap(); assert!(error.recoverable); let mut reader = Reader::new("[xxx"); assert!(try_left_bracket(&mut reader).is_ok()); assert_eq!(reader.cursor().index, 1); let mut reader = Reader::new(".[xxx"); assert!(try_left_bracket(&mut reader).is_ok()); assert_eq!(reader.cursor().index, 2); } #[test] pub fn test_query() { let expected_query = Query { selectors: vec![Selector::ArrayIndex(2)], }; assert_eq!(query(&mut Reader::new("$[2]")).unwrap(), expected_query); let expected_query = Query { selectors: vec![Selector::NameChild("key".to_string())], }; assert_eq!(query(&mut Reader::new("$['key']")).unwrap(), expected_query); assert_eq!(query(&mut Reader::new("$.key")).unwrap(), expected_query); let expected_query = Query { selectors: vec![Selector::NameChild("profile-id".to_string())], }; assert_eq!( query(&mut Reader::new("$['profile-id']")).unwrap(), expected_query ); assert_eq!( query(&mut Reader::new("$.profile-id")).unwrap(), expected_query ); let expected_query = Query { selectors: vec![ Selector::NameChild("store".to_string()), Selector::NameChild("book".to_string()), Selector::ArrayIndex(0), Selector::NameChild("title".to_string()), ], }; assert_eq!( query(&mut Reader::new("$.store.book[0].title")).unwrap(), expected_query ); assert_eq!( query(&mut Reader::new("$['store']['book'][0]['title']")).unwrap(), expected_query ); let expected_query = Query { selectors: vec![ Selector::RecursiveKey("book".to_string()), Selector::ArrayIndex(2), ], }; assert_eq!( query(&mut Reader::new("$..book[2]")).unwrap(), expected_query ); } #[test] pub fn test_query_error() { let error = query(&mut Reader::new("?$.store")).err().unwrap(); assert_eq!(error.pos, Pos { line: 1, column: 1 }); let error = query(&mut Reader::new("$.store?")).err().unwrap(); assert_eq!(error.pos, Pos { line: 1, column: 8 }); } #[test] pub fn test_selector_filter() { // Filter exist value let mut reader = Reader::new("[?(@.isbn)]"); assert_eq!( selector(&mut reader).unwrap(), Selector::Filter(Predicate { key: vec!["isbn".to_string()], func: PredicateFunc::KeyExist, }) ); assert_eq!(reader.cursor().index, 11); // Filter equal on string with single quotes let mut reader = Reader::new("[?(@.key=='value')]"); assert_eq!( selector(&mut reader).unwrap(), Selector::Filter(Predicate { key: vec!["key".to_string()], func: PredicateFunc::EqualString("value".to_string()), }) ); assert_eq!(reader.cursor().index, 19); let mut reader = Reader::new(".[?(@.key=='value')]"); assert_eq!( selector(&mut reader).unwrap(), Selector::Filter(Predicate { key: vec!["key".to_string()], func: PredicateFunc::EqualString("value".to_string()), }) ); assert_eq!(reader.cursor().index, 20); let mut reader = Reader::new("[?(@.price<10)]"); assert_eq!( selector(&mut reader).unwrap(), Selector::Filter(Predicate { key: vec!["price".to_string()], func: PredicateFunc::LessThan(Number { int: 10, decimal: 0 }), }) ); assert_eq!(reader.cursor().index, 15); } #[test] pub fn test_selector_recursive() { let mut reader = Reader::new("..book"); assert_eq!( selector(&mut reader).unwrap(), Selector::RecursiveKey("book".to_string()) ); assert_eq!(reader.cursor().index, 6); } #[test] pub fn test_selector_array_index() { let mut reader = Reader::new("[2]"); assert_eq!(selector(&mut reader).unwrap(), Selector::ArrayIndex(2)); assert_eq!(reader.cursor().index, 3); let mut reader = Reader::new("[0,1]"); assert_eq!( selector(&mut reader).unwrap(), Selector::ArrayIndices(vec![0, 1]) ); assert_eq!(reader.cursor().index, 5); // you don't need to keep the exact string // this is not part of the AST let mut reader = Reader::new(".[2]"); assert_eq!(selector(&mut reader).unwrap(), Selector::ArrayIndex(2)); assert_eq!(reader.cursor().index, 4); } #[test] pub fn test_selector_wildcard() { let mut reader = Reader::new("[*]"); assert_eq!(selector(&mut reader).unwrap(), Selector::ArrayWildcard); assert_eq!(reader.cursor().index, 3); // you don't need to keep the exact string // this is not part of the AST let mut reader = Reader::new(".[*]"); assert_eq!(selector(&mut reader).unwrap(), Selector::ArrayWildcard); assert_eq!(reader.cursor().index, 4); } #[test] pub fn test_selector_array_slice() { let mut reader = Reader::new("[1:]"); assert_eq!( selector(&mut reader).unwrap(), Selector::ArraySlice(Slice { start: Some(1), end: None }) ); assert_eq!(reader.cursor().index, 4); let mut reader = Reader::new("[-1:]"); assert_eq!( selector(&mut reader).unwrap(), Selector::ArraySlice(Slice { start: Some(-1), end: None }) ); assert_eq!(reader.cursor().index, 5); let mut reader = Reader::new("[:2]"); assert_eq!( selector(&mut reader).unwrap(), Selector::ArraySlice(Slice { start: None, end: Some(2) }) ); assert_eq!(reader.cursor().index, 4); } #[test] pub fn test_key_bracket_selector() { let mut reader = Reader::new("['key']"); assert_eq!( selector(&mut reader).unwrap(), Selector::NameChild("key".to_string()) ); assert_eq!(reader.cursor().index, 7); let mut reader = Reader::new(".['key']"); assert_eq!( selector(&mut reader).unwrap(), Selector::NameChild("key".to_string()) ); assert_eq!(reader.cursor().index, 8); let mut reader = Reader::new("['key1']"); assert_eq!( selector(&mut reader).unwrap(), Selector::NameChild("key1".to_string()) ); assert_eq!(reader.cursor().index, 8); } #[test] pub fn test_selector_key_dot_notation() { let mut reader = Reader::new(".key"); assert_eq!( selector(&mut reader).unwrap(), Selector::NameChild("key".to_string()) ); assert_eq!(reader.cursor().index, 4); let mut reader = Reader::new(".key1"); assert_eq!( selector(&mut reader).unwrap(), Selector::NameChild("key1".to_string()) ); assert_eq!(reader.cursor().index, 5); } #[test] pub fn test_predicate() { // Key exists assert_eq!( predicate(&mut Reader::new("@.isbn")).unwrap(), Predicate { key: vec!["isbn".to_string()], func: PredicateFunc::KeyExist, } ); // Filter equal on string with single quotes assert_eq!( predicate(&mut Reader::new("@.key=='value'")).unwrap(), Predicate { key: vec!["key".to_string()], func: PredicateFunc::EqualString("value".to_string()), } ); // Filter equal on int assert_eq!( predicate(&mut Reader::new("@.key==1")).unwrap(), Predicate { key: vec!["key".to_string()], func: PredicateFunc::Equal(Number { int: 1, decimal: 0 }), } ); // Filter equal on int for key in object assert_eq!( predicate(&mut Reader::new("@.obj.key==1")).unwrap(), Predicate { key: vec!["obj".to_string(), "key".to_string()], func: PredicateFunc::Equal(Number { int: 1, decimal: 0 }), } ); // Filter less than int assert_eq!( predicate(&mut Reader::new("@.price<10")).unwrap(), Predicate { key: vec!["price".to_string()], func: PredicateFunc::LessThan(Number { int: 10, decimal: 0 }), } ); } #[test] pub fn test_predicate_func() { let mut reader = Reader::new("==true"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::EqualBool(true) ); assert_eq!(reader.cursor().index, 6); let mut reader = Reader::new("==false"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::EqualBool(false) ); assert_eq!(reader.cursor().index, 7); let mut reader = Reader::new("==2"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::Equal(Number { int: 2, decimal: 0 }) ); assert_eq!(reader.cursor().index, 3); let mut reader = Reader::new("==2.1"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::Equal(Number { int: 2, decimal: 100_000_000_000_000_000 }) ); assert_eq!(reader.cursor().index, 5); let mut reader = Reader::new("== 2.1 "); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::Equal(Number { int: 2, decimal: 100_000_000_000_000_000 }) ); assert_eq!(reader.cursor().index, 7); let mut reader = Reader::new("=='hello'"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::EqualString("hello".to_string()) ); assert_eq!(reader.cursor().index, 9); let mut reader = Reader::new("!='hello'"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::NotEqualString("hello".to_string()) ); let mut reader = Reader::new("!=2"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::NotEqual(Number { int: 2, decimal: 0 }) ); let mut reader = Reader::new("!=2.5"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::NotEqual(Number { int: 2, decimal: 500_000_000_000_000_000 }) ); let mut reader = Reader::new(">5"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::GreaterThan(Number { int: 5, decimal: 0 }) ); assert_eq!(reader.cursor().index, 2); let mut reader = Reader::new(">=5"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::GreaterThanOrEqual(Number { int: 5, decimal: 0 }) ); assert_eq!(reader.cursor().index, 3); let mut reader = Reader::new("<5"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::LessThan(Number { int: 5, decimal: 0 }) ); assert_eq!(reader.cursor().index, 2); let mut reader = Reader::new("<=5"); assert_eq!( predicate_func(&mut reader).unwrap(), PredicateFunc::LessThanOrEqual(Number { int: 5, decimal: 0 }) ); assert_eq!(reader.cursor().index, 3); } } hurl-6.1.1/src/jsonpath/parser/primitives.rs000064400000000000000000000360001046102023000172540ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::reader::Reader; use crate::jsonpath::ast::Number; use crate::jsonpath::parser::error::{ParseError, ParseErrorKind, ParseResult}; pub fn natural(reader: &mut Reader) -> ParseResult { let start = reader.cursor(); if reader.is_eof() { let kind = ParseErrorKind::Expecting("natural".to_string()); let error = ParseError::new(start.pos, true, kind); return Err(error); } let first_digit = reader.read().unwrap(); if !first_digit.is_ascii_digit() { let kind = ParseErrorKind::Expecting("natural".to_string()); let error = ParseError::new(start.pos, true, kind); return Err(error); } let save = reader.cursor(); let s = reader.read_while(|c| c.is_ascii_digit()); // if the first digit is zero, you should not have any more digits if first_digit == '0' && !s.is_empty() { let kind = ParseErrorKind::Expecting("natural".to_string()); let error = ParseError::new(save.pos, false, kind); return Err(error); } Ok(format!("{first_digit}{s}").parse().unwrap()) } pub fn integer(reader: &mut Reader) -> ParseResult { let sign = if reader.peek() == Some('-') { _ = reader.read(); -1 } else { 1 }; let nat = natural(reader)?; Ok(sign * (nat as i64)) } pub fn number(reader: &mut Reader) -> ParseResult { let int = integer(reader)?; let decimal = if reader.peek() == Some('.') { _ = reader.read(); if reader.is_eof() { let kind = ParseErrorKind::Expecting("natural".to_string()); let error = ParseError::new(reader.cursor().pos, false, kind); return Err(error); } let s = reader.read_while(|c| c.is_ascii_digit()); if s.is_empty() { let kind = ParseErrorKind::Expecting("natural".to_string()); let error = ParseError::new(reader.cursor().pos, false, kind); return Err(error); } format!("{s:0<18}").parse().unwrap() } else { 0 }; whitespace(reader); Ok(Number { int, decimal }) } pub fn boolean(reader: &mut Reader) -> ParseResult { let token = reader.read_while(|c| c.is_alphabetic()); // Match the token against the strings "true" and "false" let result = match token.as_str() { "true" => Ok(true), "false" => Ok(false), _ => { let kind = ParseErrorKind::Expecting("bool".to_string()); let error = ParseError::new(reader.cursor().pos, true, kind); Err(error) } }; whitespace(reader); result } pub fn string_value(reader: &mut Reader) -> Result { try_literal("'", reader)?; let mut s = String::new(); loop { match reader.read() { None => { let kind = ParseErrorKind::Expecting("'".to_string()); let error = ParseError::new(reader.cursor().pos, false, kind); return Err(error); } Some('\'') => break, Some('\\') => { // only single quote can be escaped match reader.read() { Some('\'') => { s.push('\''); } _ => { let kind = ParseErrorKind::Expecting("'".to_string()); let error = ParseError::new(reader.cursor().pos, false, kind); return Err(error); } } } Some(c) => { s.push(c); } } } whitespace(reader); Ok(s) } pub fn key_name(reader: &mut Reader) -> Result { // test python or javascript // subset that can used for dot notation // The key must not be empty and must not start with a digit let first_char = match reader.read() { Some(c) => { if c.is_alphabetic() || c == '_' { c } else { let kind = ParseErrorKind::Expecting("key".to_string()); let error = ParseError::new(reader.cursor().pos, false, kind); return Err(error); } } None => { let kind = ParseErrorKind::Expecting("key".to_string()); let error = ParseError::new(reader.cursor().pos, false, kind); return Err(error); } }; let s = reader.read_while(|c| c.is_alphanumeric() || c == '_'); whitespace(reader); Ok(format!("{first_char}{s}")) } // key1.key2.key3 pub fn key_path(reader: &mut Reader) -> Result, ParseError> { let root = key_name(reader)?; let mut path = vec![root]; while let Some('.') = reader.peek() { reader.read(); let key = key_name(reader)?; path.push(key); } Ok(path) } pub fn literal(s: &str, reader: &mut Reader) -> ParseResult<()> { // does not return a value // non recoverable reader // => use combinator recover to make it recoverable let start = reader.cursor(); if reader.is_eof() { let kind = ParseErrorKind::Expecting(s.to_string()); let error = ParseError::new(start.pos, false, kind); return Err(error); } for c in s.chars() { match reader.read() { None => { let kind = ParseErrorKind::Expecting(s.to_string()); let error = ParseError::new(start.pos, false, kind); return Err(error); } Some(x) => { if x != c { let kind = ParseErrorKind::Expecting(s.to_string()); let error = ParseError::new(start.pos, false, kind); return Err(error); } else { continue; } } } } whitespace(reader); Ok(()) } pub fn try_literal(s: &str, p: &mut Reader) -> ParseResult<()> { match literal(s, p) { Ok(_) => Ok(()), Err(ParseError { pos, kind, .. }) => Err(ParseError { pos, recoverable: true, kind, }), } } pub fn whitespace(reader: &mut Reader) { while reader.peek() == Some(' ') { reader.read(); } } #[cfg(test)] mod tests { use hurl_core::reader::Pos; use super::*; #[test] fn test_natural() { let mut reader = Reader::new("0"); assert_eq!(natural(&mut reader).unwrap(), 0); assert_eq!(reader.cursor().index, 1); let mut reader = Reader::new("0."); assert_eq!(natural(&mut reader).unwrap(), 0); assert_eq!(reader.cursor().index, 1); let mut reader = Reader::new("10x"); assert_eq!(natural(&mut reader).unwrap(), 10); assert_eq!(reader.cursor().index, 2); } #[test] fn test_natural_error() { let mut reader = Reader::new(""); let error = natural(&mut reader).err().unwrap(); assert_eq!(error.pos, Pos { line: 1, column: 1 }); assert_eq!(error.kind, ParseErrorKind::Expecting("natural".to_string())); assert!(error.recoverable); let mut reader = Reader::new("01"); let error = natural(&mut reader).err().unwrap(); assert_eq!(error.pos, Pos { line: 1, column: 2 }); assert_eq!(error.kind, ParseErrorKind::Expecting("natural".to_string())); assert!(!error.recoverable); let mut reader = Reader::new("x"); let error = natural(&mut reader).err().unwrap(); assert_eq!(error.pos, Pos { line: 1, column: 1 }); assert_eq!(error.kind, ParseErrorKind::Expecting("natural".to_string())); assert!(error.recoverable); } #[test] pub fn test_integer() { let mut reader = Reader::new("1"); assert_eq!(integer(&mut reader).unwrap(), 1); let mut reader = Reader::new("1.1"); assert_eq!(integer(&mut reader).unwrap(), 1); let mut reader = Reader::new("-1.1"); assert_eq!(integer(&mut reader).unwrap(), -1); let mut reader = Reader::new("x"); let error = integer(&mut reader).err().unwrap(); assert_eq!(error.pos, Pos { line: 1, column: 1 }); assert_eq!(error.kind, ParseErrorKind::Expecting("natural".to_string())); assert!(error.recoverable); } #[test] fn test_number() { let mut reader = Reader::new("1"); assert_eq!(number(&mut reader).unwrap(), Number { int: 1, decimal: 0 }); assert_eq!(reader.cursor().index, 1); let mut reader = Reader::new("1.0"); assert_eq!(number(&mut reader).unwrap(), Number { int: 1, decimal: 0 }); assert_eq!(reader.cursor().index, 3); let mut reader = Reader::new("-1.0"); assert_eq!( number(&mut reader).unwrap(), Number { int: -1, decimal: 0 } ); assert_eq!(reader.cursor().index, 4); let mut reader = Reader::new("1.1"); assert_eq!( number(&mut reader).unwrap(), Number { int: 1, decimal: 100_000_000_000_000_000 } ); assert_eq!(reader.cursor().index, 3); let mut reader = Reader::new("1.100"); assert_eq!( number(&mut reader).unwrap(), Number { int: 1, decimal: 100_000_000_000_000_000 } ); assert_eq!(reader.cursor().index, 5); let mut reader = Reader::new("1.01"); assert_eq!( number(&mut reader).unwrap(), Number { int: 1, decimal: 10_000_000_000_000_000 } ); assert_eq!(reader.cursor().index, 4); let mut reader = Reader::new("1.010"); assert_eq!( number(&mut reader).unwrap(), Number { int: 1, decimal: 10_000_000_000_000_000 } ); assert_eq!(reader.cursor().index, 5); let mut reader = Reader::new("-0.333333333333333333"); assert_eq!( number(&mut reader).unwrap(), Number { int: 0, decimal: 333_333_333_333_333_333 } ); assert_eq!(reader.cursor().index, 21); } #[test] fn test_number_error() { let mut reader = Reader::new(""); let error = number(&mut reader).err().unwrap(); assert_eq!(error.kind, ParseErrorKind::Expecting("natural".to_string())); assert_eq!(error.pos, Pos { line: 1, column: 1 }); assert!(error.recoverable); let mut reader = Reader::new("-"); let error = number(&mut reader).err().unwrap(); assert_eq!(error.kind, ParseErrorKind::Expecting("natural".to_string())); assert_eq!(error.pos, Pos { line: 1, column: 2 }); assert!(error.recoverable); let mut reader = Reader::new("1."); let error = number(&mut reader).err().unwrap(); assert_eq!(error.kind, ParseErrorKind::Expecting("natural".to_string())); assert_eq!(error.pos, Pos { line: 1, column: 3 }); assert!(!error.recoverable); let mut reader = Reader::new("1.x"); let error = number(&mut reader).err().unwrap(); assert_eq!(error.kind, ParseErrorKind::Expecting("natural".to_string())); assert_eq!(error.pos, Pos { line: 1, column: 3 }); assert!(!error.recoverable); } #[test] fn test_string_value() { let mut reader = Reader::new("'hello'"); assert_eq!(string_value(&mut reader).unwrap(), "hello".to_string()); let mut reader = Reader::new("'\\''"); assert_eq!(string_value(&mut reader).unwrap(), "'".to_string()); let mut reader = Reader::new("1"); let error = string_value(&mut reader).err().unwrap(); assert_eq!(error.kind, ParseErrorKind::Expecting("'".to_string())); assert_eq!(error.pos, Pos { line: 1, column: 1 }); assert!(error.recoverable); let mut reader = Reader::new("'hi"); let error = string_value(&mut reader).err().unwrap(); assert_eq!(error.kind, ParseErrorKind::Expecting("'".to_string())); assert_eq!(error.pos, Pos { line: 1, column: 4 }); assert!(!error.recoverable); } #[test] fn test_key_name() { let mut reader = Reader::new("id'"); assert_eq!(key_name(&mut reader).unwrap(), "id".to_string()); let mut reader = Reader::new("id123"); assert_eq!(key_name(&mut reader).unwrap(), "id123".to_string()); let mut reader = Reader::new("."); let error = key_name(&mut reader).err().unwrap(); assert!(!error.recoverable); assert_eq!(error.kind, ParseErrorKind::Expecting("key".to_string())); let mut reader = Reader::new("1id"); let error = key_name(&mut reader).err().unwrap(); assert!(!error.recoverable); assert_eq!(error.kind, ParseErrorKind::Expecting("key".to_string())); } #[test] fn test_key_path() { let mut reader = Reader::new("id"); assert_eq!(key_path(&mut reader).unwrap(), vec!["id".to_string()]); let mut reader = Reader::new("key1.key2"); assert_eq!( key_path(&mut reader).unwrap(), vec!["key1".to_string(), "key2".to_string()] ); } #[test] fn test_literal() { let mut reader = Reader::new("hello"); assert_eq!(literal("hello", &mut reader), Ok(())); assert_eq!(reader.cursor().index, 5); let mut reader = Reader::new("hello "); assert_eq!(literal("hello", &mut reader), Ok(())); assert_eq!(reader.cursor().index, 6); let mut reader = Reader::new(""); let error = literal("hello", &mut reader).err().unwrap(); assert_eq!(error.pos, Pos { line: 1, column: 1 }); assert_eq!(error.kind, ParseErrorKind::Expecting("hello".to_string())); assert_eq!(reader.cursor().index, 0); let mut reader = Reader::new("hi"); let error = literal("hello", &mut reader).err().unwrap(); assert_eq!(error.pos, Pos { line: 1, column: 1 }); assert_eq!(error.kind, ParseErrorKind::Expecting("hello".to_string())); assert_eq!(reader.cursor().index, 2); let mut reader = Reader::new("he"); let error = literal("hello", &mut reader).err().unwrap(); assert_eq!(error.pos, Pos { line: 1, column: 1 }); assert_eq!(error.kind, ParseErrorKind::Expecting("hello".to_string())); assert_eq!(reader.cursor().index, 2); } } hurl-6.1.1/src/jsonpath/tests/mod.rs000064400000000000000000000320461046102023000155140ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ //! Integration tests for jsonpath module. //! These tests are not located at the root of the project, like Rust integration tests //! are usually located since we do not want to expose the jsonpath module to our public API. use std::fs::read_to_string; use serde_json::json; use crate::jsonpath; use crate::jsonpath::JsonpathResult; fn bookstore_value() -> serde_json::Value { let s = read_to_string("tests/bookstore.json").expect("could not read string from file"); serde_json::from_str(s.as_str()).expect("could not parse json file") } fn store_value() -> serde_json::Value { serde_json::from_str( r#" { "book": [ { "category": "reference", "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95 }, { "category": "fiction", "author": "Evelyn Waugh", "title": "Sword of Honour", "price": 12.99 }, { "category": "fiction", "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 }, { "category": "fiction", "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 } ], "bicycle": { "color": "red", "price": 19.95 } } "#, ) .unwrap() } fn book_value() -> serde_json::Value { serde_json::from_str( r#" [ { "category": "reference", "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95 }, { "category": "fiction", "author": "Evelyn Waugh", "title": "Sword of Honour", "price": 12.99 }, { "category": "fiction", "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 }, { "category": "fiction", "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 } ] "#, ) .unwrap() } fn bicycle_value() -> serde_json::Value { serde_json::from_str( r#" { "color": "red", "price": 19.95 } "#, ) .unwrap() } fn book0_value() -> serde_json::Value { json!( { "category": "reference", "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95 }) } fn book1_value() -> serde_json::Value { json!( { "category": "fiction", "author": "Evelyn Waugh", "title": "Sword of Honour", "price": 12.99 }) } fn book2_value() -> serde_json::Value { json!( { "category": "fiction", "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 }) } fn book3_value() -> serde_json::Value { json!({ "category": "fiction", "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 }) } #[test] fn test_bookstore_path() { // examples from https://goessner.net/articles/JsonPath/ // the authors of all books in the store let expr = jsonpath::parse("$.store.book[*].author").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![ json!("Nigel Rees"), json!("Evelyn Waugh"), json!("Herman Melville"), json!("J. R. R. Tolkien") ]) ); // all authors let expr = jsonpath::parse("$..author").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![ json!("Nigel Rees"), json!("Evelyn Waugh"), json!("Herman Melville"), json!("J. R. R. Tolkien") ]) ); // all things in store, which are some books and a red bicycle. let expr = jsonpath::parse("$.store.*").unwrap(); // Attention, there is no ordering on object keys with serde_json // But you expect that order stays the same // that's why bicycle and boot are inverted assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![bicycle_value(), book_value()]) ); // the price of everything in the store. let expr = jsonpath::parse("$.store..price").unwrap(); // Attention, there is no ordering on object keys with serde_json // But you expect that order stays the same assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![ json!(19.95), json!(8.95), json!(12.99), json!(8.99), json!(22.99), ]) ); // the third book let expr = jsonpath::parse("$..book[2]").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![book2_value()]) ); // the last book in order // The following expression is not supported // (@.length-1) // use python-like indexing instead let expr = jsonpath::parse("$..book[-1:]").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![book3_value()]) ); // the first two books let expr = jsonpath::parse("$..book[0,1]").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![book0_value(), book1_value()]) ); let expr = jsonpath::parse("$..book[:2]").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![book0_value(), book1_value()]) ); // filter all books with isbn number let expr = jsonpath::parse("$..book[?(@.isbn)]").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![book2_value(), book3_value()]) ); // filter all books cheaper than 10 let expr = jsonpath::parse("$..book[?(@.price<10)]").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![book0_value(), book2_value()]) ); // get all books whose title is not "hamlet". let expr = jsonpath::parse("$..book[?(@.title!='Moby Dick')]").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![book0_value(), book1_value(), book3_value()]) ); // get all books whose price is not 8.95 (first book) let expr = jsonpath::parse("$..book[?(@.price!=8.95)]").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![book1_value(), book2_value(), book3_value()]) ); // All members of JSON structure let expr = jsonpath::parse("$..*").unwrap(); // Order is reproducible // but does not keep same order of json input! assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![ store_value(), bicycle_value(), json!("red"), json!(19.95), book_value(), book0_value(), json!("Nigel Rees"), json!("reference"), json!(8.95), json!("Sayings of the Century"), book1_value(), json!("Evelyn Waugh"), json!("fiction"), json!(12.99), json!("Sword of Honour"), book2_value(), json!("Herman Melville"), json!("fiction"), json!("0-553-21311-3"), json!(8.99), json!("Moby Dick"), book3_value(), json!("J. R. R. Tolkien"), json!("fiction"), json!("0-395-19395-8"), json!(22.99), json!("The Lord of the Rings"), ]) ); } #[test] fn test_bookstore_additional() { // Find books more expensive than 100 let expr = jsonpath::parse("$.store.book[?(@.price>100)]").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![]) ); // find all authors for reference book let expr = jsonpath::parse("$..book[?(@.category=='reference')].author").unwrap(); assert_eq!( expr.eval(&bookstore_value()).unwrap(), JsonpathResult::Collection(vec![json!("Nigel Rees")]) ); } #[test] fn test_array() { let array = json!([0, 1, 2, 3]); let expr = jsonpath::parse("$[2]").unwrap(); assert_eq!( expr.eval(&array).unwrap(), JsonpathResult::SingleEntry(json!(2)) ); let expr = jsonpath::parse("$[0].name").unwrap(); let array = json!([{"name": "Bob"},{"name": "Bill"}]); assert_eq!( expr.eval(&array).unwrap(), JsonpathResult::SingleEntry(json!("Bob")) ); } #[test] fn test_key_access() { let obj = json!({ "_": "underscore", "-": "hyphen", "*": "asterisk", "'": "single_quote", "\"": "double_quote", "✈": "plane" }); // Bracket notation let expr = jsonpath::parse("$['-']").unwrap(); assert_eq!( expr.eval(&obj).unwrap(), JsonpathResult::SingleEntry(json!("hyphen")) ); let expr = jsonpath::parse("$['_']").unwrap(); assert_eq!( expr.eval(&obj).unwrap(), JsonpathResult::SingleEntry(json!("underscore")) ); let expr = jsonpath::parse("$['*']").unwrap(); assert_eq!( expr.eval(&obj).unwrap(), JsonpathResult::SingleEntry(json!("asterisk")) ); let expr = jsonpath::parse("$['\\'']").unwrap(); assert_eq!( expr.eval(&obj).unwrap(), JsonpathResult::SingleEntry(json!("single_quote")) ); let expr = jsonpath::parse("$['\"']").unwrap(); assert_eq!( expr.eval(&obj).unwrap(), JsonpathResult::SingleEntry(json!("double_quote")) ); let expr = jsonpath::parse("$['✈']").unwrap(); assert_eq!( expr.eval(&obj).unwrap(), JsonpathResult::SingleEntry(json!("plane")) ); // Dot notation let expr = jsonpath::parse("$._").unwrap(); assert_eq!( expr.eval(&obj).unwrap(), JsonpathResult::SingleEntry(json!("underscore")) ); // Asterisk // return all elements // There is no ordering in JSON keys // You must compare with their string values sorted let values = vec![ "asterisk", "double_quote", "hyphen", "plane", "single_quote", "underscore", ]; let expr = jsonpath::parse("$.*").unwrap(); let results = expr.eval(&obj).unwrap(); if let JsonpathResult::Collection(results) = results { let mut results = results .iter() .map(|e| e.as_str().unwrap()) .collect::>(); results.sort_unstable(); assert_eq!(results, values); } let expr = jsonpath::parse("$[*]").unwrap(); let results = expr.eval(&obj).unwrap(); if let JsonpathResult::Collection(results) = results { let mut results = results .iter() .map(|e| e.as_str().unwrap()) .collect::>(); results.sort_unstable(); assert_eq!(results, values); } } fn fruit_prices_value() -> serde_json::Value { serde_json::from_str( r#" { "fruit": [ { "name": "apple", "price": { "US": 100, "UN": 110 } }, { "name": "grape", "price": { "US": 200, "UN": 150 } } ] } "#, ) .unwrap() } #[test] fn test_filter_nested_object() { let expr = jsonpath::parse("$.fruit[?(@.price.US==200)].name").unwrap(); assert_eq!( expr.eval(&fruit_prices_value()).unwrap(), JsonpathResult::Collection(vec![json!("grape")]) ); let expr = jsonpath::parse("$.fruit[?(@.pricex.US==200)].name").unwrap(); assert_eq!( expr.eval(&fruit_prices_value()).unwrap(), JsonpathResult::Collection(vec![]) ); } #[test] fn test_parsing_error() { // not supported yet assert!(jsonpath::parse("$..book[(@.length-1)]").is_err()); } #[test] fn test_filter_collection_with_nonexisting_field() { let expr = jsonpath::parse("$.book[*].isbn").unwrap(); assert_eq!( expr.eval(&store_value()).unwrap(), JsonpathResult::Collection(vec![json!("0-553-21311-3"), json!("0-395-19395-8"),]) ); } hurl-6.1.1/src/lib.rs000064400000000000000000000025711046102023000125130ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ //! This crate provides a function to run a Hurl formatted content. //! Hurl uses a plain text format to run and tests HTTP requests. The fully documented //! format is available at //! //! A Hurl sample: //! ```hurl //! # Get home: //! GET https://example.org //! HTTP 200 //! [Captures] //! csrf_token: xpath "string(//meta[@name='_csrf_token']/@content)" //! //! //! # Do login! //! POST https://example.org/login?user=toto&password=1234 //! X-CSRF-TOKEN: {{csrf_token}} //! HTTP 302 //! ``` //! //! The main function of this crate is [`runner::run`]. //! //! This crate works on Windows, macOS and Linux. mod html; pub mod http; mod json; mod jsonpath; pub mod output; #[doc(hidden)] pub mod parallel; pub mod report; pub mod runner; pub mod util; hurl-6.1.1/src/main.rs000064400000000000000000000253761046102023000127010ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ mod cli; mod run; use std::collections::HashSet; use std::io::prelude::*; use std::path::Path; use std::time::Instant; use std::{env, process, thread}; use hurl::report::{curl, html, json, junit, tap}; use hurl::runner; use hurl::runner::HurlResult; use hurl::util::redacted::Redact; use hurl_core::input::Input; use hurl_core::text; use crate::cli::options::{CliOptions, CliOptionsError}; use crate::cli::{BaseLogger, CliError}; const EXIT_OK: i32 = 0; const EXIT_ERROR_COMMANDLINE: i32 = 1; const EXIT_ERROR_PARSING: i32 = 2; const EXIT_ERROR_RUNTIME: i32 = 3; const EXIT_ERROR_ASSERT: i32 = 4; const EXIT_ERROR_UNDEFINED: i32 = 127; /// Structure that stores the result of an Hurl file execution, and the content of the file. #[derive(Clone, Debug, PartialEq, Eq)] struct HurlRun { /// Source string for this [`HurlFile`] content: String, /// Content's source file filename: Input, hurl_result: HurlResult, } /// Executes Hurl entry point. fn main() { text::init_crate_colored(); let opts = match cli::options::parse() { Ok(v) => v, Err(e) => match e { CliOptionsError::Info(_) => { print!("{e}"); process::exit(EXIT_OK); } _ => { eprintln!("{e}"); process::exit(EXIT_ERROR_COMMANDLINE); } }, }; // We create a basic logger that can just display info, warning or error generic messages. // We'll use a more advanced logger for rich error report when running Hurl files. let verbose = opts.verbose || opts.very_verbose || opts.interactive; let base_logger = BaseLogger::new(opts.color, verbose); let current_dir = env::current_dir(); let current_dir = unwrap_or_exit(current_dir, EXIT_ERROR_UNDEFINED, &base_logger); let current_dir = current_dir.as_path(); let start = Instant::now(); let runs = if opts.parallel { let available = unwrap_or_exit( thread::available_parallelism(), EXIT_ERROR_UNDEFINED, &base_logger, ); let workers_count = opts.jobs.unwrap_or(available.get()); base_logger.debug(&format!("Parallel run using {workers_count} workers")); run::run_par(&opts.input_files, current_dir, &opts, workers_count) } else { run::run_seq(&opts.input_files, current_dir, &opts) }; let runs = match runs { Ok(r) => r, Err(CliError::IO(msg)) => exit_with_error(&msg, EXIT_ERROR_PARSING, &base_logger), // In case of parsing error, there is no error because the display of parsing error has been // done in the execution of the Hurl files, inside the crates (and not in the main). Err(CliError::Parsing) => exit_with_error("", EXIT_ERROR_PARSING, &base_logger), Err(CliError::Runtime(msg)) => exit_with_error(&msg, EXIT_ERROR_RUNTIME, &base_logger), }; // Compute duration of the test here to not take reports writings into account. let duration = start.elapsed(); // Write HTML, JUnit, TAP reports on disk. if has_report(&opts) { let ret = export_results(&runs, &opts, &base_logger); unwrap_or_exit(ret, EXIT_ERROR_UNDEFINED, &base_logger); } if opts.test { let summary = cli::summary(&runs, duration); base_logger.info(summary.as_str()); } process::exit(exit_code(&runs)); } /// Unwraps a `result` or exit with message. fn unwrap_or_exit(result: Result, code: i32, logger: &BaseLogger) -> T where E: std::fmt::Display, { match result { Ok(v) => v, Err(e) => exit_with_error(&e.to_string(), code, logger), } } /// Prints an error message and exits the current process with an exit code. fn exit_with_error(message: &str, code: i32, logger: &BaseLogger) -> ! { if !message.is_empty() { logger.error(message); } process::exit(code); } /// Returns `true` if any kind of report should be created, `false` otherwise. fn has_report(opts: &CliOptions) -> bool { opts.curl_file.is_some() || opts.junit_file.is_some() || opts.tap_file.is_some() || opts.html_dir.is_some() || opts.json_report_dir.is_some() || opts.cookie_output_file.is_some() } /// Writes `runs` results on file, in HTML, TAP, JUnit or Cookie file format. fn export_results( runs: &[HurlRun], opts: &CliOptions, logger: &BaseLogger, ) -> Result<(), CliError> { // Compute secrets from the result. As secrets can be redacted during execution, we can't // consider only secrets introduced from cli, we have to get secrets produced during execution. // We remove identical secrets as there may be a lot of identical secrets (those that come // from the command line for instance) let secrets = runs .iter() .flat_map(|r| r.hurl_result.variables.secrets()) .collect::>(); let secrets = secrets.iter().map(|s| s.as_ref()).collect::>(); if let Some(file) = &opts.curl_file { create_curl_export(runs, file, &secrets)?; } if let Some(file) = &opts.junit_file { logger.debug(&format!("Writing JUnit report to {}", file.display())); create_junit_report(runs, file, &secrets)?; } if let Some(file) = &opts.tap_file { // TAP files doesn't need to be redacted, they don't expose any logs apart from files names. logger.debug(&format!("Writing TAP report to {}", file.display())); create_tap_report(runs, file)?; } if let Some(dir) = &opts.html_dir { logger.debug(&format!("Writing HTML report to {}", dir.display())); create_html_report(runs, dir, &secrets)?; } if let Some(dir) = &opts.json_report_dir { logger.debug(&format!("Writing JSON report to {}", dir.display())); create_json_report(runs, dir, &secrets)?; } if let Some(file) = &opts.cookie_output_file { logger.debug(&format!("Writing cookies to {}", file.display())); create_cookies_file(runs, file, &secrets)?; } Ok(()) } /// Creates an export of all curl commands for this run. fn create_curl_export(runs: &[HurlRun], filename: &Path, secrets: &[&str]) -> Result<(), CliError> { let results = runs.iter().map(|r| &r.hurl_result).collect::>(); curl::write_curl(&results, filename, secrets)?; Ok(()) } /// Creates a JUnit report for this run. fn create_junit_report( runs: &[HurlRun], filename: &Path, secrets: &[&str], ) -> Result<(), CliError> { let testcases = runs .iter() .map(|r| junit::Testcase::from(&r.hurl_result, &r.content, &r.filename)) .collect::>(); junit::write_report(filename, &testcases, secrets)?; Ok(()) } /// Creates a TAP report for this run. fn create_tap_report(runs: &[HurlRun], filename: &Path) -> Result<(), CliError> { let testcases = runs .iter() .map(|r| tap::Testcase::from(&r.hurl_result, &r.filename)) .collect::>(); tap::write_report(filename, &testcases)?; Ok(()) } /// Creates an HTML report for this run. fn create_html_report(runs: &[HurlRun], dir_path: &Path, secrets: &[&str]) -> Result<(), CliError> { // We ensure that the containing folder exists. let store_path = dir_path.join("store"); std::fs::create_dir_all(&store_path)?; let mut testcases = vec![]; for run in runs.iter() { let result = &run.hurl_result; let testcase = html::Testcase::from(result, &run.filename); testcase.write_html(&run.content, &result.entries, &store_path, secrets)?; testcases.push(testcase); } html::write_report(dir_path, &testcases)?; Ok(()) } /// Creates an JSON report for this run. fn create_json_report(runs: &[HurlRun], dir_path: &Path, secrets: &[&str]) -> Result<(), CliError> { // We ensure that the containing folder exists. let store_path = dir_path.join("store"); std::fs::create_dir_all(&store_path)?; let testcases = runs .iter() .map(|r| json::Testcase::new(&r.hurl_result, &r.content, &r.filename)) .collect::>(); let index_path = dir_path.join("report.json"); json::write_report(&index_path, &testcases, &store_path, secrets)?; Ok(()) } /// Returns an exit code for a list of HurlResult. fn exit_code(runs: &[HurlRun]) -> i32 { let mut count_errors_runner = 0; let mut count_errors_assert = 0; for run in runs.iter() { let errors = run.hurl_result.errors(); if errors.is_empty() { } else if errors.iter().filter(|(error, _)| !error.assert).count() == 0 { count_errors_assert += 1; } else { count_errors_runner += 1; } } if count_errors_runner > 0 { EXIT_ERROR_RUNTIME } else if count_errors_assert > 0 { EXIT_ERROR_ASSERT } else { EXIT_OK } } /// Export cookies for this run to `filename` file. /// /// The file format for the cookies is [Netscape cookie format](http://www.cookiecentral.com/faq/#3.5). fn create_cookies_file( runs: &[HurlRun], filename: &Path, secrets: &[&str], ) -> Result<(), CliError> { if let Err(err) = hurl::util::path::create_dir_all(filename) { return Err(CliError::IO(format!( "Issue creating parent directories for {}: {err:?}", filename.display() ))); } let mut file = match std::fs::File::create(filename) { Err(why) => { return Err(CliError::IO(format!( "Issue writing to {}: {why:?}", filename.display() ))); } Ok(file) => file, }; let mut s = r#"# Netscape HTTP Cookie File # This file was generated by Hurl "# .to_string(); if runs.is_empty() { return Err(CliError::IO("Issue fetching results".to_string())); } for run in runs.iter() { s.push_str(&format!("# Cookies for file <{}>", run.filename)); s.push('\n'); for cookie in run.hurl_result.cookies.iter() { s.push_str(&cookie.redact(secrets)); s.push('\n'); } } if let Err(why) = file.write_all(s.as_bytes()) { return Err(CliError::IO(format!( "Issue writing to {}: {why:?}", filename.display() ))); } Ok(()) } hurl-6.1.1/src/output/error.rs000064400000000000000000000051701046102023000144340ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::SourceInfo; use hurl_core::error::DisplaySourceError; use hurl_core::text::{Style, StyledString}; use crate::http::HttpError; #[derive(Clone, Debug, PartialEq, Eq)] pub struct OutputError { pub source_info: SourceInfo, pub kind: OutputErrorKind, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum OutputErrorKind { Http(HttpError), Binary, Io(String), } impl OutputError { pub fn new(source_info: SourceInfo, kind: OutputErrorKind) -> OutputError { OutputError { source_info, kind } } } /// Textual Output for runner errors impl DisplaySourceError for OutputError { fn source_info(&self) -> SourceInfo { self.source_info } fn description(&self) -> String { match &self.kind { OutputErrorKind::Http(http_error) => http_error.description(), OutputErrorKind::Binary => "Binary Error".to_string(), OutputErrorKind::Io(_) => "IO Error".to_string(), } } fn fixme(&self, content: &[&str]) -> StyledString { match &self.kind { OutputErrorKind::Http(http_error) => { let message = http_error.message(); let message = hurl_core::error::add_carets(&message, self.source_info, content); color_red(&message) } OutputErrorKind::Binary => { let message = "Binary output can mess up your terminal. Use \"--output -\" to tell Hurl to output it to your terminal anyway, or consider \"--output\" to save to a file."; let message = hurl_core::error::add_carets(message, self.source_info, content); color_red(&message) } OutputErrorKind::Io(message) => { let message = hurl_core::error::add_carets(message, self.source_info, content); color_red(&message) } } } } fn color_red(message: &str) -> StyledString { let mut s = StyledString::new(); s.push_with(message, Style::new().red().bold()); s } hurl-6.1.1/src/output/json.rs000064400000000000000000000041001046102023000142440ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::io; use hurl_core::input::Input; use crate::runner::{HurlResult, Output}; use crate::util::term::Stdout; /// Writes the `hurl_result` JSON representation to the file `filename_out`. /// /// If `filename_out` is `None`, stdout is used. If `append` is true, any existing file will /// be appended instead of being truncated. The original `content` of the Hurl file and the /// source `filename_in` is necessary in order to construct error fields with column, line number /// etc... when processing failed asserts and captures. pub fn write_json( hurl_result: &HurlResult, content: &str, filename_in: &Input, filename_out: Option<&Output>, stdout: &mut Stdout, append: bool, ) -> Result<(), io::Error> { let response_dir = None; // Secrets are only redacted from standard error and reports. In this case, we want to output a // response in a structured way. We do not change the value of the response output as it may be // used for processing, contrary to the standard error that should be used for debug/log/messages. let secrets = []; let json_result = hurl_result.to_json(content, filename_in, response_dir, &secrets)?; let serialized = serde_json::to_string(&json_result)?; let bytes = format!("{serialized}\n"); let bytes = bytes.into_bytes(); match filename_out { Some(out) => out.write(&bytes, stdout, append)?, None => Output::Stdout.write(&bytes, stdout, append)?, } Ok(()) } hurl-6.1.1/src/output/mod.rs000064400000000000000000000022201046102023000140530ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ //! Serialize a Hurl run result to a file. //! //! There are two supported serialisation: //! //! - JSON: the whole run is serialized to JSON (like the [HAR](https://en.wikipedia.org/wiki/HAR_(file_format)) format) //! [`self::json::write_json`] //! - raw: the last response of a run is serialized to a file. The body can be automatically uncompress //! or written as it [`self::raw::write_last_body`] mod error; mod json; mod raw; pub use self::error::OutputError; pub use self::json::write_json; pub use self::raw::write_last_body; hurl-6.1.1/src/output/raw.rs000064400000000000000000000214101046102023000140670ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::cmp::min; use std::io::IsTerminal; use crate::output::error::OutputErrorKind; use crate::output::OutputError; use crate::runner::{HurlResult, Output}; use crate::util::term::Stdout; /// Writes the `hurl_result` last response to the file `filename_out`. /// /// If `filename_out` is `None`, standard output is used. If `append` is true, any existing file will /// be appended instead of being truncated. If `include_headers` is true, the last /// HTTP response headers are written before the body response. pub fn write_last_body( hurl_result: &HurlResult, include_headers: bool, color: bool, filename_out: Option<&Output>, stdout: &mut Stdout, append: bool, ) -> Result<(), OutputError> { // Get the last call of the Hurl result. let Some(last_entry) = &hurl_result.entries.last() else { return Ok(()); }; let Some(call) = &last_entry.calls.last() else { return Ok(()); }; let response = &call.response; let mut output = vec![]; // If include options is set, we output the HTTP response headers // with status and version (to mimic curl outputs) if include_headers { let mut text = response.get_status_line_headers(color); text.push('\n'); output.append(&mut text.into_bytes()); } if last_entry.compressed { let mut bytes = match response.uncompress_body() { Ok(b) => b, Err(e) => { let source_info = last_entry.source_info; let kind = OutputErrorKind::Http(e); return Err(OutputError::new(source_info, kind)); } }; output.append(&mut bytes); } else { let bytes = &response.body; output.extend(bytes); } // We replicate curl's checks for binary output: a warning is displayed when user hasn't // used `--output` option and the response is considered as a binary content. If user has used // `--output` whether to save to a file, or to redirect output to standard output (`--output -`) // we don't display any warning. match filename_out { None => { if std::io::stdout().is_terminal() && is_binary(&output) { let source_info = last_entry.source_info; let kind = OutputErrorKind::Binary; return Err(OutputError::new(source_info, kind)); } Output::Stdout.write(&output, stdout, append).map_err(|e| { let source_info = last_entry.source_info; let kind = OutputErrorKind::Io(e.to_string()); OutputError::new(source_info, kind) })?; } Some(out) => out.write(&output, stdout, append).map_err(|e| { let filename = if let Output::File(filename) = out { filename.display().to_string() } else { "stdout".to_string() }; let source_info = last_entry.source_info; let kind = OutputErrorKind::Io(format!("{filename} can not be written ({})", e)); OutputError::new(source_info, kind) })?, } Ok(()) } /// Returns `true` if `bytes` is a binary content, false otherwise. /// /// For the implementation, we use a simple heuristic on the buffer: just check the presence of NULL /// in the first 2000 bytes to determine if the content if binary or not. /// /// See /// and fn is_binary(bytes: &[u8]) -> bool { let len = min(2000, bytes.len()); for c in &bytes[..len] { if *c == 0 { return true; } } false } #[cfg(test)] mod tests { use std::str::FromStr; use std::time::Duration; use hurl_core::ast::SourceInfo; use hurl_core::reader::Pos; use crate::http::{Call, Header, HeaderVec, HttpVersion, Request, Response, Url}; use crate::output::write_last_body; use crate::runner::{EntryResult, HurlResult, Output}; use crate::util::term::{Stdout, WriteMode}; fn default_response() -> Response { Response { version: HttpVersion::Http10, status: 200, headers: HeaderVec::new(), body: vec![], duration: Default::default(), url: Url::from_str("http://localhost").unwrap(), certificate: None, ip_addr: Default::default(), } } fn hurl_result_json() -> HurlResult { let mut headers = HeaderVec::new(); headers.push(Header::new("x-foo", "xxx")); headers.push(Header::new("x-bar", "yyy0")); headers.push(Header::new("x-bar", "yyy1")); headers.push(Header::new("x-bar", "yyy2")); headers.push(Header::new("x-baz", "zzz")); HurlResult { entries: vec![ EntryResult { entry_index: 1, source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), calls: vec![Call { request: Request { url: Url::from_str("https://foo.com").unwrap(), method: "GET".to_string(), headers: HeaderVec::new(), body: vec![], }, response: default_response(), timings: Default::default(), }], ..Default::default() }, EntryResult { entry_index: 2, source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), calls: vec![Call { request: Request { url: Url::from_str("https://bar.com").unwrap(), method: "GET".to_string(), headers: HeaderVec::new(), body: vec![], }, response: default_response(), timings: Default::default(), }], ..Default::default() }, EntryResult { entry_index: 3, source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), calls: vec![Call { request: Request { url: Url::from_str("https://baz.com").unwrap(), method: "GET".to_string(), headers: HeaderVec::new(), body: vec![], }, response: Response { version: HttpVersion::Http3, status: 204, headers, body: b"{\"say\": \"Hello World!\"}".into(), duration: Default::default(), url: Url::from_str("https://baz.com").unwrap(), certificate: None, ip_addr: Default::default(), }, timings: Default::default(), }], ..Default::default() }, ], duration: Duration::from_millis(100), success: true, ..Default::default() } } #[test] fn write_last_body_with_headers() { let result = hurl_result_json(); let include_header = true; let color = false; let output = Some(Output::Stdout); let mut stdout = Stdout::new(WriteMode::Buffered); write_last_body( &result, include_header, color, output.as_ref(), &mut stdout, true, ) .unwrap(); let stdout = String::from_utf8(stdout.buffer().to_vec()).unwrap(); assert_eq!( stdout, "HTTP/3 204\n\ x-foo: xxx\n\ x-bar: yyy0\n\ x-bar: yyy1\n\ x-bar: yyy2\n\ x-baz: zzz\n\ \n\ {\"say\": \"Hello World!\"}" ); } } hurl-6.1.1/src/parallel/error.rs000064400000000000000000000014001046102023000146600ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ /// Error triggered when running a [`crate::parallel::job::Job`]. pub enum JobError { IO(String), Parsing, Runtime(String), } hurl-6.1.1/src/parallel/job.rs000064400000000000000000000146171046102023000143170ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::input::Input; use hurl_core::typing::Count; use crate::runner::{HurlResult, RunnerOptions, VariableSet}; use crate::util::logger::LoggerOptions; /// Represents the job to run. A job instance groups the input data to execute, and has no methods /// associated to it. #[derive(Clone, Debug, Eq, PartialEq)] pub struct Job { /// The Hurl source content pub filename: Input, /// The options to run this file. pub runner_options: RunnerOptions, /// Set of variables injected in the Hurl file pub variables: VariableSet, /// The logger options for this run pub logger_options: LoggerOptions, /// The job 0-based index in the jobs list pub seq: usize, } impl Job { /// Creates a new job. pub fn new( filename: &Input, seq: usize, runner_options: &RunnerOptions, variables: &VariableSet, logger_options: &LoggerOptions, ) -> Self { Job { filename: filename.clone(), runner_options: runner_options.clone(), variables: variables.clone(), logger_options: logger_options.clone(), seq, } } } pub struct JobResult { /// The job corresponding to this job result. pub job: Job, /// The source content of the job. pub content: String, /// The result of execution of the job. pub hurl_result: HurlResult, } impl JobResult { /// Creates a new job result. pub fn new(job: Job, content: String, hurl_result: HurlResult) -> Self { JobResult { job, content, hurl_result, } } } /// A job queue to manage a queue of [`Job`]. /// /// The job queue implements [`Iterator`] trait, and can return a new job to use each time its /// `next` method is called. This queue can repeat its input sequence a certain number of times, or /// can loop forever. pub struct JobQueue<'job> { /// The input jobs list. jobs: &'job [Job], /// Current index of the job, referencing the input job list. index: usize, /// Repeat mode of this queue (finite or infinite). repeat: Count, /// Current index of the repeat. repeat_index: usize, } impl<'job> JobQueue<'job> { /// Create a new queue, with a list of `jobs` and a `repeat` mode. pub fn new(jobs: &'job [Job], repeat: Count) -> Self { JobQueue { jobs, index: 0, repeat, repeat_index: 0, } } /// Returns the effective number of jobs. /// /// If queue is created in loop forever mode ([`Repeat::Forever`]), returns `None`. pub fn jobs_count(&self) -> Option { match self.repeat { Count::Finite(n) => Some(self.jobs.len() * n), Count::Infinite => None, } } /// Returns a new job at the given `index`. fn job_at(&self, index: usize) -> Job { let mut job = self.jobs[index].clone(); // When we're repeating a sequence, we clone an original job and give it a proper // sequence number relative to the current `repeat_index`. job.seq = self.jobs[index].seq + (self.jobs.len() * self.repeat_index); job } } impl Iterator for JobQueue<'_> { type Item = Job; fn next(&mut self) -> Option { if self.index >= self.jobs.len() { self.repeat_index = self.repeat_index.checked_add(1).unwrap_or(0); match self.repeat { Count::Finite(n) => { if self.repeat_index >= n { None } else { self.index = 1; Some(self.job_at(0)) } } Count::Infinite => { self.index = 1; Some(self.job_at(0)) } } } else { self.index += 1; Some(self.job_at(self.index - 1)) } } } #[cfg(test)] mod tests { use hurl_core::input::Input; use hurl_core::typing::Count; use crate::parallel::job::{Job, JobQueue}; use crate::runner::{RunnerOptionsBuilder, VariableSet}; use crate::util::logger::LoggerOptionsBuilder; fn new_job(file: &str, index: usize) -> Job { let variables = VariableSet::new(); let runner_options = RunnerOptionsBuilder::default().build(); let logger_options = LoggerOptionsBuilder::default().build(); Job::new( &Input::new(file), index, &runner_options, &variables, &logger_options, ) } #[test] fn job_queue_is_finite() { let jobs = [ new_job("a.hurl", 0), new_job("b.hurl", 1), new_job("c.hurl", 2), ]; let mut queue = JobQueue::new(&jobs, Count::Finite(2)); assert_eq!(queue.next(), Some(new_job("a.hurl", 0))); assert_eq!(queue.next(), Some(new_job("b.hurl", 1))); assert_eq!(queue.next(), Some(new_job("c.hurl", 2))); assert_eq!(queue.next(), Some(new_job("a.hurl", 3))); assert_eq!(queue.next(), Some(new_job("b.hurl", 4))); assert_eq!(queue.next(), Some(new_job("c.hurl", 5))); assert_eq!(queue.next(), None); assert_eq!(queue.jobs_count(), Some(6)); } #[test] fn input_queue_is_infinite() { let jobs = [new_job("foo.hurl", 0)]; let mut queue = JobQueue::new(&jobs, Count::Infinite); assert_eq!(queue.next(), Some(new_job("foo.hurl", 0))); assert_eq!(queue.next(), Some(new_job("foo.hurl", 1))); assert_eq!(queue.next(), Some(new_job("foo.hurl", 2))); assert_eq!(queue.next(), Some(new_job("foo.hurl", 3))); assert_eq!(queue.next(), Some(new_job("foo.hurl", 4))); // etc... assert_eq!(queue.jobs_count(), None); } } hurl-6.1.1/src/parallel/message.rs000064400000000000000000000077061046102023000151720ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::io; use crate::parallel::job::{Job, JobResult}; use crate::parallel::worker::WorkerId; use crate::util::term::{Stderr, Stdout}; /// Represents a message sent from the worker to the runner (running on the main thread). pub enum WorkerMessage { /// Error raised when the file can't be read. IOError(IOErrorMsg), /// Error raised when the file isn't a valid Hurl content. ParsingError(ParsingErrorMsg), /// Sent when the Hurl file is in progress (file has been parsed and HTTP exchanges have started). Running(RunningMsg), /// Sent when the Hurl file is completed, whether successful or failed. Completed(CompletedMsg), } /// A message sent from worker to runner when the input file can't be read. pub struct IOErrorMsg { /// Identifier of the worker sending this message. #[allow(dead_code)] pub worker_id: WorkerId, /// Job originator of this message. pub job: Job, /// Inner error that has triggered this message. pub error: io::Error, } impl IOErrorMsg { /// Creates a new I/O error message. pub fn new(worker_id: WorkerId, job: &Job, error: io::Error) -> Self { IOErrorMsg { worker_id, job: job.clone(), error, } } } /// A message sent from worker to runner when the input file can't be parsed. pub struct ParsingErrorMsg { /// Identifier of the worker sending this message. #[allow(dead_code)] pub worker_id: WorkerId, /// Job originator of this message. #[allow(dead_code)] pub job: Job, /// Standard error of the worker for this job. pub stderr: Stderr, } impl ParsingErrorMsg { /// Creates a new parsing error message. pub fn new(worker_id: WorkerId, job: &Job, stderr: &Stderr) -> Self { ParsingErrorMsg { worker_id, job: job.clone(), stderr: stderr.clone(), } } } /// A message sent from worker to runner at regular time to inform that the job is being run. pub struct RunningMsg { /// Identifier of the worker sending this message. pub worker_id: WorkerId, /// Job originator of this message. pub job: Job, /// 0-based index of the current entry. pub entry_index: usize, /// Number of entries pub entry_count: usize, } impl RunningMsg { /// Creates a new running message: the job is in progress. pub fn new(worker_id: WorkerId, job: &Job, entry_index: usize, entry_count: usize) -> Self { RunningMsg { worker_id, job: job.clone(), entry_index, entry_count, } } } /// A message sent from worker to runner when a Hurl file has completed, whether successful or not. pub struct CompletedMsg { /// Identifier of the worker sending this message. pub worker_id: WorkerId, /// Result execution of the originator job, can successful or failed. pub result: JobResult, /// Standard output of the worker for this job. pub stdout: Stdout, /// Standard error of the worker for this job. pub stderr: Stderr, } impl CompletedMsg { /// Creates a new completed message: the job has completed, successfully or not. pub fn new(worker_id: WorkerId, result: JobResult, stdout: Stdout, stderr: Stderr) -> Self { CompletedMsg { worker_id, result, stdout, stderr, } } } hurl-6.1.1/src/parallel/mod.rs000064400000000000000000000013671046102023000143220ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ //! Run Hurl files in parallel (experimental). pub mod error; pub mod job; mod message; mod progress; pub mod runner; mod worker; hurl-6.1.1/src/parallel/progress.rs000064400000000000000000000427031046102023000154060ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::time::{Duration, Instant}; use hurl_core::text::{Format, Style, StyledString}; use crate::parallel::job::JobResult; use crate::parallel::runner::WorkerState; use crate::parallel::worker::Worker; use crate::util::term::Stderr; /// A progress reporter to display advancement of parallel runs execution in test mode. pub struct ParProgress { /// The maximum number of running workers displayed in the progress bar. max_running_displayed: usize, /// Mode of the progress reporter mode: Mode, /// The standard error format for message: ANSI or plain. format: Format, /// The maximum width of the progress string, in chars. max_width: Option, /// Save last progress bar refresh to limits flickering. throttle: Throttle, } #[derive(Copy, Clone)] pub enum Mode { /// Run without --test Default, /// Run with --test and with a progress bar TestWithProgress, /// Run with --test and no progress bar TestWithoutProgress, } /// The minimum duration between two progress bar redraw (to avoid flickering). const UPDATE_INTERVAL: Duration = Duration::from_millis(100); /// The minimum duration for the progress bar to be throttle (some delay to let the UI stabilize) const FIRST_THROTTLE: Duration = Duration::from_millis(16); impl ParProgress { /// Creates a new instance. pub fn new( max_running_displayed: usize, mode: Mode, color: bool, max_width: Option, ) -> Self { let format = if color { Format::Ansi } else { Format::Plain }; ParProgress { max_running_displayed, mode, format, max_width, throttle: Throttle::new(UPDATE_INTERVAL, FIRST_THROTTLE), } } /// Clear the progress bar. pub fn clear_progress_bar(&self, stderr: &mut Stderr) { if !matches!(self.mode, Mode::TestWithProgress) { return; } stderr.clear_progress_bar(); } /// Displays progression of `workers` on standard error `stderr`. /// /// This method is called on the parallel runner thread (usually the main thread). pub fn update_progress_bar( &mut self, workers: &[(Worker, WorkerState)], completed: usize, count: Option, stderr: &mut Stderr, ) { if !matches!(self.mode, Mode::TestWithProgress) { return; } self.throttle.update(); let Some(progress) = build_progress( workers, completed, count, self.max_running_displayed, self.format, self.max_width, ) else { return; }; stderr.set_progress_bar(&progress); } /// Displays the completion of a job `result`. pub fn print_completed(&mut self, result: &JobResult, stderr: &mut Stderr) { if matches!(self.mode, Mode::Default) { return; } let count = result .hurl_result .entries .iter() .flat_map(|r| &r.calls) .count(); let duration = result.hurl_result.duration.as_millis(); let filename = result.job.filename.to_string(); let mut message = StyledString::new(); message.push_with(&filename, Style::new().bold()); message.push(": "); if result.hurl_result.success { message.push_with("Success", Style::new().green().bold()); } else { message.push_with("Failure", Style::new().red().bold()); }; message.push(&format!(" ({count} request(s) in {duration} ms)")); let message = message.to_string(self.format); stderr.eprintln(&message); } /// Returns `true` if there has been sufficient time elapsed since the last progress bar /// refresh, `false` otherwise. pub fn can_update(&mut self) -> bool { self.throttle.allowed() } /// For the next progress bar update to be effectively drawn. pub fn force_next_update(&mut self) { self.throttle.reset(); } } impl Mode { pub fn new(test: bool, progress_bar: bool) -> Self { match (test, progress_bar) { (true, true) => Mode::TestWithProgress, (true, false) => Mode::TestWithoutProgress, _ => Mode::Default, } } } /// Records the instant when a progress bar is refreshed on the terminal. /// We don't want to update the progress bar too often as it can cause excessive performance loss /// just putting stuff onto the terminal. We also want to avoid flickering by not drawing anything /// that goes away too quickly. struct Throttle { /// Creation time of the progress. start: Instant, /// Last time the progress bar has be refreshed on the terminal. last_update: Option, /// Refresh interval interval: Duration, /// First interval of non throttle to let the UI initialize first_throttle: Duration, } impl Throttle { /// Creates a new instances. fn new(interval: Duration, first_throttle: Duration) -> Self { Throttle { start: Instant::now(), last_update: None, interval, first_throttle, } } /// Returns `true` if there has been sufficient time elapsed since the last refresh. fn allowed(&self) -> bool { match self.last_update { None => true, Some(update) => update.elapsed() >= self.interval, } } fn update(&mut self) { if self.start.elapsed() < self.first_throttle { return; } self.last_update = Some(Instant::now()); } fn reset(&mut self) { self.last_update = None; } } /// Returns a progress string, given a list of `workers`, a number of `completed` jobs and the /// total number of jobs. `count` is the optional total number of files to execute. /// /// `max_running_displayed` is used to limit the number of running progress bar. If more jobs are /// running, a label "...x more" is displayed. /// `format` is the format of the progress string (ANSI or plain). /// The progress string is wrapped with new lines at width `max_width`. fn build_progress( workers: &[(Worker, WorkerState)], completed: usize, count: Option, max_running_displayed: usize, format: Format, max_width: Option, ) -> Option { // Select the running workers to be displayed let mut workers = workers .iter() .filter(|(_, state)| matches!(state, WorkerState::Running { .. })) .collect::>(); if workers.is_empty() { return None; } // We sort the running workers by job sequence id, this way a given job will be displayed // on the same line, independently of the worker id. workers.sort_unstable_by_key(|(_, state)| match state { WorkerState::Running { job, .. } => job.seq, WorkerState::Idle => usize::MAX, }); let running = workers.len(); // We keep a reasonable number of worker to displayed, from the oldest to the newest. workers.truncate(max_running_displayed); // Computes maximum size of the string "[current request] / [nb of request]" to left align // the column. let max = workers .iter() .map(|(_, state)| match state { WorkerState::Running { entry_count, .. } => *entry_count, WorkerState::Idle => 0, }) .max() .unwrap(); let max_completed_width = 2 * (((max as f64).log10() as usize) + 1) + 1; // Construct all the progress strings let mut all_progress = String::new(); let progress = match count { Some(count) => { let percent = (completed as f64 * 100.0 / count as f64) as usize; format!("Executed files: {completed}/{count} ({percent}%)\n") } None => format!("Executed files: {completed}\n"), }; // We don't wrap this string for the moment, there is low chance to overlap the maximum width // of the terminal. all_progress.push_str(&progress); for (_, state) in &workers { if let WorkerState::Running { job, entry_index, entry_count, } = state { let entry_index = entry_index + 1; // entry index display is 1-based let requests = format!("{entry_index}/{entry_count}"); let padding = " ".repeat(max_completed_width - requests.len()); let bar = progress_bar(entry_index, *entry_count); let mut progress = StyledString::new(); progress.push(&bar); progress.push(&padding); progress.push(" "); progress.push_with(&job.filename.to_string(), Style::new().bold()); progress.push(": "); progress.push_with("Running", Style::new().cyan().bold()); progress.push("\n"); // We wrap the progress string with new lines if necessary if let Some(max_width) = max_width { if progress.len() >= max_width { progress = progress.wrap(max_width); } } let progress = progress.to_string(format); all_progress.push_str(&progress); } } // If the number of running workers is greater that those displayed, we add the remaining // number of not displayed running. if running > max_running_displayed { all_progress.push_str(&format!("...{} more\n", running - max_running_displayed)); } Some(all_progress) } /// Returns the progress bar of a single operation with the 1-based current `index`. fn progress_bar(index: usize, count: usize) -> String { const WIDTH: usize = 24; // We report the number of items already processed. let progress = (index - 1) as f64 / count as f64; let col = (progress * WIDTH as f64) as usize; let completed = if col > 0 { "=".repeat(col) } else { String::new() }; let void = " ".repeat(WIDTH - col - 1); format!("[{completed}>{void}] {index}/{count}") } #[cfg(test)] mod tests { use std::sync::{mpsc, Arc, Mutex}; use hurl_core::input::Input; use hurl_core::text::Format; use crate::parallel::job::Job; use crate::parallel::progress::{build_progress, progress_bar}; use crate::parallel::runner::WorkerState; use crate::parallel::worker::{Worker, WorkerId}; use crate::runner::{RunnerOptionsBuilder, VariableSet}; use crate::util::logger::LoggerOptionsBuilder; fn new_workers() -> (Worker, Worker, Worker, Worker, Worker) { let (tx_out, _) = mpsc::channel(); let (_, rx_in) = mpsc::channel(); let rx_in = Arc::new(Mutex::new(rx_in)); let w0 = Worker::new(WorkerId::from(0), &tx_out, &rx_in); let w1 = Worker::new(WorkerId::from(1), &tx_out, &rx_in); let w2 = Worker::new(WorkerId::from(2), &tx_out, &rx_in); let w3 = Worker::new(WorkerId::from(3), &tx_out, &rx_in); let w4 = Worker::new(WorkerId::from(4), &tx_out, &rx_in); (w0, w1, w2, w3, w4) } fn new_jobs() -> Vec { let variables = VariableSet::new(); let runner_options = RunnerOptionsBuilder::default().build(); let logger_options = LoggerOptionsBuilder::default().build(); let files = [ "a.hurl", "b.hurl", "c.hurl", "d.hurl", "e.hurl", "f.hurl", "g.hurl", ]; files .iter() .enumerate() .map(|(index, file)| { Job::new( &Input::new(file), index, &runner_options, &variables, &logger_options, ) }) .collect() } fn new_running_state(job: &Job, entry_index: usize, entry_count: usize) -> WorkerState { WorkerState::Running { job: job.clone(), entry_index, entry_count, } } #[test] fn all_workers_running() { let (w0, w1, w2, w3, w4) = new_workers(); let jobs = new_jobs(); let completed = 75; let total = Some(100); let max_displayed = 3; let mut workers = vec![ (w0, WorkerState::Idle), (w1, WorkerState::Idle), (w2, WorkerState::Idle), (w3, WorkerState::Idle), (w4, WorkerState::Idle), ]; let progress = build_progress( &workers, completed, total, max_displayed, Format::Plain, None, ); assert!(progress.is_none()); workers[0].1 = new_running_state(&jobs[0], 0, 10); workers[1].1 = new_running_state(&jobs[1], 0, 2); workers[2].1 = new_running_state(&jobs[2], 0, 5); workers[3].1 = new_running_state(&jobs[3], 0, 7); workers[4].1 = new_running_state(&jobs[4], 0, 4); let progress = build_progress( &workers, completed, total, max_displayed, Format::Plain, None, ); assert_eq!( progress.unwrap(), "\ Executed files: 75/100 (75%)\n\ [> ] 1/10 a.hurl: Running\n\ [> ] 1/2 b.hurl: Running\n\ [> ] 1/5 c.hurl: Running\n\ ...2 more\n\ " ); workers[0].1 = new_running_state(&jobs[0], 5, 10); workers[1].1 = new_running_state(&jobs[1], 1, 2); workers[2].1 = new_running_state(&jobs[2], 2, 5); workers[3].1 = new_running_state(&jobs[3], 3, 7); workers[4].1 = new_running_state(&jobs[4], 1, 4); let progress = build_progress( &workers, completed, total, max_displayed, Format::Plain, None, ); assert_eq!( progress.unwrap(), "\ Executed files: 75/100 (75%)\n\ [============> ] 6/10 a.hurl: Running\n\ [============> ] 2/2 b.hurl: Running\n\ [=========> ] 3/5 c.hurl: Running\n\ ...2 more\n\ " ); workers[0].1 = new_running_state(&jobs[0], 9, 10); workers[1].1 = new_running_state(&jobs[5], 0, 6); workers[2].1 = new_running_state(&jobs[2], 4, 5); workers[3].1 = new_running_state(&jobs[3], 5, 7); workers[4].1 = new_running_state(&jobs[4], 2, 4); let progress = build_progress( &workers, completed, total, max_displayed, Format::Plain, None, ); assert_eq!( progress.unwrap(), "\ Executed files: 75/100 (75%)\n\ [=====================> ] 10/10 a.hurl: Running\n\ [===================> ] 5/5 c.hurl: Running\n\ [=================> ] 6/7 d.hurl: Running\n\ ...2 more\n\ " ); workers[0].1 = WorkerState::Idle; workers[1].1 = new_running_state(&jobs[5], 2, 6); workers[2].1 = WorkerState::Idle; workers[3].1 = WorkerState::Idle; workers[4].1 = new_running_state(&jobs[4], 3, 4); let progress = build_progress( &workers, completed, total, max_displayed, Format::Plain, None, ); assert_eq!( progress.unwrap(), "\ Executed files: 75/100 (75%)\n\ [==================> ] 4/4 e.hurl: Running\n\ [========> ] 3/6 f.hurl: Running\n\ " ); workers[0].1 = WorkerState::Idle; workers[1].1 = new_running_state(&jobs[5], 5, 6); workers[2].1 = WorkerState::Idle; workers[3].1 = WorkerState::Idle; workers[4].1 = WorkerState::Idle; let progress = build_progress( &workers, completed, total, max_displayed, Format::Plain, None, ); assert_eq!( progress.unwrap(), "\ Executed files: 75/100 (75%)\n\ [====================> ] 6/6 f.hurl: Running\n\ " ); } #[rustfmt::skip] #[test] fn test_progress_bar() { // Progress strings with 20 entries: assert_eq!(progress_bar(1, 20), "[> ] 1/20"); assert_eq!(progress_bar(2, 20), "[=> ] 2/20"); assert_eq!(progress_bar(5, 20), "[====> ] 5/20"); assert_eq!(progress_bar(10, 20), "[==========> ] 10/20"); assert_eq!(progress_bar(15, 20), "[================> ] 15/20"); assert_eq!(progress_bar(20, 20), "[======================> ] 20/20"); // Progress strings with 3 entries: assert_eq!(progress_bar(1, 3), "[> ] 1/3"); assert_eq!(progress_bar(2, 3), "[========> ] 2/3"); assert_eq!(progress_bar(3, 3), "[================> ] 3/3"); // Progress strings with 1 entry: assert_eq!(progress_bar(1, 1), "[> ] 1/1"); } } hurl-6.1.1/src/parallel/runner.rs000064400000000000000000000352601046102023000150530ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::sync::mpsc::{Receiver, Sender}; use std::sync::{mpsc, Arc, Mutex}; use hurl_core::error::{DisplaySourceError, OutputFormat}; use hurl_core::typing::Count; use crate::output; use crate::parallel::error::JobError; use crate::parallel::job::{Job, JobQueue, JobResult}; use crate::parallel::message::WorkerMessage; use crate::parallel::progress::{Mode, ParProgress}; use crate::parallel::worker::{Worker, WorkerId}; use crate::util::term::{Stderr, Stdout, WriteMode}; /// A parallel runner manages a list of `Worker`. Each worker is either idle or is running a /// [`Job`]. To run jobs, the [`ParallelRunner::run`] method much be executed on the main thread. /// Each worker has its own thread that it uses to run a Hurl file, and communicates with the main /// thread. Standard multi-producer single-producer channels are used between the main runner and /// the workers to send job request and receive job result. /// /// The parallel runner is responsible to manage the state of the workers, and to display standard /// output and standard error, in the main thread. Each worker reports its progression to the /// parallel runner, which updates the workers states and displays a progress bar. /// Inside each worker, logs (messages on standard error) and HTTP response (output on /// standard output) are buffered and send to the runner to be eventually displayed. /// /// By design, the workers state is read and modified on the main thread. pub struct ParallelRunner { /// The list of workers, running Hurl file in their inner thread. workers: Vec<(Worker, WorkerState)>, /// The transmit end of the channel used to send messages to workers. tx: Option>, /// The receiving end of the channel used to communicate to workers. rx: Receiver, /// Progress reporter to display the advancement of the parallel runs. progress: ParProgress, /// Output type for each completed job on standard output. output_type: OutputType, /// Repeat mode for the runner: infinite or finite. repeat: Count, } /// Represents a worker's state. #[allow(clippy::large_enum_variant)] pub enum WorkerState { /// Worker has no job to run. Idle, /// Worker is currently running a `job`, the entry being executed is at 0-based index /// `entry_index`, the total number of entries being `entry_count`. Running { job: Job, entry_index: usize, entry_count: usize, }, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum OutputType { /// The last HTTP response body of a Hurl file is outputted on standard output. ResponseBody { include_headers: bool, color: bool }, /// The whole Hurl file run is exported in a structured JSON export on standard output. Json, /// Nothing is outputted on standard output when a Hurl file run is completed. NoOutput, } const MAX_RUNNING_DISPLAYED: usize = 8; impl ParallelRunner { /// Creates a new parallel runner, with `worker_count` worker thread. /// /// The runner runs a list of [`Job`] in parallel. It creates two channels to communicate /// with the workers: /// /// - `runner -> worker`: is used to send [`Job`] processing request to a worker, /// - `worker -> runner`: is used to send [`WorkerMessage`] to update job progression, from a /// worker to the runner. /// /// When a job is completed, depending on `output_type`, it can be outputted to standard output: /// whether as a raw response body bytes, or in a structured JSON output. /// /// The runner can repeat running a list of jobs. For instance, when repeating two times the job /// sequence (`a`, `b`, `c`), runner will act as if it runs (`a`, `b`, `c`, `a`, `b`, `c`). /// /// If `test` mode is `true` the runner is run in "test" mode, reporting the success or failure /// of each file on standard error. In addition to the test mode, a `progress_bar` designed for /// parallel run progression can be used. When the progress bar is displayed, it's wrapped with /// new lines at width `max_width`. /// /// `color` determines if color if used in standard error. pub fn new( workers_count: usize, output_type: OutputType, repeat: Count, test: bool, progress_bar: bool, color: bool, max_width: Option, ) -> Self { // Worker are running on theirs own thread, while parallel runner is running in the main // thread. // We create the channel to communicate from workers to the parallel runner. let (tx_in, rx_in) = mpsc::channel(); // We create the channel to communicate from the parallel runner to the workers. let (tx_out, rx_out) = mpsc::channel(); let rx_out = Arc::new(Mutex::new(rx_out)); // Create the workers: let workers = (0..workers_count) .map(|i| { let worker = Worker::new(WorkerId::from(i), &tx_in, &rx_out); let state = WorkerState::Idle; (worker, state) }) .collect::>(); let mode = Mode::new(test, progress_bar); let progress = ParProgress::new(MAX_RUNNING_DISPLAYED, mode, color, max_width); ParallelRunner { workers, tx: Some(tx_out), rx: rx_in, progress, output_type, repeat, } } /// Runs a list of [`Job`] in parallel and returns the results. /// /// Results are returned ordered by the sequence number, and not their execution order. So, the /// order of the `jobs` is the same as the order of the `jobs` results, independently of the /// worker's count. pub fn run(&mut self, jobs: &[Job]) -> Result, JobError> { // The parallel runner runs on the main thread. It's responsible for displaying standard // output and standard error. Workers are buffering their output and error in memory, and // delegate the display to the runners. let mut stdout = Stdout::new(WriteMode::Immediate); let mut stderr = Stderr::new(WriteMode::Immediate); // Create the jobs queue: let mut queue = JobQueue::new(jobs, self.repeat); let jobs_count = queue.jobs_count(); // Initiate the runner, fill our workers: self.workers.iter().for_each(|_| { if let Some(job) = queue.next() { _ = self.tx.as_ref().unwrap().send(job); } }); // When dumped HTTP responses, we truncate existing output file on first save, then append // it on subsequent write. let mut append = false; // Start the message pump: let mut results = vec![]; for msg in self.rx.iter() { match msg { // If we have any error (either a [`WorkerMessage::IOError`] or a [`WorkerMessage::ParsingError`] // we don't take any more jobs and exit from the methods in error. This is the same // behaviour as when we run sequentially a list of Hurl files. WorkerMessage::IOError(msg) => { self.progress.clear_progress_bar(&mut stderr); let filename = msg.job.filename; let error = msg.error; let message = format!("Issue reading from {filename}: {error}"); return Err(JobError::IO(message)); } WorkerMessage::ParsingError(msg) => { // Like [`hurl::runner::run`] method, the display of parsing error is done here // instead of being done in [`hurl::run_par`] method. self.progress.clear_progress_bar(&mut stderr); stderr.eprint(msg.stderr.buffer()); return Err(JobError::Parsing); } // Everything is OK, we report the progress. As we can receive a lot of running // messages, we don't want to update the progress bar too often to avoid flickering. WorkerMessage::Running(msg) => { self.workers[msg.worker_id.0].1 = WorkerState::Running { job: msg.job, entry_index: msg.entry_index, entry_count: msg.entry_count, }; if self.progress.can_update() { self.progress.clear_progress_bar(&mut stderr); self.progress.update_progress_bar( &self.workers, results.len(), jobs_count, &mut stderr, ); } } // A new job has been completed, we take a new job if the queue is not empty. // Contrary to when we receive a running message, we clear the progress bar no // matter what the frequency is, to get a "correct" and up-to-date display on any // test completion. WorkerMessage::Completed(msg) => { self.progress.clear_progress_bar(&mut stderr); // The worker is becoming idle. self.workers[msg.worker_id.0].1 = WorkerState::Idle; // First, we display the job standard error, then the job standard output // (similar to the sequential runner). if !msg.stderr.buffer().is_empty() { stderr.eprint(msg.stderr.buffer()); } if !msg.stdout.buffer().is_empty() { let ret = stdout.write_all(msg.stdout.buffer()); if ret.is_err() { return Err(JobError::IO("Issue writing to stdout".to_string())); } } // Then, we print job output on standard output (the first response truncates // exiting file, subsequent response appends bytes). self.print_output(&msg.result, &mut stdout, append)?; append = true; // Report the completion of this job and update the progress. self.progress.print_completed(&msg.result, &mut stderr); results.push(msg.result); self.progress.update_progress_bar( &self.workers, results.len(), jobs_count, &mut stderr, ); // We want to force the next refresh of the progress bar (when we receive a // running message) to be sure that the new next jobs will be printed. This // is needed because we've a throttle on the progress bar refresh and not every // running messages received leads to a progress bar refresh. self.progress.force_next_update(); // We run the next job to process: let job = queue.next(); match job { Some(job) => { _ = self.tx.as_ref().unwrap().send(job); } None => { // If we have received all the job results, we can stop the run. if let Some(jobs_count) = jobs_count { if results.len() == jobs_count { break; } } } } } } } // We gracefully shut down workers, by dropping the sender and wait for each thread workers // to join. drop(self.tx.take()); for worker in &mut self.workers { if let Some(thread) = worker.0.take_thread() { thread.join().unwrap(); } } // All jobs have been executed, we sort results by sequence number to get the same order // as the input jobs list. results.sort_unstable_by_key(|result| result.job.seq); Ok(results) } /// Prints a job `result` to standard output `stdout`, either as a raw HTTP response (last /// body of the run), or in a structured JSON way. /// If `append` is true, any existing file will be appended instead of being truncated. fn print_output( &self, result: &JobResult, stdout: &mut Stdout, append: bool, ) -> Result<(), JobError> { let job = &result.job; let content = &result.content; let hurl_result = &result.hurl_result; let filename_in = &job.filename; let filename_out = job.runner_options.output.as_ref(); match self.output_type { OutputType::ResponseBody { include_headers, color, } => { if hurl_result.success { let result = output::write_last_body( hurl_result, include_headers, color, filename_out, stdout, append, ); if let Err(e) = result { return Err(JobError::Runtime(e.to_string( &filename_in.to_string(), content, None, OutputFormat::Terminal(color), ))); } } } OutputType::Json => { let result = output::write_json( hurl_result, content, filename_in, filename_out, stdout, append, ); if let Err(e) = result { return Err(JobError::Runtime(e.to_string())); } } OutputType::NoOutput => {} } Ok(()) } } hurl-6.1.1/src/parallel/worker.rs000064400000000000000000000130751046102023000150530ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::sync::mpsc::{Receiver, Sender}; use std::sync::{Arc, Mutex}; use std::{fmt, thread}; use hurl_core::parser; use crate::parallel::job::{Job, JobResult}; use crate::parallel::message::{ CompletedMsg, IOErrorMsg, ParsingErrorMsg, RunningMsg, WorkerMessage, }; use crate::runner; use crate::runner::EventListener; use crate::util::logger::Logger; use crate::util::term::{Stderr, Stdout, WriteMode}; /// A worker runs job in its own thread. pub struct Worker { /// The id of this worker. worker_id: WorkerId, /// The thread handle of this worker. thread: Option>, } impl fmt::Display for Worker { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "id: {}", self.worker_id) } } /// Identifier of a worker. #[derive(Copy, Clone, Debug)] pub struct WorkerId(pub usize); impl From for WorkerId { fn from(value: usize) -> Self { WorkerId(value) } } impl fmt::Display for WorkerId { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.0) } } impl Worker { /// Creates a new worker, with id `worker_id`. /// /// The worker spawns a new thread and process [`Job`] sent by the parallel runner through `rx` /// (the receiving part of the `runner -> worker` channel). Worker send message back to the /// runner to update the job progression thorough `tx` (the sending part of the `worker -> runner`. pub fn new( worker_id: WorkerId, tx: &Sender, rx: &Arc>>, ) -> Self { let rx = Arc::clone(rx); let tx = tx.clone(); let thread = thread::spawn(move || loop { let Ok(job) = rx.lock().unwrap().recv() else { return; }; // In parallel execution, standard output and standard error messages are buffered // (in sequential mode, we'll use immediate standard output and error). let mut stdout = Stdout::new(WriteMode::Buffered); let stderr = Stderr::new(WriteMode::Buffered); // We also create a common logger for this run (logger verbosity can eventually be // mutated on each entry). let secrets = job.variables.secrets(); let mut logger = Logger::new(&job.logger_options, stderr, &secrets); // Create a worker progress listener. let progress = WorkerProgress::new(worker_id, &job, &tx); let content = job.filename.read_to_string(); let content = match content { Ok(c) => c, Err(e) => { let msg = IOErrorMsg::new(worker_id, &job, e); _ = tx.send(WorkerMessage::IOError(msg)); return; } }; // Try to parse the content let hurl_file = parser::parse_hurl_file(&content); let hurl_file = match hurl_file { Ok(h) => h, Err(e) => { logger.error_parsing_rich(&content, Some(&job.filename), &e); let msg = ParsingErrorMsg::new(worker_id, &job, &logger.stderr); _ = tx.send(WorkerMessage::ParsingError(msg)); return; } }; // Now, we have a syntactically correct HurlFile instance, we can run it. let result = runner::run_entries( &hurl_file.entries, &content, Some(&job.filename), &job.runner_options, &job.variables, &mut stdout, Some(&progress), &mut logger, ); if result.success && result.entries.last().is_none() { logger.warning(&format!( "No entry have been executed for file {}", job.filename )); } let job_result = JobResult::new(job, content, result); let msg = CompletedMsg::new(worker_id, job_result, stdout, logger.stderr); _ = tx.send(WorkerMessage::Completed(msg)); }); Worker { worker_id, thread: Some(thread), } } /// Takes the thread out of the worker, leaving a None in its place. pub fn take_thread(&mut self) -> Option> { self.thread.take() } } struct WorkerProgress { worker_id: WorkerId, job: Job, tx: Sender, } impl WorkerProgress { fn new(worker_id: WorkerId, job: &Job, tx: &Sender) -> Self { WorkerProgress { worker_id, job: job.clone(), tx: tx.clone(), } } } impl EventListener for WorkerProgress { fn on_running(&self, entry_index: usize, entry_count: usize) { let msg = RunningMsg::new(self.worker_id, &self.job, entry_index, entry_count); _ = self.tx.send(WorkerMessage::Running(msg)); } } hurl-6.1.1/src/report/curl.rs000064400000000000000000000032251046102023000142220ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::fs::OpenOptions; use std::io::Write; use std::path::Path; use crate::report::ReportError; use crate::runner::HurlResult; use crate::util::path::create_dir_all; use crate::util::redacted::Redact; /// Creates a curl export from a list of `hurl_results`. /// /// `secrets` strings are redacted from this export. pub fn write_curl( hurl_results: &[&HurlResult], filename: &Path, secrets: &[&str], ) -> Result<(), ReportError> { if let Err(err) = create_dir_all(filename) { return Err(ReportError::from_error( err, filename, "Issue creating curl export", )); } let mut file = OpenOptions::new() .create(true) .truncate(true) .write(true) .append(false) .open(filename)?; let mut cmds = hurl_results .iter() .flat_map(|h| &h.entries) .map(|e| e.curl_cmd.to_string().redact(secrets)) .collect::>() .join("\n"); cmds.push('\n'); file.write_all(cmds.as_bytes())?; Ok(()) } hurl-6.1.1/src/report/error.rs000064400000000000000000000033531046102023000144100ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::path::{Path, PathBuf}; use std::{fmt, io}; #[derive(Debug)] pub enum ReportError { IO { inner: io::Error, file: PathBuf, message: String, }, Message(String), } impl ReportError { /// Creates a new error instance. pub fn from_string(message: &str) -> Self { ReportError::Message(message.to_string()) } /// Creates a new error instance. pub fn from_error(error: io::Error, file: &Path, message: &str) -> Self { ReportError::IO { inner: error, file: file.to_path_buf(), message: message.to_string(), } } } impl fmt::Display for ReportError { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { ReportError::IO { inner, file, message, } => write!(f, "{message} {} ({inner})", file.display()), ReportError::Message(message) => write!(f, "{message}"), } } } impl From for ReportError { fn from(e: io::Error) -> Self { ReportError::from_string(&e.to_string()) } } hurl-6.1.1/src/report/html/mod.rs000064400000000000000000000026661046102023000150100ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ //! HTML report. mod nav; mod report; mod run; mod source; mod testcase; mod timeline; pub use report::write_report; pub use testcase::Testcase; /// The test result to be displayed in an HTML page #[derive(Clone, Debug, PartialEq, Eq)] struct HTMLResult { /// Original filename, as given in the run execution filename: String, /// The id of the corresponding [`Testcase`] id: String, time_in_ms: u128, success: bool, timestamp: i64, } impl HTMLResult { /// Creates a new HTMLResult from a [`Testcase`]. fn from(testcase: &Testcase) -> Self { HTMLResult { filename: testcase.filename.clone(), id: testcase.id.clone(), time_in_ms: testcase.time_in_ms, success: testcase.success, timestamp: testcase.timestamp, } } } hurl-6.1.1/src/report/html/nav.rs000064400000000000000000000123551046102023000150110ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::SourceInfo; use hurl_core::error::{DisplaySourceError, OutputFormat}; use crate::report::html::Testcase; use crate::runner::RunnerError; use crate::util::redacted::Redact; #[derive(Copy, Clone, Eq, PartialEq)] pub enum Tab { Timeline, Run, Source, } impl Testcase { /// Returns the HTML navigation component for a `tab`. /// This common component is used to get source information and errors. pub fn get_nav_html(&self, content: &str, tab: Tab, secrets: &[&str]) -> String { let status = get_status_html(self.success); let errors = self.get_errors_html(content, secrets); let errors_count = if !self.errors.is_empty() { self.errors.len().to_string() } else { "-".to_string() }; format!( include_str!("resources/nav.html"), duration = self.time_in_ms, errors = errors, errors_count = errors_count, filename = self.filename, href_run = self.run_filename(), href_source = self.source_filename(), href_timeline = self.timeline_filename(), run_selected = tab == Tab::Run, source_selected = tab == Tab::Source, status = status, timeline_selected = tab == Tab::Timeline, ) } /// Formats a list of Hurl errors to HTML snippet. fn get_errors_html(&self, content: &str, secrets: &[&str]) -> String { self.errors .iter() .map(|(error, entry_src_info)| { let error = error_to_html( error, *entry_src_info, content, &self.filename, &self.source_filename(), secrets, ); format!("
{error}
") }) .collect::>() .join("") } } fn get_status_html(success: bool) -> &'static str { if success { "Success" } else { "Failure" } } /// Returns an HTML `
` tag representing this `error`.
fn error_to_html(
    error: &RunnerError,
    entry_src_info: SourceInfo,
    content: &str,
    filename: &str,
    source_filename: &str,
    secrets: &[&str],
) -> String {
    let line = error.source_info.start.line;
    let column = error.source_info.start.column;
    let message = error.to_string(
        filename,
        content,
        Some(entry_src_info),
        OutputFormat::Terminal(false),
    );
    let message = message.redact(secrets);
    let message = html_escape(&message);
    // We override the first part of the error string to add an anchor to
    // the error context.
    let old = format!("{filename}:{line}:{column}");
    let href = source_filename;
    let new = format!("{filename}:{line}:{column}");
    let message = message.replace(&old, &new);
    format!("
{message}
") } /// Escapes '<' and '>' from `text`. fn html_escape(text: &str) -> String { text.replace('<', "<").replace('>', ">") } #[cfg(test)] mod tests { use hurl_core::ast::SourceInfo; use hurl_core::reader::Pos; use crate::report::html::nav::error_to_html; use crate::runner::{RunnerError, RunnerErrorKind}; #[test] fn test_error_html() { let entry_src_info = SourceInfo::new(Pos::new(1, 1), Pos::new(1, 39)); let error = RunnerError::new( SourceInfo::new(Pos::new(4, 1), Pos::new(4, 9)), RunnerErrorKind::AssertFailure { actual: "".to_string(), expected: "Hello world".to_string(), type_mismatch: false, }, true, ); let content = "GET http://localhost:8000/inline-script\n\ HTTP 200\n\ [Asserts]\n\ `Hello World`\n\ "; let filename = "a/b/c/foo.hurl"; let source_filename = "abc-source.hurl"; let html = error_to_html( &error, entry_src_info, content, filename, source_filename, &[], ); assert_eq!( html, r##"
Assert failure
  --> a/b/c/foo.hurl:4:1
   |
   | GET http://localhost:8000/inline-script
   | ...
 4 | `Hello World`
   |   actual:   <script>alert('Hi')</script>
   |   expected: Hello world
   |
"## ); } } hurl-6.1.1/src/report/html/report.rs000064400000000000000000000176221046102023000155420ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::io::Write; use std::path::Path; use chrono::{DateTime, Local}; use crate::report::html::{HTMLResult, Testcase}; use crate::report::ReportError; /// Creates and HTML report for this list of [`Testcase`] at `dir_path`/index.html. /// /// If the report already exists, results are merged. pub fn write_report(dir_path: &Path, testcases: &[Testcase]) -> Result<(), ReportError> { let index_path = dir_path.join("index.html"); let mut results = parse_html(&index_path)?; for testcase in testcases.iter() { let html_result = HTMLResult::from(testcase); results.push(html_result); } let now = Local::now(); let s = create_html_index(&now.to_rfc2822(), &results); let file_path = index_path; let mut file = match std::fs::File::create(&file_path) { Err(err) => { return Err(ReportError::from_error( err, &file_path, "Issue writing HTML report", )) } Ok(file) => file, }; if let Err(err) = file.write_all(s.as_bytes()) { return Err(ReportError::from_error( err, &file_path, "Issue writing HTML report", )); } Ok(()) } /// Returns a standalone HTML report from the list of `hurl_results`. fn create_html_index(now: &str, hurl_results: &[HTMLResult]) -> String { let count_total = hurl_results.len(); let count_failure = hurl_results.iter().filter(|result| !result.success).count(); let count_success = hurl_results.iter().filter(|result| result.success).count(); let percentage_success = percentage(count_success, count_total); let percentage_failure = percentage(count_failure, count_total); let css = include_str!("resources/report.css"); let rows = hurl_results .iter() .map(create_html_table_row) .collect::>() .join(""); format!( include_str!("resources/report.html"), now = now, css = css, count_total = count_total, count_success = count_success, count_failure = count_failure, percentage_success = percentage_success, percentage_failure = percentage_failure, rows = rows, ) } fn parse_html(path: &Path) -> Result, ReportError> { if path.exists() { let s = match std::fs::read_to_string(path) { Ok(s) => s, Err(e) => { return Err(ReportError::from_error( e, path, "Issue reading HTML report", )) } }; Ok(parse_html_report(&s)) } else { Ok(vec![]) } } /// Parses the HTML report `html` an returns a list of [`HTMLResult`]. fn parse_html_report(html: &str) -> Vec { let re = regex::Regex::new( r#"(?x) data-duration="(?P\d+)" \s+ data-status="(?P[a-z]+)" \s+ data-filename="(?P[A-Za-z0-9_./-]+)" \s+ data-id="(?P[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})" (\s+ data-timestamp="(?P[0-9]{1,10})")? "#, ) .unwrap(); // TODO: if the existing HTML report is not valid, we consider that there is no // existing report to append, without displaying any error or warning. Maybe a better option // would be to raise an error here and ask the user to explicitly deal with this error. re.captures_iter(html) .map(|cap| { let filename = cap["filename"].to_string(); let id = cap["id"].to_string(); let time_in_ms = cap["time_in_ms"].to_string().parse().unwrap(); let success = &cap["status"] == "success"; // Older reports won't have this so make it optional let timestamp: i64 = cap .name("timestamp") .map_or(0, |m| m.as_str().parse().unwrap()); HTMLResult { filename, id, time_in_ms, success, timestamp, } }) .collect::>() } fn create_html_table_row(result: &HTMLResult) -> String { let status = if result.success { "success".to_string() } else { "failure".to_string() }; let duration_in_ms = result.time_in_ms; let duration_in_s = result.time_in_ms as f64 / 1000.0; let filename = &result.filename; let displayed_filename = if filename == "-" { "(standard input)" } else { filename }; let id = &result.id; let timestamp = result.timestamp; let displayed_time = if timestamp == 0 { "-".to_string() } else { DateTime::from_timestamp(timestamp, 0) .unwrap() .naive_local() .and_local_timezone(Local) .unwrap() .to_rfc3339() }; format!( r#" {displayed_filename} {status} {displayed_time} {duration_in_s} "# ) } fn percentage(count: usize, total: usize) -> String { format!("{:.1}%", (count as f32 * 100.0) / total as f32) } #[cfg(test)] mod tests { use super::*; #[test] fn test_percentage() { assert_eq!(percentage(100, 100), "100.0%".to_string()); assert_eq!(percentage(66, 99), "66.7%".to_string()); assert_eq!(percentage(33, 99), "33.3%".to_string()); } #[test] fn test_parse_html_report() { let html = r#"

Hurl Report

tests/hello.hurl success 0.1s
tests/failure.hurl failure 2023-10-05T02:37:24Z 0.2s
"#; assert_eq!( parse_html_report(html), vec![ HTMLResult { filename: "tests/hello.hurl".to_string(), id: "08aad14a-8d10-4ecc-892e-a72703c5b494".to_string(), time_in_ms: 100, success: true, timestamp: 0, }, HTMLResult { filename: "tests/failure.hurl".to_string(), id: "a6641ae3-8ce0-4d9f-80c5-3e23e032e055".to_string(), time_in_ms: 200, success: false, timestamp: 1696473444, } ] ); } } hurl-6.1.1/src/report/html/resources/calls.css000064400000000000000000000002701046102023000174720ustar 00000000000000@media (prefers-color-scheme: dark) { .calls-list { fill: #c2c2c2; } .calls-back { fill: #27272c; } .calls-grid rect { fill: #444; } }hurl-6.1.1/src/report/html/resources/nav.css000064400000000000000000000020301046102023000171540ustar 00000000000000.report-nav { margin-top: 20px; margin-bottom: 20px; } .report-nav-links { display: flex; margin-bottom: 20px; font-weight: bold; } .report-nav a { color: royalblue; margin-right: 20px; } .report-nav a[aria-selected="true"] { color: #ff0288; } .report-nav-summary > div { display: flex; } .report-nav-summary .item-name { min-width: 100px; font-weight: bold; } .error { margin-top: 10px; margin-bottom: 10px; border-left: red 4px solid; } .error-desc { background: #f5f5f5; } .error-desc pre { font-size: 0.8rem; line-height: 1.2; margin: 0.75rem; padding: 0.8rem; overflow-x: auto; } .error-desc pre code { font-size: 0.8rem; line-height: 1.2; } .success, .success a { color: green; } .failure, .failure a { color: red; } @media (prefers-color-scheme: dark) { .report-nav a { color: #34a7ff; } .report-nav a[aria-selected="true"] { color: #ff0288; } .error-desc { background: #27272c; } } hurl-6.1.1/src/report/html/resources/nav.html000064400000000000000000000015111046102023000173330ustar 00000000000000
Status:
{status}
Duration:
{duration} ms
Errors:
{errors_count}
{errors}
hurl-6.1.1/src/report/html/resources/report.css000064400000000000000000000013521046102023000177110ustar 00000000000000body { font-family: "Helvetica Neue", Arial, sans-serif; font-size: 1.125rem; line-height: 1.4; } .container { max-width: 1200px; width: 100%; margin-left: auto; margin-right: auto; } h2 { color: #ff0288; font-size: 2.5rem; } .summary { margin: 32px 0 32px 0; font-size: 1.25rem; } a { color: royalblue; } @media (prefers-color-scheme: dark) { a { color: #34a7ff; } } .date { margin-bottom: 20px; } td { padding: 4px 8px 4px 0; } thead { font-weight: bold; } .success, .success a { color: green; } .failure, .failure a { color: red; } @media (prefers-color-scheme: dark) { body { background-color: #19191c; color: #c2c2c2; } } hurl-6.1.1/src/report/html/resources/report.html000064400000000000000000000013151046102023000200640ustar 00000000000000Test Report

Report

{now}
Executed: {count_total} (100%)
Succeeded: {count_success} ({percentage_success})
Failed: {count_failure} ({percentage_failure})
{rows}
File Status Start Time Duration
hurl-6.1.1/src/report/html/resources/run.css000064400000000000000000000023741046102023000172070ustar 00000000000000body { font-family: "Helvetica Neue", Arial, sans-serif; font-size: 1.125rem; line-height: 1.4; } .container { max-width: 1200px; width: 100%; margin-left: auto; margin-right: auto; } h4:target { color: #ff0288; } table { display: block; font-size: 15px; width: 100%; max-width: 100%; overflow: auto; border-collapse: collapse; margin-top: 16px; margin-bottom: 16px; } th, td { border-width: 1px; border-style: solid; border-color: #ddd; } th { padding: 6px 8px; text-align: left; background: #f5f5f5; } td { padding: 6px 8px; vertical-align: text-top; } .name { width: 120px; font-weight: bold; background: #fbfafd; } .value { width: 800px; word-break: break-all } details { margin-bottom: 20px; } summary { font-size: 1.3rem; line-height: 1.4; font-weight: bold; } @media (prefers-color-scheme: dark) { body { background-color: #19191c; color: #c2c2c2; } th, td { border-color: #444; } th, table tr:nth-child(2n) { background-color: #27272c; } table tr:nth-child(2n+1) { background-color: #19191c; } .name { background-color: #19191c; } } hurl-6.1.1/src/report/html/resources/run.html000064400000000000000000000004041046102023000173530ustar 00000000000000 {filename}
{nav} {run}
hurl-6.1.1/src/report/html/resources/source.css000064400000000000000000000021351046102023000176760ustar 00000000000000body { font-family: "Helvetica Neue", Arial, sans-serif; font-size: 1.125rem; line-height: 1.4; } .line-error { border-bottom: red 2px dashed; } .line-error::after { content: " ⛔️" } .container { max-width: 1200px; width: 100%; margin-left: auto; margin-right: auto; } .source-container { display: flex; padding: 0; border: solid 1px #dcdcde; } .line-numbers { text-align: right; padding: 8px 10px; border-right: solid 1px #dcdcde; background: #fbfafd; } .line-numbers a { color: #89888d; text-decoration: none; } .line-numbers a:hover { text-decoration: underline; } .source { padding: 8px 10px; overflow: auto; overflow-y: hidden; } @media (prefers-color-scheme: dark) { body { background-color: #19191c; color: #c2c2c2; } .source-container { border-color: #444; background-color: #27272c; } .line-numbers { padding: 8px 10px; border-right-color: #444; background: #19191c; } .line-numbers a { color: dimgray; } } hurl-6.1.1/src/report/html/resources/source.html000064400000000000000000000006551046102023000200570ustar 00000000000000 {filename}
{nav}
{lines_div}
{source_div}
hurl-6.1.1/src/report/html/resources/timeline.css000064400000000000000000000013101046102023000201760ustar 00000000000000body { font-family: "Helvetica Neue", Arial, sans-serif; font-size: 1.125rem; line-height: 1.4; } .container { max-width: 1200px; width: 100%; margin-left: auto; margin-right: auto; } .timeline-container { max-width: 1400px; width: 100%; margin-left: auto; margin-right: auto; border: solid 1px #dcdcde; display: flex; } .calls { position: sticky; left: 0; right: 0; width: 260px; flex-shrink: 0; } .waterfall { overflow: auto; overflow-y: hidden; } @media (prefers-color-scheme: dark) { body { background-color: #19191c; color: #c2c2c2; } .timeline-container { border-color: #444; } } hurl-6.1.1/src/report/html/resources/timeline.html000064400000000000000000000006051046102023000203600ustar 00000000000000 {filename}
{nav}
{calls}
{waterfall}
hurl-6.1.1/src/report/html/resources/waterfall.css000064400000000000000000000007131046102023000203570ustar 00000000000000.call-detail { display: none; } .call-summary:hover + .call-detail, .call-detail:hover { display: block; } .call-sel { pointer-events: none; } @media (prefers-color-scheme: dark) { .grid-strip rect { fill: #27272c; } .grid-ticks { stroke: #444; } .call-back { stroke: #444; fill: #19191c; } .call-sel { opacity: 0.1; } .call-legend { fill: #c2c2c2; } }hurl-6.1.1/src/report/html/run.rs000064400000000000000000000141621046102023000150270ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::HurlFile; use crate::http::Call; use crate::report::html::nav::Tab; use crate::report::html::Testcase; use crate::runner::EntryResult; use crate::util::redacted::Redact; impl Testcase { /// Creates an HTML view of a run (HTTP status code, response header etc...) pub fn get_run_html( &self, hurl_file: &HurlFile, content: &str, entries: &[EntryResult], secrets: &[&str], ) -> String { let nav = self.get_nav_html(content, Tab::Run, secrets); let nav_css = include_str!("resources/nav.css"); let run_css = include_str!("resources/run.css"); let mut run = String::new(); for (entry_index, e) in entries.iter().enumerate() { let entry_src_index = e.entry_index - 1; let entry_src = hurl_file.entries.get(entry_src_index).unwrap(); let line = entry_src.source_info().start.line; let source = self.source_filename(); run.push_str("
"); let info = get_entry_html(e, entry_index + 1, secrets); run.push_str(&info); for (call_index, c) in e.calls.iter().enumerate() { let info = get_call_html( c, entry_index + 1, call_index + 1, &self.filename, &source, line, secrets, ); run.push_str(&info); } run.push_str("
"); } format!( include_str!("resources/run.html"), filename = self.filename, nav = nav, nav_css = nav_css, run = run, run_css = run_css, ) } } /// Returns an HTML view of an `entry` information as HTML (title, `entry_index` and captures). fn get_entry_html(entry: &EntryResult, entry_index: usize, secrets: &[&str]) -> String { let mut text = String::new(); text.push_str(&format!("Entry {entry_index}")); let cmd = entry.curl_cmd.to_string().redact(secrets); let table = new_table("Debug", &[("Command", &cmd)]); text.push_str(&table); if !entry.captures.is_empty() { let mut values = entry .captures .iter() .map(|c| (&c.name, c.value.to_string().redact(secrets))) .collect::>(); values.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); let table = new_table("Captures", &values); text.push_str(&table); } text } /// Returns an HTML view of a `call` (source file, request and response headers, certificate etc...) fn get_call_html( call: &Call, entry_index: usize, call_index: usize, filename: &str, source: &str, line: usize, secrets: &[&str], ) -> String { let mut text = String::new(); let id = format!("e{entry_index}:c{call_index}"); text.push_str(&format!("

Call {call_index}

")); // General let status = call.response.status.to_string(); let version = call.response.version.to_string(); let url = &call.request.url.to_string().redact(secrets); let url = format!("{url}"); let source = format!("{filename}:{line}"); let values = vec![ ("Request URL", url.as_str()), ("Request Method", call.request.method.as_str()), ("Version", version.as_str()), ("Status code", status.as_str()), ("Source", source.as_str()), ]; let table = new_table("General", &values); text.push_str(&table); // Certificate if let Some(certificate) = &call.response.certificate { let start_date = certificate.start_date.to_string(); let end_date = certificate.expire_date.to_string(); let values = vec![ ("Subject", certificate.subject.as_str()), ("Issuer", certificate.issuer.as_str()), ("Start Date", start_date.as_str()), ("Expire Date", end_date.as_str()), ("Serial Number", certificate.serial_number.as_str()), ]; let table = new_table("Certificate", &values); text.push_str(&table); } let mut values = call .request .headers .iter() .map(|h| (h.name.as_str(), h.value.redact(secrets))) .collect::>(); values.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); let table = new_table("Request Headers", &values); text.push_str(&table); let mut values = call .response .headers .iter() .map(|h| (h.name.as_str(), h.value.redact(secrets))) .collect::>(); values.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase())); let table = new_table("Response Headers", &values); text.push_str(&table); text } /// Returns an HTML table with a `title` and a list of key/values. Values are redacted using `secrets`. fn new_table, U: AsRef + std::fmt::Display>( title: &str, data: &[(T, U)], ) -> String { let mut text = String::new(); text.push_str(&format!( "" )); data.iter().for_each(|(name, value)| { text.push_str(&format!( "", name.as_ref(), value )); }); text.push_str("
{title}
{}{}
"); text } hurl-6.1.1/src/report/html/source.rs000064400000000000000000000126771046102023000155340ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::{HurlFile, SourceInfo}; use lazy_static::lazy_static; use regex::{Captures, Regex}; use crate::report::html::nav::Tab; use crate::report::html::Testcase; use crate::runner::RunnerError; impl Testcase { /// Returns the HTML string of the Hurl source file (syntax colored and errors). pub fn get_source_html(&self, hurl_file: &HurlFile, content: &str, secrets: &[&str]) -> String { let nav = self.get_nav_html(content, Tab::Source, secrets); let nav_css = include_str!("resources/nav.css"); let source_div = hurl_core::format::format_html(hurl_file, false); let source_div = underline_errors(&source_div, &self.errors); let lines_div = get_numbered_lines(content); let source_css = include_str!("resources/source.css"); let hurl_css = hurl_core::format::hurl_css(); format!( include_str!("resources/source.html"), filename = self.filename, hurl_css = hurl_css, lines_div = lines_div, nav = nav, nav_css = nav_css, source_div = source_div, source_css = source_css, ) } } /// Returns a list of lines number in HTML. fn get_numbered_lines(content: &str) -> String { let mut lines = content .lines() .enumerate() .fold("
".to_string(), |acc, (count, _)| -> String {
                let line = count + 1;
                acc + format!("{line}\n").as_str()
            });
    lines.push_str("
"); lines } lazy_static! { static ref LINES_RE: Regex = Regex::new("").unwrap(); } /// Adds error class to `content` lines that triggers `errors`. fn underline_errors(content: &str, errors: &[(RunnerError, SourceInfo)]) -> String { // In nutshell, we're replacing line `...` // with `...`. let mut line = 0; let error_lines = errors .iter() .map(|(error, _)| error.source_info.start.line - 1) .collect::>(); LINES_RE .replace_all(content, |_: &Captures| { let str = if error_lines.contains(&line) { "" } else { "" }; line += 1; str }) .to_string() } #[cfg(test)] mod tests { use hurl_core::ast::SourceInfo; use hurl_core::reader::Pos; use super::*; use crate::runner::RunnerErrorKind::QueryHeaderNotFound; #[test] fn add_underlined_errors() { let content = r#"
            
                
                    
                        
                            GET http://foo.com
                        
                        
                            x-bar: baz
                        
                    
                    
                        
                            HTTP 200
                        
                    
                
                
                
            
        
"#; let underlined_content = r#"
            
                
                    
                        
                            GET http://foo.com
                        
                        
                            x-bar: baz
                        
                    
                    
                        
                            HTTP 200
                        
                    
                
                
                
            
        
"#; let error = RunnerError { source_info: SourceInfo::new(Pos::new(2, 1), Pos::new(2, 4)), kind: QueryHeaderNotFound, assert: true, }; let entry_src_info = SourceInfo::new(Pos::new(1, 1), Pos::new(1, 18)); let errors = [(error, entry_src_info)]; assert_eq!(underlined_content, underline_errors(content, &errors)); } } hurl-6.1.1/src/report/html/testcase.rs000064400000000000000000000071601046102023000160360ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::fs; use std::path::Path; use hurl_core::ast::SourceInfo; use hurl_core::input::Input; use hurl_core::parser; use uuid::Uuid; use crate::runner::{EntryResult, HurlResult, RunnerError}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct Testcase { /// Unique identifier of this testcase. pub id: String, /// Source file name. pub filename: String, pub success: bool, pub time_in_ms: u128, /// The runtime errors, and the source information of the entry throwing this error. pub errors: Vec<(RunnerError, SourceInfo)>, pub timestamp: i64, } impl Testcase { /// Creates an HTML testcase. pub fn from(hurl_result: &HurlResult, filename: &Input) -> Testcase { let id = Uuid::new_v4(); let errors = hurl_result .errors() .into_iter() .map(|(error, entry_src_info)| (error.clone(), entry_src_info)) .collect(); Testcase { id: id.to_string(), filename: filename.to_string(), time_in_ms: hurl_result.duration.as_millis(), success: hurl_result.success, errors, timestamp: hurl_result.timestamp, } } /// Exports a [`Testcase`] to HTML in the directory `dir`. /// /// It will create three HTML files: /// - an HTML view of the Hurl source file (with potential errors and syntax colored), /// - an HTML timeline view of the executed entries (with potential errors, waterfall) /// - an HTML view of the executed run (headers, cookies, etc...) /// /// `secrets` strings are redacted from the produced HTML. pub fn write_html( &self, content: &str, entries: &[EntryResult], dir: &Path, secrets: &[&str], ) -> Result<(), crate::report::ReportError> { // We parse the content as we'll reuse the AST to construct the HTML source file, and // the waterfall. // TODO: for the moment, we can only have parseable file. let hurl_file = parser::parse_hurl_file(content).unwrap(); // We create the timeline view. let output_file = dir.join(self.timeline_filename()); let html = self.get_timeline_html(&hurl_file, content, entries, secrets); fs::write(output_file, html.as_bytes())?; // Then create the run view. let output_file = dir.join(self.run_filename()); let html = self.get_run_html(&hurl_file, content, entries, secrets); fs::write(output_file, html.as_bytes())?; // And create the source view. let output_file = dir.join(self.source_filename()); let html = self.get_source_html(&hurl_file, content, secrets); fs::write(output_file, html.as_bytes())?; Ok(()) } pub fn source_filename(&self) -> String { format!("{}-source.html", self.id) } pub fn timeline_filename(&self) -> String { format!("{}-timeline.html", self.id) } pub fn run_filename(&self) -> String { format!("{}-run.html", self.id) } } hurl-6.1.1/src/report/html/timeline/calls.rs000064400000000000000000000145031046102023000171260ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::iter::zip; use crate::http::Call; use crate::report::html::timeline::svg::Attribute::{ Class, Fill, FontFamily, FontSize, Height, Href, TextDecoration, ViewBox, Width, X, Y, }; use crate::report::html::timeline::svg::{Element, ElementKind}; use crate::report::html::timeline::unit::{Pixel, Px}; use crate::report::html::timeline::util::{ new_failure_icon, new_retry_icon, new_success_icon, trunc_str, }; use crate::report::html::timeline::{svg, CallContext, CallContextKind, CALL_HEIGHT}; use crate::report::html::Testcase; use crate::util::redacted::Redact; impl Testcase { /// Returns a SVG view of `calls` list using contexts `call_ctxs`. pub fn get_calls_svg( &self, calls: &[&Call], call_ctxs: &[CallContext], secrets: &[&str], ) -> String { let margin_top = 50.px(); let margin_bottom = 250.px(); let call_height = 24.px(); let width = 260.px(); let height = call_height * calls.len() + margin_top + margin_bottom; let height = Pixel::max(100.px(), height); let mut root = svg::new_svg(); root.add_attr(ViewBox(0.0, 0.0, width.0, height.0)); root.add_attr(Width(width.0.to_string())); root.add_attr(Height(height.0.to_string())); // Add styles, symbols for success and failure icons: let elt = svg::new_style(include_str!("../resources/calls.css")); root.add_child(elt); let symbol = new_success_icon("success"); root.add_child(symbol); let symbol = new_failure_icon("failure"); root.add_child(symbol); let symbol = new_retry_icon("retry"); root.add_child(symbol); // Add a flat background. let mut elt = Element::new(ElementKind::Rect); elt.add_attr(Class("calls-back".to_string())); elt.add_attr(X(0.0)); elt.add_attr(Y(0.0)); elt.add_attr(Width("100%".to_string())); elt.add_attr(Height("100%".to_string())); elt.add_attr(Fill("#fbfafd".to_string())); root.add_child(elt); if !calls.is_empty() { // Add horizontal lines let x = 0.px(); let y = margin_top; let elt = new_grid(calls, y, width, height); root.add_child(elt); // Add calls info let elt = new_calls(calls, call_ctxs, x, y, secrets); root.add_child(elt); } root.to_string() } } /// Returns an SVG view of a list of `call`. /// For instance: /// /// `✅ GET www.google.fr 303 ` fn new_calls( calls: &[&Call], call_ctxs: &[CallContext], offset_x: Pixel, offset_y: Pixel, secrets: &[&str], ) -> Element { let mut group = svg::new_group(); group.add_attr(Class("calls-list".to_string())); group.add_attr(FontSize("13px".to_string())); group.add_attr(FontFamily("sans-serif".to_string())); group.add_attr(Fill("#777".to_string())); let margin_left = 13.px(); zip(calls, call_ctxs) .enumerate() .for_each(|(index, (call, call_ctx))| { let mut x = offset_x + margin_left; let y = offset_y + (CALL_HEIGHT * index) + CALL_HEIGHT - 7.px(); // Icon success / failure let mut elt = svg::new_use(); let icon = match call_ctx.kind { CallContextKind::Success => "#success", CallContextKind::Failure => "#failure", CallContextKind::Retry => "#retry", }; elt.add_attr(Href(icon.to_string())); elt.add_attr(X(x.0 - 6.0)); elt.add_attr(Y(y.0 - 11.0)); elt.add_attr(Width("13".to_string())); elt.add_attr(Height("13".to_string())); group.add_child(elt); x += 12.px(); // URL let url = &call.request.url.to_string().redact(secrets); let url = url.strip_prefix("http://").unwrap_or(url); let url = url.strip_prefix("https://").unwrap_or(url); let text = format!("{} {url}", call.request.method); let text = trunc_str(&text, 24); let mut elt = svg::new_text(x.0, y.0, &text); if call_ctx.kind == CallContextKind::Failure { elt.add_attr(Fill("red".to_string())); } group.add_child(elt); // Status code x += 180.px(); let text = format!("{}", call.response.status); let mut elt = svg::new_text(x.0, y.0, &text); if call_ctx.kind == CallContextKind::Failure { elt.add_attr(Fill("red".to_string())); } group.add_child(elt); // Source x += 28.px(); let href = format!( "{}#e{}:c{}", call_ctx.run_filename, call_ctx.entry_index, call_ctx.call_entry_index ); let mut a = svg::new_a(&href); let mut text = svg::new_text(x.0, y.0, "run"); text.add_attr(Fill("royalblue".to_string())); text.add_attr(TextDecoration("underline".to_string())); a.add_child(text); group.add_child(a); }); group } /// Returns a SVG view of the grid calls. fn new_grid(calls: &[&Call], offset_y: Pixel, width: Pixel, height: Pixel) -> Element { let mut group = svg::new_group(); group.add_attr(Class("calls-grid".to_string())); let nb_lines = 2 * (calls.len() / 2) + 2; (0..nb_lines).for_each(|index| { let y = CALL_HEIGHT * index + offset_y - (index % 2).px(); let elt = svg::new_rect(0.0, y.0, width.0, 1.0, "#ddd"); group.add_child(elt); }); // Right borders: let elt = svg::new_rect(width.0 - 1.0, 0.0, 1.0, height.0, "#ddd"); group.add_child(elt); group } hurl-6.1.1/src/report/html/timeline/mod.rs000064400000000000000000000110631046102023000166050ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::HurlFile; use crate::http::Call; use crate::report::html::nav::Tab; use crate::report::html::timeline::unit::Pixel; use crate::report::html::Testcase; use crate::runner::EntryResult; mod calls; mod nice; mod svg; mod unit; mod util; mod waterfall; /// Some common constants used to construct our SVG timeline. const CALL_HEIGHT: Pixel = Pixel(24.0); const CALL_INSET: Pixel = Pixel(3.0); #[derive(Copy, Clone, Eq, PartialEq)] pub enum CallContextKind { Success, // call context parent entry is successful Failure, // call context parent entry is in error and has not been retried Retry, // call context parent entry is in error and has been retried } /// A structure that holds information to construct a SVG view /// of a [`Call`] pub struct CallContext { pub kind: CallContextKind, // If the parent entry is successful, retried or in error. pub line: usize, // Line number of the source entry (1-based) pub entry_index: usize, // Index of the runtime EntryResult pub call_entry_index: usize, // Index of the runtime Call in the current entry pub call_index: usize, // Index of the runtime Call in the whole run pub source_filename: String, pub run_filename: String, } impl Testcase { /// Returns the HTML timeline of these `entries`. /// The AST `hurl_file` is used to construct URL with line numbers to the corresponding /// entry in the colored HTML source file. pub fn get_timeline_html( &self, hurl_file: &HurlFile, content: &str, entries: &[EntryResult], secrets: &[&str], ) -> String { let calls = entries .iter() .flat_map(|e| &e.calls) .collect::>(); let call_ctxs = self.get_call_contexts(hurl_file, entries); let timeline_css = include_str!("../resources/timeline.css"); let nav = self.get_nav_html(content, Tab::Timeline, secrets); let nav_css = include_str!("../resources/nav.css"); let calls_svg = self.get_calls_svg(&calls, &call_ctxs, secrets); let waterfall_svg = self.get_waterfall_svg(&calls, &call_ctxs, secrets); format!( include_str!("../resources/timeline.html"), calls = calls_svg, filename = self.filename, nav = nav, nav_css = nav_css, timeline_css = timeline_css, waterfall = waterfall_svg, ) } /// Constructs a list of call contexts to record source line code, runtime entry and call indices. fn get_call_contexts(&self, hurl_file: &HurlFile, entries: &[EntryResult]) -> Vec { let mut calls_ctx = vec![]; for (entry_index, e) in entries.iter().enumerate() { let next_e = entries.get(entry_index + 1); let retry = match next_e { None => false, // last entry of the whole run can't be retried Some(next_e) => e.entry_index == next_e.entry_index, }; let kind = match (e.errors.is_empty(), retry) { (true, _) => CallContextKind::Success, (false, true) => CallContextKind::Retry, (false, false) => CallContextKind::Failure, }; for (call_entry_index, _) in e.calls.iter().enumerate() { let entry_src_index = e.entry_index - 1; let entry_src = hurl_file.entries.get(entry_src_index).unwrap(); let line = entry_src.source_info().start.line; let ctx = CallContext { kind, line, entry_index: entry_index + 1, call_entry_index: call_entry_index + 1, call_index: calls_ctx.len() + 1, source_filename: self.source_filename(), run_filename: self.run_filename(), }; calls_ctx.push(ctx); } } calls_ctx } } hurl-6.1.1/src/report/html/timeline/nice.rs000064400000000000000000000066461046102023000167570ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ /// A structure that holds "nice" numbers given a minimum and maximum values, and a number of ticks. /// The code is derived from "Graphics Gems, Volume 1" by Andrew S. Glassner /// See: /// - /// - #[derive(Copy, Clone, PartialEq, Debug)] pub struct NiceScale { min_value: f64, max_value: f64, max_ticks: usize, range: f64, tick_spacing: f64, nice_min: f64, nice_max: f64, } impl NiceScale { pub fn new(min_value: f64, max_value: f64, max_ticks: usize) -> Self { let range = max_value - min_value; let range = nice_number(range, false); let tick_spacing = range / ((max_ticks - 1) as f64); let tick_spacing = nice_number(tick_spacing, true); let nice_min = (min_value / tick_spacing).floor() * tick_spacing; let nice_max = (max_value / tick_spacing).ceil() * tick_spacing; NiceScale { min_value, max_value, max_ticks, range, tick_spacing, nice_min, nice_max, } } pub fn get_tick_spacing(&self) -> f64 { self.tick_spacing } } /// Returns a 'nice' number approximately equal to `range`. /// Rounds the number if `round` is true, otherwise take the ceiling. fn nice_number(range: f64, round: bool) -> f64 { let exponent = range.log10().floor() as i32; let fraction = range / 10_f64.powi(exponent); let nice_fraction = if round { if fraction < 1.5 { 1.0 } else if fraction < 3.0 { 2.0 } else if fraction < 7.0 { 5.0 } else { 10.0 } } else if fraction <= 1.0 { 1.0 } else if fraction <= 2.0 { 2.0 } else if fraction <= 5.0 { 5.0 } else { 10.0 }; nice_fraction * 10_f64.powi(exponent) } #[cfg(test)] mod tests { use crate::report::html::timeline::nice::NiceScale; #[test] fn test_nice_scale() { let ns = NiceScale::new(0.0, 500.0, 20); assert_eq!( ns, NiceScale { min_value: 0.0, max_value: 500.0, max_ticks: 20, range: 500.0, tick_spacing: 20.0, nice_min: 0.0, nice_max: 500.0, } ); let ns = NiceScale::new(0.0, 1700.0, 20); assert_eq!( ns, NiceScale { min_value: 0.0, max_value: 1700.0, max_ticks: 20, range: 2000.0, tick_spacing: 100.0, nice_min: 0.0, nice_max: 1700.0, } ); } } hurl-6.1.1/src/report/html/timeline/svg.rs000064400000000000000000000360741046102023000166360ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::fmt; use std::slice::Iter; /// Represents a SVG element. This list is __partial__, and contains only /// elements necessary to Hurl waterfall export. /// See . #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum ElementKind { A, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/a Defs, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs FeDropShadow, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/feDropShadow Filter, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/filter Group, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/g Line, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/line Path, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/path Rect, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/rect Style, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/style Svg, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/svg Symbol, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/symbol Text, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/text Use, // https://developer.mozilla.org/en-US/docs/Web/SVG/Element/use } impl ElementKind { /// Returns the XML tag name of this SVG element. pub fn name(&self) -> &'static str { match self { ElementKind::A => "a", ElementKind::Defs => "defs", ElementKind::Filter => "filter", ElementKind::FeDropShadow => "feDropShadow", ElementKind::Group => "g", ElementKind::Line => "line", ElementKind::Path => "path", ElementKind::Rect => "rect", ElementKind::Style => "style", ElementKind::Svg => "svg", ElementKind::Symbol => "symbol", ElementKind::Text => "text", ElementKind::Use => "use", } } } /// Represents a SVG element of `kind` type. /// SVG elements can have attributes (a list of [`Attribute`]), and children (a list of [`Element`]). /// Optionally, an SVG element can have `content`. #[derive(Clone, Debug, PartialEq)] pub struct Element { kind: ElementKind, attrs: Vec, children: Vec, content: Option, } impl Element { /// Returns a new SVG element of type `kind`. pub fn new(kind: ElementKind) -> Element { Element { kind, attrs: vec![], children: vec![], content: None, } } /// Adds an attribute `attr` to this element. pub fn add_attr(&mut self, attr: Attribute) { self.attrs.push(attr); } /// Returns an iterator over these element's attributes. pub fn attrs(&self) -> Iter<'_, Attribute> { self.attrs.iter() } /// Adds a `child` to this element. pub fn add_child(&mut self, child: Element) { self.children.push(child); } /// Returns an iterator over these element's children. pub fn children(&self) -> Iter<'_, Element> { self.children.iter() } /// Returns [true] if this element has any child, [false] otherwise. pub fn has_children(&self) -> bool { !self.children.is_empty() } /// Returns this element's kind. pub fn kind(&self) -> ElementKind { self.kind } /// Sets the `content` of this element. pub fn set_content(&mut self, content: &str) { self.content = Some(content.to_string()); } /// Returns [true] if this element has content, [false] otherwise. pub fn has_content(&self) -> bool { self.content.is_some() } /// Returns the content if this element or an empty string if this element has no content. pub fn content(&self) -> &str { match &self.content { None => "", Some(e) => e, } } /// Serializes this element to a SVG string. fn to_svg(&self, buffer: &mut String) { let name = self.kind().name(); buffer.push('<'); buffer.push_str(name); if self.kind() == ElementKind::Svg { // Attributes specific to svg push_attr(buffer, "xmlns", "http://www.w3.org/2000/svg"); } for att in self.attrs() { buffer.push(' '); buffer.push_str(&att.to_string()); } if self.has_children() || self.has_content() { buffer.push('>'); for child in self.children() { child.to_svg(buffer); } buffer.push_str(self.content()); buffer.push_str("'); } else { buffer.push_str(" />"); } } } impl fmt::Display for Element { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let mut text = String::new(); self.to_svg(&mut text); f.write_str(&text) } } fn push_attr(f: &mut String, key: &str, value: &str) { f.push_str(&format!(" {key}=\"{value}\"")); } /// SVG elements can be modified using attributes. /// This list of attributes is __partial__ and only includes attributes necessary for Hurl waterfall /// export. See // TODO: fond a better way to represent unit. For the moment X attribute // take a float but X could be "10", "10px", "10%". #[derive(Clone, Debug, PartialEq)] pub enum Attribute { Class(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/class D(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/d DX(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/dx DY(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/dy Fill(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/fill Filter(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/filter FloodOpacity(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/flood-opacity FontFamily(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/font-family FontSize(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/font-size FontWeight(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/font-weight Height(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/height Href(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/href Id(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/id Opacity(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/opacity StdDeviation(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stdDeviation Stroke(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke StrokeWidth(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-width TextDecoration(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/text-decoration ViewBox(f64, f64, f64, f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/viewBox Width(String), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/width X(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/x X1(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/x1 X2(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/x2 Y(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/y Y1(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/y1 Y2(f64), // https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/y2 } impl Attribute { fn name(&self) -> &'static str { match self { Attribute::Class(_) => "class", Attribute::D(_) => "d", Attribute::DX(_) => "dx", Attribute::DY(_) => "dy", Attribute::Fill(_) => "fill", Attribute::Filter(_) => "filter", Attribute::FloodOpacity(_) => "flood-opacity", Attribute::FontFamily(_) => "font-family", Attribute::FontSize(_) => "font-size", Attribute::FontWeight(_) => "font-weight", Attribute::Height(_) => "height", Attribute::Href(_) => "href", Attribute::Id(_) => "id", Attribute::Opacity(_) => "opacity", Attribute::StdDeviation(_) => "stdDeviation", Attribute::Stroke(_) => "stroke", Attribute::StrokeWidth(_) => "stroke-width", Attribute::TextDecoration(_) => "text-decoration", Attribute::ViewBox(_, _, _, _) => "viewBox", Attribute::Width(_) => "width", Attribute::X(_) => "x", Attribute::X1(_) => "x1", Attribute::X2(_) => "x2", Attribute::Y(_) => "y", Attribute::Y1(_) => "y1", Attribute::Y2(_) => "y2", } } } impl fmt::Display for Attribute { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let value = match self { Attribute::Class(value) => value.clone(), Attribute::D(value) => value.clone(), Attribute::DX(value) => value.to_string(), Attribute::DY(value) => value.to_string(), Attribute::Fill(value) => value.clone(), Attribute::Filter(value) => value.clone(), Attribute::FloodOpacity(value) => value.to_string(), Attribute::FontFamily(value) => value.clone(), Attribute::FontSize(value) => value.clone(), Attribute::FontWeight(value) => value.clone(), Attribute::Height(value) => value.to_string(), Attribute::Href(value) => value.to_string(), Attribute::Id(value) => value.clone(), Attribute::Opacity(value) => value.to_string(), Attribute::StdDeviation(value) => value.to_string(), Attribute::Stroke(value) => value.to_string(), Attribute::StrokeWidth(value) => value.to_string(), Attribute::TextDecoration(value) => value.clone(), Attribute::ViewBox(min_x, min_y, width, height) => { format!("{min_x} {min_y} {width} {height}") } Attribute::Width(value) => value.to_string(), Attribute::X(value) => value.to_string(), Attribute::X1(value) => value.to_string(), Attribute::X2(value) => value.to_string(), Attribute::Y(value) => value.to_string(), Attribute::Y1(value) => value.to_string(), Attribute::Y2(value) => value.to_string(), }; f.write_str(&format!("{}=\"{}\"", self.name(), value)) } } /// Returns a new `` element. pub fn new_a(href: &str) -> Element { let mut elt = Element::new(ElementKind::A); elt.add_attr(Attribute::Href(href.to_string())); elt } /// Returns a new `` element. pub fn new_svg() -> Element { Element::new(ElementKind::Svg) } /// Returns a new `` element. pub fn new_group() -> Element { Element::new(ElementKind::Group) } /// Returns a new ` \

My First Heading

\

My first paragraph.

\ \ "; let doc = Document::parse(html, Format::Html).unwrap(); assert_eq!( doc.eval_xpath("string(//h1)").unwrap(), Value::String("My First Heading".to_string()) ); let mut cache = BodyCache::new(); assert!(cache.xml().is_none()); cache.set_xml(doc); let doc = cache.xml().unwrap(); assert_eq!( doc.eval_xpath("string(//h1)").unwrap(), Value::String("My First Heading".to_string()) ); } } hurl-6.1.1/src/runner/capture.rs000064400000000000000000000213421046102023000147160ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::Capture; use crate::http; use crate::runner::cache::BodyCache; use crate::runner::error::{RunnerError, RunnerErrorKind}; use crate::runner::filter::eval_filters; use crate::runner::query::eval_query; use crate::runner::result::CaptureResult; use crate::runner::template::eval_template; use crate::runner::VariableSet; /// Evaluates a `capture` with `variables` map and `http_response`, returns a /// [`CaptureResult`] on success or an [`RunnerError`]. /// /// The `cache` is used to store XML / JSON structured response data and avoid redundant parsing /// operation on the response. pub fn eval_capture( capture: &Capture, variables: &VariableSet, http_response: &http::Response, cache: &mut BodyCache, ) -> Result { let name = eval_template(&capture.name, variables)?; let value = eval_query(&capture.query, variables, http_response, cache)?; let value = match value { None => { return Err(RunnerError::new( capture.query.source_info, RunnerErrorKind::NoQueryResult, false, )); } Some(value) => { let filters = capture .filters .iter() .map(|(_, f)| f.clone()) .collect::>(); match eval_filters(&filters, &value, variables, false)? { None => { return Err(RunnerError::new( capture.query.source_info, RunnerErrorKind::NoQueryResult, false, )); } Some(v) => v, } } }; Ok(CaptureResult { name: name.clone(), value, }) } #[cfg(test)] pub mod tests { use hurl_core::ast::{ LineTerminator, Query, QueryValue, SourceInfo, Template, TemplateElement, Whitespace, }; use hurl_core::reader::Pos; use hurl_core::typing::ToSource; use self::super::super::query; use super::*; use crate::runner::{Number, Value}; pub fn user_count_capture() -> Capture { // non scalar value let whitespace = Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }; Capture { line_terminators: vec![], space0: whitespace.clone(), name: Template::new( None, vec![TemplateElement::String { value: "UserCount".to_string(), source: "UserCount".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), space1: whitespace.clone(), space2: whitespace.clone(), // xpath count(//user) query: query::tests::xpath_count_user_query(), filters: vec![], space3: whitespace.clone(), redact: false, line_terminator0: LineTerminator { space0: whitespace.clone(), comment: None, newline: whitespace, }, } } pub fn duration_capture() -> Capture { // non scalar value let whitespace = Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }; Capture { line_terminators: vec![], space0: whitespace.clone(), name: Template::new( None, vec![TemplateElement::String { value: "duration".to_string(), source: "duration".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), space1: whitespace.clone(), space2: whitespace.clone(), // xpath count(//user) query: query::tests::jsonpath_duration(), filters: vec![], space3: whitespace.clone(), redact: false, line_terminator0: LineTerminator { space0: whitespace.clone(), comment: None, newline: whitespace, }, } } #[test] fn test_invalid_xpath() { let variables = VariableSet::new(); let mut cache = BodyCache::new(); let whitespace = Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }; let capture = Capture { line_terminators: vec![], space0: whitespace.clone(), name: Template::new( None, vec![TemplateElement::String { value: "count".to_string(), source: "count".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), filters: vec![], space1: whitespace.clone(), space2: whitespace.clone(), query: query::tests::xpath_invalid_query(), space3: whitespace.clone(), redact: false, line_terminator0: LineTerminator { space0: whitespace.clone(), comment: None, newline: whitespace, }, }; let error = eval_capture( &capture, &variables, &http::xml_three_users_http_response(), &mut cache, ) .err() .unwrap(); assert_eq!(error.source_info.start, Pos { line: 1, column: 7 }); assert_eq!(error.kind, RunnerErrorKind::QueryInvalidXpathEval); } #[test] fn test_capture_unsupported() { // non scalar value let whitespace = Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }; let _capture = Capture { line_terminators: vec![], space0: whitespace.clone(), name: Template::new( None, vec![TemplateElement::String { value: "???".to_string(), source: "???".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), space1: whitespace.clone(), space2: whitespace.clone(), // xpath //user query: Query { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 13)), value: QueryValue::Xpath { space0: whitespace.clone(), expr: Template::new( Some('"'), vec![TemplateElement::String { value: "//user".to_string(), source: "//user".to_source(), }], SourceInfo::new(Pos::new(1, 7), Pos::new(1, 13)), ), }, }, filters: vec![], space3: whitespace.clone(), redact: false, line_terminator0: LineTerminator { space0: whitespace.clone(), comment: None, newline: whitespace, }, }; } #[test] fn test_capture() { let variables = VariableSet::new(); let mut cache = BodyCache::new(); assert_eq!( eval_capture( &user_count_capture(), &variables, &http::xml_three_users_http_response(), &mut cache, ) .unwrap(), CaptureResult { name: "UserCount".to_string(), value: Value::Number(Number::from(3.0)), } ); assert_eq!( eval_capture( &duration_capture(), &variables, &http::json_http_response(), &mut cache ) .unwrap(), CaptureResult { name: "duration".to_string(), value: Value::Number(Number::from(1.5)), } ); } } hurl-6.1.1/src/runner/diff.rs000064400000000000000000000276211046102023000141710ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::text::{Style, StyledString}; use similar::{ChangeTag, DiffOp, DiffTag, TextDiff}; #[derive(Clone, Debug, PartialEq, Eq)] pub struct DiffHunk { pub content: StyledString, pub start: usize, // 0-based pub source_line: usize, // 0-based } pub fn diff(old: &str, new: &str) -> Vec { let text_diff = TextDiff::from_lines(old, new); let mut unified_diff = text_diff.unified_diff(); let unified_diff = unified_diff.context_radius(0); let mut hunks = vec![]; for hunk in unified_diff.iter_hunks() { let (start, source_line) = get_hunk_lines(hunk.ops()); let mut content = StyledString::new(); for change in hunk.iter_changes() { let sign = match change.tag() { ChangeTag::Delete => "-", ChangeTag::Insert => "+", ChangeTag::Equal => " ", }; let line = format!("{}{}", sign, change); let style = match change.tag() { ChangeTag::Delete => Style::new().red(), ChangeTag::Insert => Style::new().green(), ChangeTag::Equal => Style::new(), }; content.push_with(&line, style); } let hunk = DiffHunk { content, source_line, start, }; hunks.push(hunk); } hunks } /// get start of the hunk and first change in the input source string /// Both are 0-based line number fn get_hunk_lines(ops: &[DiffOp]) -> (usize, usize) { let mut start = 0; for op in ops { match op.tag() { DiffTag::Equal => { start = op.old_range().start; } DiffTag::Delete => return (start, op.old_range().start), DiffTag::Insert => return (start, op.old_range().start - 1), DiffTag::Replace => return (start, op.old_range().start), } } (start, 0) } #[cfg(test)] mod tests { use hurl_core::text::{Style, StyledString}; use super::*; fn old_string() -> String { r#"{ "first_name": "John", "last_name": "Smith", "is_alive": true, "age": 27, "address": { "street_address": "21 2nd Street", "city": "New York", "state": "NY", "postal_code": "10021-3100" }, "phone_numbers": [ { "type": "home", "number": "212 555-1234" }, { "type": "office", "number": "646 555-4567" } ], "children": [ "Catherine", "Thomas", "Trevor" ], "spouse": null } "# .to_string() } fn new_string_change_line1() -> String { r#"[ "first_name": "John", "last_name": "Smith", "is_alive": true, "age": 27, "address": { "street_address": "21 2nd Street", "city": "New York", "state": "NY", "postal_code": "10021-3100" }, "phone_numbers": [ { "type": "home", "number": "212 555-1234" }, { "type": "office", "number": "646 555-4567" } ], "children": [ "Catherine", "Thomas", "Trevor" ], "spouse": null } "# .to_string() } fn new_string_change_line2() -> String { r#"{ "first_name": "Bob", "last_name": "Smith", "is_alive": true, "age": 27, "address": { "street_address": "21 2nd Street", "city": "New York", "state": "NY", "postal_code": "10021-3100" }, "phone_numbers": [ { "type": "home", "number": "212 555-1234" }, { "type": "office", "number": "646 555-4567" } ], "children": [ "Catherine", "Thomas", "Trevor" ], "spouse": null } "# .to_string() } fn new_string_change_line3() -> String { r#"{ "first_name": "John", "last_name": "Smiths", "is_alive": true, "age": 27, "address": { "street_address": "21 2nd Street", "city": "New York", "state": "NY", "postal_code": "10021-3100" }, "phone_numbers": [ { "type": "home", "number": "212 555-1234" }, { "type": "office", "number": "646 555-4567" } ], "children": [ "Catherine", "Thomas", "Trevor" ], "spouse": null } "# .to_string() } fn new_string_change_line4() -> String { r#"{ "first_name": "John", "last_name": "Smith", "is_alive": true, "age": 28, "address": { "street_address": "21 2nd Street", "city": "New York", "state": "NY", "postal_code": "10021-3100" }, "phone_numbers": [ { "type": "home", "number": "212 555-1234" }, { "type": "office", "number": "646 555-4567" } ], "children": [ "Catherine", "Thomas", "Trevor" ], "spouse": null } "# .to_string() } fn new_string_delete_line3() -> String { r#"{ "first_name": "John", "is_alive": true, "age": 27, "address": { "street_address": "21 2nd Street", "city": "New York", "state": "NY", "postal_code": "10021-3100" }, "phone_numbers": [ { "type": "home", "number": "212 555-1234" }, { "type": "office", "number": "646 555-4567" } ], "children": [ "Catherine", "Thomas", "Trevor" ], "spouse": null } "# .to_string() } fn new_string_add_line3() -> String { r#"{ "first_name": "John", "middle_name": "Bob", "last_name": "Smith", "is_alive": true, "age": 27, "address": { "street_address": "21 2nd Street", "city": "New York", "state": "NY", "postal_code": "10021-3100" }, "phone_numbers": [ { "type": "home", "number": "212 555-1234" }, { "type": "office", "number": "646 555-4567" } ], "children": [ "Catherine", "Thomas", "Trevor" ], "spouse": null } "# .to_string() } fn new_string_change_line4_line24() -> String { r#"{ "first_name": "John", "last_name": "Smith", "is_alive": true, "age": 28, "address": { "street_address": "21 2nd Street", "city": "New York", "state": "NY", "postal_code": "10021-3100" }, "phone_numbers": [ { "type": "home", "number": "212 555-1234" }, { "type": "office", "number": "646 555-4567" } ], "children": [ "Catherine", "Thomas", "Bob" ], "spouse": null } "# .to_string() } #[test] fn test_diff_change_line1() { let mut expected_diff_output = StyledString::new(); expected_diff_output.push_with( r#"-{ "#, Style::new().red(), ); expected_diff_output.push_with( r#"+[ "#, Style::new().green(), ); let hunks = diff(&old_string(), &new_string_change_line1()); let first_hunk = hunks.first().unwrap().clone(); assert_eq!(first_hunk.content, expected_diff_output); assert_eq!(first_hunk.start, 0); assert_eq!(first_hunk.source_line, 0); } #[test] fn test_diff_change_line2() { let mut expected_diff_output = StyledString::new(); expected_diff_output.push_with( r#"- "first_name": "John", "#, Style::new().red(), ); expected_diff_output.push_with( r#"+ "first_name": "Bob", "#, Style::new().green(), ); let hunks = diff(&old_string(), &new_string_change_line2()); let first_hunk = hunks.first().unwrap().clone(); assert_eq!(first_hunk.content, expected_diff_output); assert_eq!(first_hunk.start, 1); assert_eq!(first_hunk.source_line, 1); } #[test] fn test_diff_change_line3() { let mut expected_diff_output = StyledString::new(); expected_diff_output.push_with( r#"- "last_name": "Smith", "#, Style::new().red(), ); expected_diff_output.push_with( r#"+ "last_name": "Smiths", "#, Style::new().green(), ); let hunks = diff(&old_string(), &new_string_change_line3()); let first_hunk = hunks.first().unwrap().clone(); assert_eq!(first_hunk.content, expected_diff_output); assert_eq!(first_hunk.start, 2); assert_eq!(first_hunk.source_line, 2); } #[test] fn test_diff_change_line4() { let mut expected_diff_output = StyledString::new(); expected_diff_output.push_with( r#"- "age": 27, "#, Style::new().red(), ); expected_diff_output.push_with( r#"+ "age": 28, "#, Style::new().green(), ); let hunks = diff(&old_string(), &new_string_change_line4()); let first_hunk = hunks.first().unwrap().clone(); assert_eq!(first_hunk.content, expected_diff_output); assert_eq!(first_hunk.start, 4); assert_eq!(first_hunk.source_line, 4); } #[test] fn test_diff_delete_line3() { let mut expected_diff_output = StyledString::new(); expected_diff_output.push_with( r#"- "last_name": "Smith", "#, Style::new().red(), ); let hunks = diff(&old_string(), &new_string_delete_line3()); let first_hunk = hunks.first().unwrap().clone(); assert_eq!(first_hunk.content, expected_diff_output); assert_eq!(first_hunk.start, 2); assert_eq!(first_hunk.source_line, 2); } #[test] fn test_diff_add_line3() { let mut expected_diff_output = StyledString::new(); expected_diff_output.push_with( r#"+ "middle_name": "Bob", "#, Style::new().green(), ); let hunks = diff(&old_string(), &new_string_add_line3()); let first_hunk = hunks.first().unwrap().clone(); assert_eq!(first_hunk.content, expected_diff_output); assert_eq!(first_hunk.start, 2); assert_eq!(first_hunk.source_line, 1); } #[test] fn test_diff_change_line4_line24() { let hunks = diff(&old_string(), &new_string_change_line4_line24()); let first_hunk = hunks.first().unwrap().clone(); let mut expected_diff_output = StyledString::new(); expected_diff_output.push_with( r#"- "age": 27, "#, Style::new().red(), ); expected_diff_output.push_with( r#"+ "age": 28, "#, Style::new().green(), ); assert_eq!(first_hunk.content, expected_diff_output); assert_eq!(first_hunk.start, 4); assert_eq!(first_hunk.source_line, 4); let second_hunk = hunks.get(1).unwrap().clone(); let mut expected_diff_output = StyledString::new(); expected_diff_output.push_with( r#"- "Trevor" "#, Style::new().red(), ); expected_diff_output.push_with( r#"+ "Bob" "#, Style::new().green(), ); assert_eq!(second_hunk.content, expected_diff_output); assert_eq!(second_hunk.start, 24); assert_eq!(second_hunk.source_line, 24); } #[test] fn test_diff_add_new_line() { let hunks = diff("

Hello

\n", "

Hello

\n\n"); let first_hunk = hunks.first().unwrap().clone(); let mut expected_diff_output = StyledString::new(); expected_diff_output.push_with("+\n", Style::new().green()); assert_eq!(first_hunk.content, expected_diff_output); assert_eq!(first_hunk.start, 1); assert_eq!(first_hunk.source_line, 0); } } hurl-6.1.1/src/runner/entry.rs000064400000000000000000000310471046102023000144170ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::{Entry, PredicateFuncValue, Response, SourceInfo}; use crate::http; use crate::http::{ClientOptions, CurlCmd}; use crate::runner::cache::BodyCache; use crate::runner::error::RunnerError; use crate::runner::result::{AssertResult, EntryResult}; use crate::runner::runner_options::RunnerOptions; use crate::runner::{request, response, CaptureResult, RunnerErrorKind, VariableSet}; use crate::util::logger::{Logger, Verbosity}; use crate::util::term::WriteMode; /// Runs an `entry` with `http_client` and returns one [`EntryResult`]. /// /// The `calls` field of the [`EntryResult`] contains a list of HTTP requests and responses that have /// been executed. If `http_client` has been configured to follow redirection, the `calls` list contains /// every step of the redirection for the first to the last. /// `variables` are used to render values at runtime, and can be updated by captures. pub fn run( entry: &Entry, entry_index: usize, http_client: &mut http::Client, variables: &mut VariableSet, runner_options: &RunnerOptions, logger: &mut Logger, ) -> EntryResult { let compressed = runner_options.compressed; let source_info = entry.source_info(); let context_dir = &runner_options.context_dir; // We don't allow creating secrets if the logger is immediate and verbose because, in this case, // network logs have already been written and may have leaked secrets before captures evaluation. // Note: in `--test` mode, the logger is buffered so there is no restriction on logger level. if let Some(response_spec) = &entry.response { let immediate_logs = matches!(logger.stderr.mode(), WriteMode::Immediate) && logger.verbosity.is_some(); if immediate_logs { let redacted = response_spec.captures().iter().find(|c| c.redact); if let Some(redacted) = redacted { let source_info = redacted.name.source_info; let error = RunnerError::new(source_info, RunnerErrorKind::PossibleLoggedSecret, false); return EntryResult { entry_index, source_info, errors: vec![error], compressed, ..Default::default() }; } } } // Evaluates our source requests given our set of variables let http_request = match request::eval_request(&entry.request, variables, context_dir) { Ok(r) => r, Err(error) => { return EntryResult { entry_index, source_info, errors: vec![error], compressed, ..Default::default() }; } }; let client_options = ClientOptions::from(runner_options, logger.verbosity); // Experimental features with cookie storage use std::str::FromStr; if let Some(s) = request::cookie_storage_set(&entry.request) { if let Ok(cookie) = http::Cookie::from_str(s.as_str()) { http_client.add_cookie(&cookie, logger); } else { logger.warning(&format!("Cookie string can not be parsed: '{s}'")); } } if request::cookie_storage_clear(&entry.request) { http_client.clear_cookie_storage(logger); } let curl_cmd = http_client.curl_command_line( &http_request, context_dir, runner_options.output.as_ref(), &client_options, logger, ); log_request(http_client, &curl_cmd, &http_request, logger); // Run the HTTP requests (optionally follow redirection) let calls = match http_client.execute_with_redirect(&http_request, &client_options, logger) { Ok(calls) => calls, Err(http_error) => { let start = entry.request.url.source_info.start; let end = entry.request.url.source_info.end; let error_source_info = SourceInfo::new(start, end); let error = RunnerError::new(error_source_info, RunnerErrorKind::Http(http_error), false); return EntryResult { entry_index, source_info, errors: vec![error], compressed, curl_cmd, ..Default::default() }; } }; // Now, we can compute capture and asserts on the last HTTP request/response chains. let call = calls.last().unwrap(); let http_response = &call.response; // `transfer_duration` represent the network time of calls, not including assert processing. let transfer_duration = calls.iter().map(|call| call.timings.total).sum(); // We proceed asserts and captures in this order: // 1. first, check implicit assert on status and version. If KO, test is failed // 2. then, we compute captures, we might need them in asserts // 3. finally, run the remaining asserts let mut cache = BodyCache::new(); let mut asserts = vec![]; if !runner_options.ignore_asserts { if let Some(response_spec) = &entry.response { let mut status_asserts = response::eval_version_status_asserts(response_spec, http_response); let errors = asserts_to_errors(&status_asserts); asserts.append(&mut status_asserts); if !errors.is_empty() { logger.debug(""); return EntryResult { entry_index, source_info, calls, captures: vec![], asserts, errors, transfer_duration, compressed, curl_cmd, }; } } }; let captures = match &entry.response { None => vec![], Some(response_spec) => { match response::eval_captures(response_spec, http_response, &mut cache, variables) { Ok(captures) => captures, Err(e) => { return EntryResult { entry_index, source_info, calls, captures: vec![], asserts, errors: vec![e], transfer_duration, compressed, curl_cmd, }; } } } }; // After captures evaluation, we update the logger with secrets from the variable set. The variable // set can have been updated with new secrets to redact. logger.set_secrets(variables.secrets()); log_captures(&captures, logger); logger.debug(""); // Compute asserts if !runner_options.ignore_asserts { if let Some(response_spec) = &entry.response { warn_deprecated(response_spec, logger); let mut other_asserts = response::eval_asserts( response_spec, variables, http_response, &mut cache, context_dir, ); asserts.append(&mut other_asserts); } }; let errors = asserts_to_errors(&asserts); EntryResult { entry_index, source_info, calls, captures, asserts, errors, transfer_duration, compressed, curl_cmd, } } /// Converts a list of [`AssertResult`] to a list of [`RunnerError`]. fn asserts_to_errors(asserts: &[AssertResult]) -> Vec { asserts .iter() .filter_map(|assert| assert.error()) .map( |RunnerError { source_info, kind: inner, .. }| RunnerError::new(source_info, inner, true), ) .collect() } impl ClientOptions { fn from(runner_options: &RunnerOptions, verbosity: Option) -> Self { ClientOptions { allow_reuse: runner_options.allow_reuse, aws_sigv4: runner_options.aws_sigv4.clone(), cacert_file: runner_options.cacert_file.clone(), client_cert_file: runner_options.client_cert_file.clone(), client_key_file: runner_options.client_key_file.clone(), compressed: runner_options.compressed, connect_timeout: runner_options.connect_timeout, connects_to: runner_options.connects_to.clone(), cookie_input_file: runner_options.cookie_input_file.clone(), follow_location: runner_options.follow_location, follow_location_trusted: runner_options.follow_location_trusted, headers: runner_options.headers.clone(), http_version: runner_options.http_version, ip_resolve: runner_options.ip_resolve, max_filesize: runner_options.max_filesize, max_recv_speed: runner_options.max_recv_speed, max_redirect: runner_options.max_redirect, max_send_speed: runner_options.max_send_speed, netrc: runner_options.netrc, netrc_file: runner_options.netrc_file.clone(), netrc_optional: runner_options.netrc_optional, path_as_is: runner_options.path_as_is, proxy: runner_options.proxy.clone(), no_proxy: runner_options.no_proxy.clone(), insecure: runner_options.insecure, resolves: runner_options.resolves.clone(), ssl_no_revoke: runner_options.ssl_no_revoke, timeout: runner_options.timeout, unix_socket: runner_options.unix_socket.clone(), user: runner_options.user.clone(), user_agent: runner_options.user_agent.clone(), verbosity: match verbosity { Some(Verbosity::Verbose) => Some(http::Verbosity::Verbose), Some(Verbosity::VeryVerbose) => Some(http::Verbosity::VeryVerbose), _ => None, }, } } } /// Logs this HTTP `request`. fn log_request( http_client: &mut http::Client, curl_cmd: &CurlCmd, request: &http::RequestSpec, logger: &mut Logger, ) { logger.debug(""); logger.debug_important("Cookie store:"); for cookie in &http_client.cookie_storage(logger) { logger.debug(&cookie.to_string()); } logger.debug(""); logger.debug_important("Request:"); logger.debug(&format!("{} {}", request.method, request.url.raw())); for header in &request.headers { logger.debug(&header.to_string()); } if !request.querystring.is_empty() { logger.debug("[QueryStringParams]"); for param in &request.querystring { logger.debug(¶m.to_string()); } } if !request.form.is_empty() { logger.debug("[FormParams]"); for param in &request.form { logger.debug(¶m.to_string()); } } if !request.multipart.is_empty() { logger.debug("[MultipartFormData]"); for param in &request.multipart { logger.debug(¶m.to_string()); } } if !request.cookies.is_empty() { logger.debug("[Cookies]"); for cookie in &request.cookies { logger.debug(&cookie.to_string()); } } logger.debug(""); logger.debug("Request can be run with the following curl command:"); logger.debug(&curl_cmd.to_string()); logger.debug(""); } /// Logs the `captures` from the entry HTTP response. fn log_captures(captures: &[CaptureResult], logger: &mut Logger) { if captures.is_empty() { return; } logger.debug_important("Captures:"); for c in captures.iter() { logger.capture(&c.name, &c.value); } } /// Warns some deprecation on this `response`. fn warn_deprecated(response_spec: &Response, logger: &mut Logger) { if response_spec.asserts().iter().any(|a| { matches!( &a.predicate.predicate_func.value, PredicateFuncValue::Include { .. } ) }) { logger.warning(" predicate is now deprecated in favor of predicate"); } } hurl-6.1.1/src/runner/error.rs000064400000000000000000000575071046102023000144200ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::cmp::max; use std::path::PathBuf; use hurl_core::ast::SourceInfo; use hurl_core::error; use hurl_core::error::DisplaySourceError; use hurl_core::text::{Style, StyledString}; use crate::http::HttpError; use crate::runner::diff::DiffHunk; /// Represents a single instance of a runtime error, usually triggered by running a /// [`hurl_core::ast::Entry`]. Running a Hurl content (see [`crate::runner::run`]) returns a list of /// result for each entry. Each entry result can contain a list of [`RunnerError`]. The runtime error variant /// is defined in [`RunnerErrorKind`] #[derive(Clone, Debug, PartialEq, Eq)] pub struct RunnerError { pub source_info: SourceInfo, pub kind: RunnerErrorKind, pub assert: bool, } impl RunnerError { pub fn new(source_info: SourceInfo, kind: RunnerErrorKind, assert: bool) -> RunnerError { RunnerError { source_info, kind, assert, } } } #[derive(Clone, Debug, PartialEq, Eq)] pub enum RunnerErrorKind { AssertBodyDiffError { body_source_info: SourceInfo, hunks: Vec, }, AssertBodyValueError { actual: String, expected: String, }, AssertFailure { actual: String, expected: String, type_mismatch: bool, }, AssertHeaderValueError { actual: String, }, AssertStatus { actual: String, }, AssertVersion { actual: String, }, ExpressionInvalidType { value: String, expecting: String, }, /// I/O read error on `path`. FileReadAccess { path: PathBuf, }, /// I/O write error on `path`. FileWriteAccess { path: PathBuf, error: String, }, FilterDecode(String), FilterInvalidEncoding(String), FilterInvalidInput(String), FilterInvalidFormatSpecifier(String), FilterMissingInput, Http(HttpError), InvalidJson { value: String, }, InvalidRegex, InvalidUrl { url: String, message: String, }, NoQueryResult, PossibleLoggedSecret, QueryHeaderNotFound, QueryInvalidJsonpathExpression { value: String, }, QueryInvalidXpathEval, QueryInvalidXml, QueryInvalidJson, ReadOnlySecret { name: String, }, TemplateVariableNotDefined { name: String, }, /// Unauthorized file access, check `--file-root` option. UnauthorizedFileAccess { path: PathBuf, }, /// Only string secrets are supported. UnsupportedSecretType(String), UnrenderableExpression { value: String, }, } /// Textual Output for runner errors impl DisplaySourceError for RunnerError { fn source_info(&self) -> SourceInfo { self.source_info } fn description(&self) -> String { match &self.kind { RunnerErrorKind::AssertBodyDiffError { .. } => "Assert body value".to_string(), RunnerErrorKind::AssertBodyValueError { .. } => "Assert body value".to_string(), RunnerErrorKind::AssertFailure { .. } => "Assert failure".to_string(), RunnerErrorKind::AssertHeaderValueError { .. } => "Assert header value".to_string(), RunnerErrorKind::AssertStatus { .. } => "Assert status code".to_string(), RunnerErrorKind::AssertVersion { .. } => "Assert HTTP version".to_string(), RunnerErrorKind::ExpressionInvalidType { .. } => "Invalid expression type".to_string(), RunnerErrorKind::FileReadAccess { .. } => "File read access".to_string(), RunnerErrorKind::FileWriteAccess { .. } => "File write access".to_string(), RunnerErrorKind::FilterDecode { .. } => "Filter error".to_string(), RunnerErrorKind::FilterInvalidEncoding { .. } => "Filter error".to_string(), RunnerErrorKind::FilterInvalidInput { .. } => "Filter error".to_string(), RunnerErrorKind::FilterInvalidFormatSpecifier { .. } => "Filter error".to_string(), RunnerErrorKind::FilterMissingInput => "Filter error".to_string(), RunnerErrorKind::Http(http_error) => http_error.description(), RunnerErrorKind::InvalidJson { .. } => "Invalid JSON".to_string(), RunnerErrorKind::InvalidUrl { .. } => "Invalid URL".to_string(), RunnerErrorKind::InvalidRegex => "Invalid regex".to_string(), RunnerErrorKind::NoQueryResult => "No query result".to_string(), RunnerErrorKind::PossibleLoggedSecret => "Invalid redacted secret".to_string(), RunnerErrorKind::QueryHeaderNotFound => "Header not found".to_string(), RunnerErrorKind::QueryInvalidJson => "Invalid JSON".to_string(), RunnerErrorKind::QueryInvalidJsonpathExpression { .. } => { "Invalid JSONPath".to_string() } RunnerErrorKind::QueryInvalidXml => "Invalid XML".to_string(), RunnerErrorKind::QueryInvalidXpathEval => "Invalid XPath expression".to_string(), RunnerErrorKind::ReadOnlySecret { .. } => "Readonly secret".to_string(), RunnerErrorKind::TemplateVariableNotDefined { .. } => "Undefined variable".to_string(), RunnerErrorKind::UnauthorizedFileAccess { .. } => { "Unauthorized file access".to_string() } RunnerErrorKind::UnrenderableExpression { .. } => "Unrenderable expression".to_string(), RunnerErrorKind::UnsupportedSecretType(_) => "Invalid secret type".to_string(), } } fn fixme(&self, content: &[&str]) -> StyledString { match &self.kind { // FIXME: this variant can not be called because message doesn't call it // contrary to the default implementation. RunnerErrorKind::AssertBodyDiffError { hunks, .. } => { let mut message = StyledString::new(); for hunk in &hunks[..1] { message.append(hunk.content.clone()); } message } RunnerErrorKind::AssertBodyValueError { actual, .. } => { let message = &format!("actual value is <{actual}>"); let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::AssertFailure { actual, expected, type_mismatch, .. } => { let additional = if *type_mismatch { "\n >>> types between actual and expected are not consistent" } else { "" }; let message = format!(" actual: {actual}\n expected: {expected}{additional}"); color_red_multiline_string(&message) } RunnerErrorKind::AssertHeaderValueError { actual } => { let message = &format!("actual value is <{actual}>"); let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::AssertStatus { actual, .. } => { let message = &format!("actual value is <{actual}>"); let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::AssertVersion { actual, .. } => { let message = &format!("actual value is <{actual}>"); let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::ExpressionInvalidType { value, expecting, .. } => { let message = &format!("expecting {expecting}, actual value is {value}"); let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::FileReadAccess { path } => { let message = &format!("file {} can not be read", path.to_string_lossy()); let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::FileWriteAccess { path, error } => { let message = &format!("{} can not be written ({error})", path.to_string_lossy()); let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::FilterDecode(encoding) => { let message = &format!("value can not be decoded with <{encoding}> encoding"); let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::FilterInvalidEncoding(encoding) => { let message = &format!("<{encoding}> encoding is not supported"); let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::FilterInvalidInput(message) => { let message = &format!("invalid filter input: {message}"); let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::FilterInvalidFormatSpecifier(format) => { let message = &format!("<{format}> format is not supported"); let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::FilterMissingInput => { let message = "missing value to apply filter"; let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::Http(http_error) => { let message = http_error.message(); let message = error::add_carets(&message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::InvalidJson { value } => { let message = &format!("actual value is <{value}>"); let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::InvalidUrl { url, message } => { let message = &format!("invalid URL <{url}> ({message})"); let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::InvalidRegex => { let message = "regex expression is not valid"; let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::NoQueryResult => { let message = "The query didn't return any result"; let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::PossibleLoggedSecret => { let message = "redacted secret not authorized in verbose"; let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::QueryHeaderNotFound => { let message = "this header has not been found in the response"; let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::QueryInvalidJson => { let message = "the HTTP response is not a valid JSON"; let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::QueryInvalidJsonpathExpression { value } => { let message = &format!("the JSONPath expression '{value}' is not valid"); let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::QueryInvalidXml => { let message = "the HTTP response is not a valid XML"; let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::QueryInvalidXpathEval => { let message = "the XPath expression is not valid"; let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::ReadOnlySecret { name } => { let message = &format!("secret '{name}' can't be reassigned"); let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::TemplateVariableNotDefined { name } => { let message = &format!("you must set the variable {name}"); let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::UnauthorizedFileAccess { path } => { let message = &format!( "unauthorized access to file {}, check --file-root option", path.to_string_lossy() ); let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::UnrenderableExpression { value } => { let message = &format!("expression with value {value} can not be rendered"); let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } RunnerErrorKind::UnsupportedSecretType(kind) => { let message = &format!("secret must be string, actual value is <{kind}>"); let message = error::add_carets(message, self.source_info, content); color_red_multiline_string(&message) } } } fn message(&self, content: &[&str]) -> StyledString { let mut text = StyledString::new(); if let RunnerErrorKind::AssertBodyDiffError { hunks, body_source_info, } = &self.kind { let loc_max_width = max(content.len().to_string().len(), 2); // Only process first hunk for the time-being // TODO: Process all the hunks for hunk in &hunks[..1] { text.push("\n"); text.append(hunk_string( hunk, body_source_info.start.line, content, loc_max_width, )); } text } else { error::add_source_line(&mut text, content, self.source_info().start.line); text.append(self.fixme(content)); let error_line = self.source_info().start.line; error::add_line_info_prefix(&text, content, error_line) } } } /// Color each line separately fn color_red_multiline_string(s: &str) -> StyledString { let lines = s.split('\n'); let mut s = StyledString::new(); for (i, line) in lines.enumerate() { if i > 0 { s.push("\n"); } s.push_with(line, Style::new().red().bold()); } s } fn hunk_string( hunk: &DiffHunk, source_line: usize, content: &[&str], loc_max_width: usize, ) -> StyledString { let mut s = StyledString::new(); let lines = hunk.content.split('\n'); let separator = "|"; let spaces = " ".repeat(loc_max_width); let mut prefix = StyledString::new(); prefix.push_with( format!("{spaces} {separator}").as_str(), Style::new().blue().bold(), ); let error_line = source_line + hunk.source_line; let source = content[error_line - 1]; s.push_with( format!("{error_line:>loc_max_width$} {separator} {source}\n").as_str(), Style::new().blue().bold(), ); for (i, line) in lines.iter().enumerate() { if i > 0 { s.push("\n"); } s.append(prefix.clone()); if !line.is_empty() { s.push(" "); s.append(line.clone()); } } s } #[cfg(test)] mod tests { use hurl_core::ast::SourceInfo; use hurl_core::error::{DisplaySourceError, OutputFormat}; use hurl_core::reader::Pos; use hurl_core::text::Format; use crate::http::HttpError; use crate::runner::diff::diff; use crate::runner::{RunnerError, RunnerErrorKind}; #[test] fn test_error_timeout() { let content = "GET http://unknown"; let lines = content.lines().collect::>(); let filename = "test.hurl"; let kind = RunnerErrorKind::Http(HttpError::Libcurl { code: 6, description: "Could not resolve host: unknown".to_string(), }); let error_source_info = SourceInfo::new(Pos::new(1, 5), Pos::new(1, 19)); let entry_source_info = SourceInfo::new(Pos::new(1, 1), Pos::new(1, 19)); let error = RunnerError::new(error_source_info, kind, true); assert_eq!( error .message(&lines) .to_string(Format::Plain), "\n 1 | GET http://unknown\n | ^^^^^^^^^^^^^^ (6) Could not resolve host: unknown\n |" ); assert_eq!( error.to_string( filename, content, Some(entry_source_info), OutputFormat::Terminal(false) ), r#"HTTP connection --> test.hurl:1:5 | 1 | GET http://unknown | ^^^^^^^^^^^^^^ (6) Could not resolve host: unknown |"# ); } #[test] fn test_assert_error_status() { hurl_core::text::init_crate_colored(); let content = r#"GET http://unknown HTTP/1.0 200 "#; let lines = content.lines().collect::>(); let filename = "test.hurl"; let kind = RunnerErrorKind::AssertStatus { actual: "404".to_string(), }; let error_source_info = SourceInfo::new(Pos::new(2, 10), Pos::new(2, 13)); let entry_source_info = SourceInfo::new(Pos::new(1, 1), Pos::new(1, 18)); let error = RunnerError::new(error_source_info, kind, true); assert_eq!( error.message(&lines).to_string(Format::Plain), "\n 2 | HTTP/1.0 200\n | ^^^ actual value is <404>\n |" ); assert_eq!( error.message(&lines).to_string(Format::Ansi), "\n\u{1b}[1;34m 2 |\u{1b}[0m HTTP/1.0 200\n\u{1b}[1;34m |\u{1b}[0m\u{1b}[1;31m ^^^ actual value is <404>\u{1b}[0m\n\u{1b}[1;34m |\u{1b}[0m" ); assert_eq!( error.to_string( filename, content, Some(entry_source_info), OutputFormat::Terminal(false) ), r#"Assert status code --> test.hurl:2:10 | | GET http://unknown 2 | HTTP/1.0 200 | ^^^ actual value is <404> |"# ); } #[test] fn test_invalid_xpath_expression() { let content = r#"GET http://example.com HTTP/1.0 200 [Asserts] xpath "strong(//head/title)" == "Hello" "#; let lines = content.lines().collect::>(); let filename = "test.hurl"; let error_source_info = SourceInfo::new(Pos::new(4, 7), Pos::new(4, 29)); let entry_source_info = SourceInfo::new(Pos::new(1, 1), Pos::new(1, 22)); let error = RunnerError::new( error_source_info, RunnerErrorKind::QueryInvalidXpathEval, true, ); assert_eq!( &error.message(&lines).to_string(Format::Plain), "\n 4 | xpath \"strong(//head/title)\" == \"Hello\"\n | ^^^^^^^^^^^^^^^^^^^^^^ the XPath expression is not valid\n |" ); assert_eq!( error.to_string( filename, content, Some(entry_source_info), OutputFormat::Terminal(false) ), r#"Invalid XPath expression --> test.hurl:4:7 | | GET http://example.com | ... 4 | xpath "strong(//head/title)" == "Hello" | ^^^^^^^^^^^^^^^^^^^^^^ the XPath expression is not valid |"# ); } #[test] fn test_assert_error_jsonpath() { let content = r#"GET http://api HTTP/1.0 200 [Asserts] jsonpath "$.count" >= 5 "#; let lines = content.lines().collect::>(); let filename = "test.hurl"; let error_source_info = SourceInfo::new(Pos::new(4, 0), Pos::new(4, 0)); let entry_source_info = SourceInfo::new(Pos::new(1, 1), Pos::new(1, 14)); let error = RunnerError { source_info: error_source_info, kind: RunnerErrorKind::AssertFailure { actual: "integer <2>".to_string(), expected: "greater than integer <5>".to_string(), type_mismatch: false, }, assert: true, }; assert_eq!( error.message(&lines).to_string(Format::Plain), r#" 4 | jsonpath "$.count" >= 5 | actual: integer <2> | expected: greater than integer <5> |"# ); assert_eq!( error.to_string( filename, content, Some(entry_source_info), OutputFormat::Terminal(false) ), r#"Assert failure --> test.hurl:4:0 | | GET http://api | ... 4 | jsonpath "$.count" >= 5 | actual: integer <2> | expected: greater than integer <5> |"# ); } #[test] fn test_assert_error_newline() { let content = r#"GET http://localhost HTTP/1.0 200 ```

Hello

``` "#; let lines = content.lines().collect::>(); let filename = "test.hurl"; let kind = RunnerErrorKind::AssertBodyDiffError { hunks: diff("

Hello

\n", "

Hello

\n\n"), body_source_info: SourceInfo::new(Pos::new(4, 1), Pos::new(4, 1)), }; let error_source_info = SourceInfo::new(Pos::new(4, 1), Pos::new(4, 1)); let entry_source_info = SourceInfo::new(Pos::new(1, 1), Pos::new(1, 20)); let error = RunnerError::new(error_source_info, kind, true); assert_eq!( error.message(&lines).to_string(Format::Plain), "\n 4 |

Hello

\n | +\n |" ); assert_eq!( error.to_string( filename, content, Some(entry_source_info), OutputFormat::Terminal(false) ), r#"Assert body value --> test.hurl:4:1 | | GET http://localhost | ... 4 |

Hello

| + |"# ); } } hurl-6.1.1/src/runner/event.rs000064400000000000000000000017321046102023000143750ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ /// This trait is implemented by run event observers, during the execution of one Hurl file. pub trait EventListener { /// Call when running an entry, `entry_index` is the entry 0-based index in the Hurl file, /// and `entry_count` is the total number of entries in the Hurl file. fn on_running(&self, entry_index: usize, entry_count: usize); } hurl-6.1.1/src/runner/expr.rs000064400000000000000000000067341046102023000142410ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::{Expr, ExprKind}; use super::function; use crate::runner::error::{RunnerError, RunnerErrorKind}; use crate::runner::value::Value; use crate::runner::VariableSet; /// Evaluates the expression `expr` with `variables` map, returns a [`Value`] on success or an [`RunnerError`] . pub fn eval(expr: &Expr, variables: &VariableSet) -> Result { match &expr.kind { ExprKind::Variable(variable) => { if let Some(variable) = variables.get(variable.name.as_str()) { Ok(variable.value().clone()) } else { let kind = RunnerErrorKind::TemplateVariableNotDefined { name: variable.name.clone(), }; Err(RunnerError::new(variable.source_info, kind, false)) } } ExprKind::Function(fct) => function::eval(fct), } } /// Render the expression `expr` with `variables` map, returns a [`String`] on success or an [`RunnerError`] . pub fn render(expr: &Expr, variables: &VariableSet) -> Result { let source_info = expr.source_info; let value = eval(expr, variables)?; if let Some(s) = value.render() { Ok(s) } else { let kind = RunnerErrorKind::UnrenderableExpression { value: value.to_string(), }; Err(RunnerError::new(source_info, kind, false)) } } #[cfg(test)] mod tests { use hurl_core::ast::{ExprKind, SourceInfo, Variable}; use hurl_core::reader::Pos; use super::*; #[test] fn test_render_expression() { let mut variables = VariableSet::new(); variables .insert("status".to_string(), Value::Bool(true)) .unwrap(); let expr = Expr { kind: ExprKind::Variable(Variable { name: "status".to_string(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }; assert_eq!(eval(&expr, &variables).unwrap(), Value::Bool(true)); assert_eq!(render(&expr, &variables).unwrap(), "true"); let data_chrono = chrono::DateTime::parse_from_rfc2822("Tue, 10 Jan 2023 08:29:52 GMT") .unwrap() .into(); variables .insert("now".to_string(), Value::Date(data_chrono)) .unwrap(); let expr = Expr { kind: ExprKind::Variable(Variable { name: "now".to_string(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }; assert_eq!(eval(&expr, &variables).unwrap(), Value::Date(data_chrono)); assert_eq!( render(&expr, &variables).unwrap(), "2023-01-10T08:29:52.000000Z" ); } } hurl-6.1.1/src/runner/filter/base64_decode.rs000064400000000000000000000066661046102023000171430ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use base64::prelude::BASE64_STANDARD; use base64::Engine; use hurl_core::ast::SourceInfo; use crate::runner::{RunnerError, RunnerErrorKind, Value}; /// Decode base 64 encoded string 'value' into bytes. pub fn eval_base64_decode( value: &Value, source_info: SourceInfo, assert: bool, ) -> Result, RunnerError> { match value { Value::String(value) => match BASE64_STANDARD.decode(value) { Ok(decoded) => Ok(Some(Value::Bytes(decoded))), Err(_) => { let kind = RunnerErrorKind::FilterInvalidInput("Invalid base64 string".to_string()); Err(RunnerError::new(source_info, kind, assert)) } }, v => { let kind = RunnerErrorKind::FilterInvalidInput(v.kind().to_string()); Err(RunnerError::new(source_info, kind, assert)) } } } #[cfg(test)] mod tests { use hurl_core::ast::{Filter, FilterValue, SourceInfo}; use hurl_core::reader::Pos; use super::*; use crate::runner::filter::eval::eval_filter; use crate::runner::VariableSet; #[test] fn eval_filter_base64_decode_ok() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::Base64Decode, }; let bytes = vec![ 0xe4, 0xbd, 0xa0, 0xe5, 0xa5, 0xbd, 0xe4, 0xb8, 0x96, 0xe7, 0x95, 0x8c, ]; let ret = eval_filter( &filter, &Value::String("5L2g5aW95LiW55WM".to_string()), &variables, false, ); assert_eq!(ret.unwrap().unwrap(), Value::Bytes(bytes)); } #[test] fn eval_filter_base64_decode_ok_invalid_characters() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::Base64Decode, }; let ret = eval_filter( &filter, &Value::String("!@#".to_string()), &variables, false, ); assert_eq!( ret.unwrap_err().kind, RunnerErrorKind::FilterInvalidInput("Invalid base64 string".to_string()) ); } #[test] fn eval_filter_base64_decode_ok_invalid_input() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::Base64Decode, }; let ret = eval_filter( &filter, &Value::Bytes([0xc4, 0xe3, 0xba].to_vec()), &variables, false, ); assert_eq!( ret.unwrap_err().kind, RunnerErrorKind::FilterInvalidInput("bytes".to_string()) ); } } hurl-6.1.1/src/runner/filter/base64_encode.rs000064400000000000000000000051211046102023000171360ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use base64::prelude::BASE64_STANDARD; use base64::Engine; use hurl_core::ast::SourceInfo; use crate::runner::{RunnerError, RunnerErrorKind, Value}; /// Encode bytes 'value' into base 64 encoded string. pub fn eval_base64_encode( value: &Value, source_info: SourceInfo, assert: bool, ) -> Result, RunnerError> { match value { Value::Bytes(value) => Ok(Some(Value::String(BASE64_STANDARD.encode(value)))), v => { let kind = RunnerErrorKind::FilterInvalidInput(v.kind().to_string()); Err(RunnerError::new(source_info, kind, assert)) } } } #[cfg(test)] mod tests { use hurl_core::ast::{Filter, FilterValue, SourceInfo}; use hurl_core::reader::Pos; use super::*; use crate::runner::filter::eval::eval_filter; use crate::runner::VariableSet; #[test] fn eval_filter_base64_encode_ok() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::Base64Encode, }; let bytes = vec![ 0xe4, 0xbd, 0xa0, 0xe5, 0xa5, 0xbd, 0xe4, 0xb8, 0x96, 0xe7, 0x95, 0x8c, ]; let ret = eval_filter(&filter, &Value::Bytes(bytes), &variables, false); assert_eq!( ret.unwrap().unwrap(), Value::String("5L2g5aW95LiW55WM".to_string()) ); } #[test] fn eval_filter_base64_encode_ok_invalid_input() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::Base64Encode, }; let ret = eval_filter( &filter, &Value::String("你好世界".to_string()), &variables, false, ); assert_eq!( ret.unwrap_err().kind, RunnerErrorKind::FilterInvalidInput("string".to_string()) ); } } hurl-6.1.1/src/runner/filter/count.rs000064400000000000000000000051721046102023000156730ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::SourceInfo; use crate::runner::{Number, RunnerError, RunnerErrorKind, Value}; /// Counts the number of items in a collection `value`. pub fn eval_count( value: &Value, source_info: SourceInfo, assert: bool, ) -> Result, RunnerError> { match value { Value::List(values) => Ok(Some(Value::Number(Number::Integer(values.len() as i64)))), Value::Bytes(values) => Ok(Some(Value::Number(Number::Integer(values.len() as i64)))), Value::Nodeset(size) => Ok(Some(Value::Number(Number::Integer(*size as i64)))), v => { let kind = RunnerErrorKind::FilterInvalidInput(v.kind().to_string()); Err(RunnerError::new(source_info, kind, assert)) } } } #[cfg(test)] mod tests { use hurl_core::ast::{Filter, FilterValue, SourceInfo}; use hurl_core::reader::Pos; use super::*; use crate::runner::filter::eval::eval_filter; use crate::runner::VariableSet; #[test] fn eval_filter_count() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 6)), value: FilterValue::Count, }; assert_eq!( eval_filter( &filter, &Value::List(vec![ Value::Number(Number::Integer(1)), Value::Number(Number::Integer(2)), Value::Number(Number::Integer(2)), ]), &variables, false, ) .unwrap() .unwrap(), Value::Number(Number::Integer(3)) ); let error = eval_filter(&filter, &Value::Bool(true), &variables, false) .err() .unwrap(); assert_eq!( error.source_info, SourceInfo::new(Pos::new(1, 1), Pos::new(1, 6)) ); assert_eq!( error.kind, RunnerErrorKind::FilterInvalidInput("boolean".to_string()) ); } } hurl-6.1.1/src/runner/filter/days_after_now.rs000064400000000000000000000060731046102023000175500ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use chrono::Utc; use hurl_core::ast::SourceInfo; use crate::runner::{Number, RunnerError, RunnerErrorKind, Value}; /// Returns the number of days between now and a date `value` in the future. pub fn eval_days_after_now( value: &Value, source_info: SourceInfo, assert: bool, ) -> Result, RunnerError> { match value { Value::Date(value) => { let diff = value.signed_duration_since(Utc::now()); Ok(Some(Value::Number(Number::Integer(diff.num_days())))) } v => { let kind = RunnerErrorKind::FilterInvalidInput(v.kind().to_string()); Err(RunnerError::new(source_info, kind, assert)) } } } #[cfg(test)] mod tests { use chrono::offset::Utc; use chrono::Duration; use hurl_core::ast::{Filter, FilterValue, SourceInfo}; use hurl_core::reader::Pos; use super::*; use crate::runner::filter::eval::eval_filter; use crate::runner::VariableSet; #[test] fn eval_filter_days_after_before_now() { let variables = VariableSet::new(); let now = Utc::now(); assert_eq!( eval_filter( &Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::DaysAfterNow, }, &Value::Date(now), &variables, false, ) .unwrap() .unwrap(), Value::Number(Number::Integer(0)) ); let now_plus_30hours = now + Duration::try_hours(30).unwrap(); assert_eq!( eval_filter( &Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::DaysAfterNow, }, &Value::Date(now_plus_30hours), &variables, false, ) .unwrap() .unwrap(), Value::Number(Number::Integer(1)) ); assert_eq!( eval_filter( &Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::DaysBeforeNow, }, &Value::Date(now_plus_30hours), &variables, false, ) .unwrap() .unwrap(), Value::Number(Number::Integer(-1)) ); } } hurl-6.1.1/src/runner/filter/days_before_now.rs000064400000000000000000000024611046102023000177060ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use chrono::Utc; use hurl_core::ast::SourceInfo; use crate::runner::{Number, RunnerError, RunnerErrorKind, Value}; /// Returns the number of days between now and a date `value` in the past. pub fn eval_days_before_now( value: &Value, source_info: SourceInfo, assert: bool, ) -> Result, RunnerError> { match value { Value::Date(value) => { let diff = Utc::now().signed_duration_since(*value); Ok(Some(Value::Number(Number::Integer(diff.num_days())))) } v => { let kind = RunnerErrorKind::FilterInvalidInput(v.kind().to_string()); Err(RunnerError::new(source_info, kind, assert)) } } } hurl-6.1.1/src/runner/filter/decode.rs000064400000000000000000000120511046102023000157600ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use encoding::DecoderTrap; use hurl_core::ast::{SourceInfo, Template}; use crate::runner::template::eval_template; use crate::runner::{RunnerError, RunnerErrorKind, Value, VariableSet}; /// Decode bytes `value` to string using an `encoding`. pub fn eval_decode( value: &Value, encoding: &Template, variables: &VariableSet, source_info: SourceInfo, assert: bool, ) -> Result, RunnerError> { let encoding = eval_template(encoding, variables)?; match value { Value::Bytes(value) => { match encoding::label::encoding_from_whatwg_label(encoding.as_str()) { None => { let kind = RunnerErrorKind::FilterInvalidEncoding(encoding); Err(RunnerError::new(source_info, kind, assert)) } Some(enc) => match enc.decode(value, DecoderTrap::Strict) { Ok(decoded) => Ok(Some(Value::String(decoded))), Err(_) => { let kind = RunnerErrorKind::FilterDecode(encoding); Err(RunnerError::new(source_info, kind, assert)) } }, } } v => { let kind = RunnerErrorKind::FilterInvalidInput(v.kind().to_string()); Err(RunnerError::new(source_info, kind, assert)) } } } #[cfg(test)] mod tests { use hurl_core::ast::{Filter, FilterValue, SourceInfo, Template, TemplateElement, Whitespace}; use hurl_core::reader::Pos; use hurl_core::typing::ToSource; use super::*; use crate::runner::filter::eval::eval_filter; use crate::runner::VariableSet; /// Helper function to return a new filter given an `encoding` fn new_decode_filter(encoding: &str) -> Filter { // Example: decode "gb2312" Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::Decode { space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(7, 1), Pos::new(8, 1)), }, encoding: Template::new( None, vec![TemplateElement::String { value: encoding.to_string(), source: encoding.to_source(), }], SourceInfo::new(Pos::new(8, 1), Pos::new(8 + encoding.len(), 1)), ), }, } } #[test] fn eval_filter_decode_ok() { let variables = VariableSet::new(); let filter = new_decode_filter("utf-8"); let bytes = vec![ 0xe4, 0xbd, 0xa0, 0xe5, 0xa5, 0xbd, 0xe4, 0xb8, 0x96, 0xe7, 0x95, 0x8c, ]; let ret = eval_filter(&filter, &Value::Bytes(bytes), &variables, false); assert_eq!(ret.unwrap().unwrap(), Value::String("你好世界".to_string())); let filter = new_decode_filter("gb2312"); let bytes = vec![0xc4, 0xe3, 0xba, 0xc3, 0xca, 0xc0, 0xbd, 0xe7]; let ret = eval_filter(&filter, &Value::Bytes(bytes), &variables, false); assert_eq!(ret.unwrap().unwrap(), Value::String("你好世界".to_string())); } #[test] fn eval_filter_decode_ko_unknown_encoding() { let variables = VariableSet::new(); let filter = new_decode_filter("xxx"); let bytes = vec![]; let ret = eval_filter(&filter, &Value::Bytes(bytes), &variables, false); assert_eq!( ret.unwrap_err().kind, RunnerErrorKind::FilterInvalidEncoding("xxx".to_string()), ); } #[test] fn eval_filter_decode_ko_bad_bytes_input() { let variables = VariableSet::new(); let filter = new_decode_filter("gb2312"); let bytes = vec![0xc4, 0x00]; let ret = eval_filter(&filter, &Value::Bytes(bytes), &variables, false); assert_eq!( ret.unwrap_err().kind, RunnerErrorKind::FilterDecode("gb2312".to_string()), ); } #[test] fn eval_filter_decode_ko_bad_input_type() { let variables = VariableSet::new(); let filter = new_decode_filter("utf-8"); let ret = eval_filter( &filter, &Value::String("café".to_string()), &variables, false, ); assert_eq!( ret.unwrap_err().kind, RunnerErrorKind::FilterInvalidInput("string".to_string()), ); } } hurl-6.1.1/src/runner/filter/eval.rs000064400000000000000000000137541046102023000154770ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::{Filter, FilterValue}; use crate::runner::filter::base64_decode::eval_base64_decode; use crate::runner::filter::base64_encode::eval_base64_encode; use crate::runner::filter::count::eval_count; use crate::runner::filter::days_after_now::eval_days_after_now; use crate::runner::filter::days_before_now::eval_days_before_now; use crate::runner::filter::decode::eval_decode; use crate::runner::filter::format::eval_format; use crate::runner::filter::html_escape::eval_html_escape; use crate::runner::filter::html_unescape::eval_html_unescape; use crate::runner::filter::jsonpath::eval_jsonpath; use crate::runner::filter::nth::eval_nth; use crate::runner::filter::regex::eval_regex; use crate::runner::filter::replace::eval_replace; use crate::runner::filter::split::eval_split; use crate::runner::filter::to_date::eval_to_date; use crate::runner::filter::to_float::eval_to_float; use crate::runner::filter::to_int::eval_to_int; use crate::runner::filter::to_string::eval_to_string; use crate::runner::filter::url_decode::eval_url_decode; use crate::runner::filter::url_encode::eval_url_encode; use crate::runner::filter::xpath::eval_xpath; use crate::runner::{RunnerError, RunnerErrorKind, Value, VariableSet}; /// Apply successive `filter` to an input `value`. /// Specify whether they are executed `in_assert` or not. pub fn eval_filters( filters: &[Filter], value: &Value, variables: &VariableSet, in_assert: bool, ) -> Result, RunnerError> { let mut value = Some(value.clone()); for filter in filters { value = if let Some(value) = value { eval_filter(filter, &value, variables, in_assert)? } else { return Err(RunnerError::new( filter.source_info, RunnerErrorKind::FilterMissingInput, in_assert, )); } } Ok(value) } /// Evaluates a `filter` with an input `value`, given a set of `variables`. pub fn eval_filter( filter: &Filter, value: &Value, variables: &VariableSet, in_assert: bool, ) -> Result, RunnerError> { match &filter.value { FilterValue::Base64Decode => eval_base64_decode(value, filter.source_info, in_assert), FilterValue::Base64Encode => eval_base64_encode(value, filter.source_info, in_assert), FilterValue::Count => eval_count(value, filter.source_info, in_assert), FilterValue::DaysAfterNow => eval_days_after_now(value, filter.source_info, in_assert), FilterValue::DaysBeforeNow => eval_days_before_now(value, filter.source_info, in_assert), FilterValue::Decode { encoding, .. } => { eval_decode(value, encoding, variables, filter.source_info, in_assert) } FilterValue::Format { fmt, .. } => { eval_format(value, fmt, variables, filter.source_info, in_assert) } FilterValue::HtmlEscape => eval_html_escape(value, filter.source_info, in_assert), FilterValue::HtmlUnescape => eval_html_unescape(value, filter.source_info, in_assert), FilterValue::JsonPath { expr, .. } => { eval_jsonpath(value, expr, variables, filter.source_info, in_assert) } FilterValue::Regex { value: regex_value, .. } => eval_regex(value, regex_value, variables, filter.source_info, in_assert), FilterValue::Nth { n, .. } => eval_nth(value, filter.source_info, in_assert, n.as_u64()), FilterValue::Replace { old_value, new_value, .. } => eval_replace( value, variables, filter.source_info, in_assert, old_value, new_value, ), FilterValue::Split { sep, .. } => { eval_split(value, variables, filter.source_info, in_assert, sep) } FilterValue::ToDate { fmt, .. } => { eval_to_date(value, fmt, variables, filter.source_info, in_assert) } FilterValue::ToFloat => eval_to_float(value, filter.source_info, in_assert), FilterValue::ToInt => eval_to_int(value, filter.source_info, in_assert), FilterValue::ToString => eval_to_string(value, filter.source_info, in_assert), FilterValue::UrlDecode => eval_url_decode(value, filter.source_info, in_assert), FilterValue::UrlEncode => eval_url_encode(value, filter.source_info, in_assert), FilterValue::XPath { expr, .. } => { eval_xpath(value, expr, variables, filter.source_info, in_assert) } } } #[cfg(test)] mod tests { use hurl_core::ast::{Filter, FilterValue, SourceInfo}; use hurl_core::reader::Pos; use crate::runner::filter::eval::eval_filters; use crate::runner::{Number, Value, VariableSet}; #[test] fn test_filters() { let variables = VariableSet::new(); assert_eq!( eval_filters( &[Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 6)), value: FilterValue::Count, }], &Value::List(vec![ Value::Number(Number::Integer(1)), Value::Number(Number::Integer(2)), Value::Number(Number::Integer(2)), ]), &variables, false, ) .unwrap() .unwrap(), Value::Number(Number::Integer(3)) ); } } hurl-6.1.1/src/runner/filter/format.rs000064400000000000000000000103551046102023000160320ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::fmt::Write; use hurl_core::ast::{SourceInfo, Template}; use crate::runner::template::eval_template; use crate::runner::{RunnerError, RunnerErrorKind, Value, VariableSet}; /// Formats a date `value` to a string given a specification `format`. /// See pub fn eval_format( value: &Value, format: &Template, variables: &VariableSet, source_info: SourceInfo, assert: bool, ) -> Result, RunnerError> { let format = eval_template(format, variables)?; match value { Value::Date(value) => { let mut formatted = String::new(); match write!(formatted, "{}", value.format(format.as_str())) { Ok(_) => Ok(Some(Value::String(formatted))), Err(_) => { let kind = RunnerErrorKind::FilterInvalidFormatSpecifier(format); Err(RunnerError::new(source_info, kind, assert)) } } } v => { let kind = RunnerErrorKind::FilterInvalidInput(v.kind().to_string()); Err(RunnerError::new(source_info, kind, assert)) } } } #[cfg(test)] mod tests { use chrono::offset::Utc; use chrono::TimeZone; use hurl_core::ast::{Filter, FilterValue, SourceInfo, Template, TemplateElement, Whitespace}; use hurl_core::reader::Pos; use hurl_core::typing::ToSource; use super::*; use crate::runner::filter::eval::eval_filter; use crate::runner::VariableSet; /// Helper function to return a new filter given a `fmt` fn new_format_filter(fmt: &str) -> Filter { // Example: format "%m/%d/%Y" Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::Format { space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(7, 1), Pos::new(8, 1)), }, fmt: Template::new( None, vec![TemplateElement::String { value: fmt.to_string(), source: fmt.to_source(), }], SourceInfo::new(Pos::new(8, 1), Pos::new(8 + fmt.len(), 1)), ), }, } } #[test] fn eval_filter_format_ok() { let variables = VariableSet::new(); let date = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(); let filter = new_format_filter("%m/%d/%Y"); let ret = eval_filter(&filter, &Value::Date(date), &variables, false); assert_eq!( ret.unwrap().unwrap(), Value::String("01/01/2025".to_string()) ); } #[test] fn eval_filter_format_ko_bad_input_type() { let variables = VariableSet::new(); let filter = new_format_filter("%m/%d/%Y"); let ret = eval_filter( &filter, &Value::String("01/01/2025".to_string()), &variables, false, ); assert_eq!( ret.unwrap_err().kind, RunnerErrorKind::FilterInvalidInput("string".to_string()) ); } #[test] fn eval_filter_format_ko_invalid_format() { let variables = VariableSet::new(); let date = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(); let filter = new_format_filter("%%%"); let ret = eval_filter(&filter, &Value::Date(date), &variables, false); assert_eq!( ret.unwrap_err().kind, RunnerErrorKind::FilterInvalidFormatSpecifier("%%%".to_string()) ); } } hurl-6.1.1/src/runner/filter/html_escape.rs000064400000000000000000000046071046102023000170310ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::SourceInfo; use crate::html; use crate::runner::{RunnerError, RunnerErrorKind, Value}; /// Converts the characters `&`, `<` and `>` in `value` to HTML-safe sequence. pub fn eval_html_escape( value: &Value, source_info: SourceInfo, assert: bool, ) -> Result, RunnerError> { match value { Value::String(value) => { let encoded = html::html_escape(value); Ok(Some(Value::String(encoded))) } v => { let kind = RunnerErrorKind::FilterInvalidInput(v.kind().to_string()); Err(RunnerError::new(source_info, kind, assert)) } } } #[cfg(test)] pub mod tests { use hurl_core::ast::{Filter, FilterValue, SourceInfo}; use hurl_core::reader::Pos; use crate::runner::filter::eval::eval_filter; use crate::runner::{Value, VariableSet}; #[test] pub fn eval_filter_html_escape() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::HtmlEscape, }; let tests = [ ("foo", "foo"), ("", "<tag>"), ("foo & bar", "foo & bar"), ( "string with double quote: \"baz\"", "string with double quote: "baz"", ), ]; for (input, output) in tests.iter() { assert_eq!( eval_filter( &filter, &Value::String(input.to_string()), &variables, false ) .unwrap() .unwrap(), Value::String(output.to_string()) ); } } } hurl-6.1.1/src/runner/filter/html_unescape.rs000064400000000000000000000047031046102023000173710ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::SourceInfo; use crate::html; use crate::runner::{RunnerError, RunnerErrorKind, Value}; /// Converts all named and numeric character references (e.g. >, >, >) in `value` to the /// corresponding Unicode characters. pub fn eval_html_unescape( value: &Value, source_info: SourceInfo, assert: bool, ) -> Result, RunnerError> { match value { Value::String(value) => { let decoded = html::html_unescape(value); Ok(Some(Value::String(decoded))) } v => { let kind = RunnerErrorKind::FilterInvalidInput(v.kind().to_string()); Err(RunnerError::new(source_info, kind, assert)) } } } #[cfg(test)] mod tests { use hurl_core::ast::{Filter, FilterValue, SourceInfo}; use hurl_core::reader::Pos; use crate::runner::filter::eval::eval_filter; use crate::runner::{Value, VariableSet}; #[test] fn eval_filter_html_unescape() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::HtmlUnescape, }; let tests = [ ("foo", "foo"), ("<tag>", ""), ("foo & bar", "foo & bar"), ( "string with double quote: "baz"", "string with double quote: \"baz\"", ), ]; for (input, output) in tests.iter() { assert_eq!( eval_filter( &filter, &Value::String(input.to_string()), &variables, false ) .unwrap() .unwrap(), Value::String(output.to_string()) ); } } } hurl-6.1.1/src/runner/filter/jsonpath.rs000064400000000000000000000075661046102023000164020ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::{SourceInfo, Template}; use crate::jsonpath; use crate::runner::template::eval_template; use crate::runner::{RunnerError, RunnerErrorKind, Value, VariableSet}; /// Evaluates a JSONPath expression `expr` against a `value`. pub fn eval_jsonpath( value: &Value, expr: &Template, variables: &VariableSet, source_info: SourceInfo, assert: bool, ) -> Result, RunnerError> { match value { Value::String(text) => { let json = match serde_json::from_str(text) { Err(_) => { return Err(RunnerError::new( source_info, RunnerErrorKind::QueryInvalidJson, false, )); } Ok(v) => v, }; eval_jsonpath_json(&json, expr, variables) } v => { let kind = RunnerErrorKind::FilterInvalidInput(v.kind().to_string()); Err(RunnerError::new(source_info, kind, assert)) } } } pub fn eval_jsonpath_json( json: &serde_json::Value, expr: &Template, variables: &VariableSet, ) -> Result, RunnerError> { let expr_str = eval_template(expr, variables)?; let expr_source_info = expr.source_info; let jsonpath_query = match jsonpath::parse(&expr_str) { Ok(q) => q, Err(_) => { let kind = RunnerErrorKind::QueryInvalidJsonpathExpression { value: expr_str }; return Err(RunnerError::new(expr_source_info, kind, false)); } }; let results = jsonpath_query.eval(json); match results { None => Ok(None), Some(jsonpath::JsonpathResult::SingleEntry(value)) => Ok(Some(Value::from_json(&value))), Some(jsonpath::JsonpathResult::Collection(values)) => { Ok(Some(Value::from_json(&serde_json::Value::Array(values)))) } } } #[cfg(test)] mod tests { use hurl_core::ast::{Filter, FilterValue, SourceInfo, Template, TemplateElement, Whitespace}; use hurl_core::reader::Pos; use hurl_core::typing::ToSource; use crate::runner::filter::eval::eval_filter; use crate::runner::{Value, VariableSet}; #[test] fn eval_filter_jsonpath() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::JsonPath { expr: Template::new( Some('"'), vec![TemplateElement::String { value: "$.message".to_string(), source: "$.message".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }, }, }; assert_eq!( eval_filter( &filter, &Value::String(r#"{"message":"Hello"}"#.to_string()), &variables, false ) .unwrap() .unwrap(), Value::String("Hello".to_string()) ); } } hurl-6.1.1/src/runner/filter/mod.rs000064400000000000000000000020231046102023000153120ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ pub use eval::eval_filters; pub use jsonpath::eval_jsonpath_json; pub use xpath::eval_xpath_doc; mod base64_decode; mod base64_encode; mod count; mod days_after_now; mod days_before_now; mod decode; mod eval; mod format; mod html_escape; mod html_unescape; mod jsonpath; mod nth; mod regex; mod replace; mod split; mod to_date; mod to_float; mod to_int; mod to_string; mod url_decode; mod url_encode; mod xpath; hurl-6.1.1/src/runner/filter/nth.rs000064400000000000000000000065011046102023000153310ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::SourceInfo; use crate::runner::{RunnerError, RunnerErrorKind, Value}; /// Returns the element from a collection `value` at a zero-based index. pub fn eval_nth( value: &Value, source_info: SourceInfo, assert: bool, n: u64, ) -> Result, RunnerError> { match value { Value::List(values) => match values.get(n as usize) { None => { let kind = RunnerErrorKind::FilterInvalidInput(format!( "Out of bound - size is {}", values.len() )); Err(RunnerError::new(source_info, kind, assert)) } Some(value) => Ok(Some(value.clone())), }, v => { let kind = RunnerErrorKind::FilterInvalidInput(v.repr()); Err(RunnerError::new(source_info, kind, assert)) } } } #[cfg(test)] mod tests { use hurl_core::ast::{Filter, FilterValue, SourceInfo, Whitespace, U64}; use hurl_core::reader::Pos; use hurl_core::typing::ToSource; use crate::runner::filter::eval::eval_filter; use crate::runner::{Number, RunnerError, RunnerErrorKind, Value, VariableSet}; #[test] fn eval_filter_nth() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::Nth { n: U64::new(2, "2".to_source()), space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }, }, }; assert_eq!( eval_filter( &filter, &Value::List(vec![ Value::Number(Number::Integer(0)), Value::Number(Number::Integer(1)), Value::Number(Number::Integer(2)), Value::Number(Number::Integer(3)) ]), &variables, false ) .unwrap() .unwrap(), Value::Number(Number::Integer(2)) ); assert_eq!( eval_filter( &filter, &Value::List(vec![ Value::Number(Number::Integer(0)), Value::Number(Number::Integer(1)) ]), &variables, false ) .err() .unwrap(), RunnerError::new( SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), RunnerErrorKind::FilterInvalidInput("Out of bound - size is 2".to_string()), false ) ); } } hurl-6.1.1/src/runner/filter/regex.rs000064400000000000000000000110461046102023000156520ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::{RegexValue, SourceInfo}; use crate::runner::regex::eval_regex_value; use crate::runner::{RunnerError, RunnerErrorKind, Value, VariableSet}; /// Extracts `regex` capture group from `value`. /// Pattern must have at least one capture group. pub fn eval_regex( value: &Value, regex: &RegexValue, variables: &VariableSet, source_info: SourceInfo, assert: bool, ) -> Result, RunnerError> { let re = eval_regex_value(regex, variables)?; match value { Value::String(s) => match re.captures(s.as_str()) { Some(captures) => match captures.get(1) { Some(v) => Ok(Some(Value::String(v.as_str().to_string()))), None => Ok(None), }, None => Ok(None), }, v => { let kind = RunnerErrorKind::FilterInvalidInput(v.kind().to_string()); Err(RunnerError::new(source_info, kind, assert)) } } } #[cfg(test)] mod tests { use hurl_core::ast::{ Filter, FilterValue, RegexValue, SourceInfo, Template, TemplateElement, Whitespace, }; use hurl_core::reader::Pos; use hurl_core::typing::ToSource; use crate::runner::filter::eval::eval_filter; use crate::runner::{RunnerErrorKind, Value, VariableSet}; #[test] fn eval_filter_regex() { // regex "Hello (.*)!" let variables = VariableSet::new(); let whitespace = Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }; let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 20)), value: FilterValue::Regex { space0: whitespace, value: RegexValue::Template(Template::new( None, vec![TemplateElement::String { value: "Hello (.*)!".to_string(), source: "Hello (.*)!".to_source(), }], SourceInfo::new(Pos::new(1, 7), Pos::new(1, 20)), )), }, }; assert_eq!( eval_filter( &filter, &Value::String("Hello Bob!".to_string()), &variables, false, ) .unwrap() .unwrap(), Value::String("Bob".to_string()) ); let error = eval_filter(&filter, &Value::Bool(true), &variables, false) .err() .unwrap(); assert_eq!( error.source_info, SourceInfo::new(Pos::new(1, 1), Pos::new(1, 20)) ); assert_eq!( error.kind, RunnerErrorKind::FilterInvalidInput("boolean".to_string()) ); } #[test] fn eval_filter_invalid_regex() { let variables = VariableSet::new(); let whitespace = Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }; let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 20)), value: FilterValue::Regex { space0: whitespace, value: RegexValue::Template(Template::new( None, vec![TemplateElement::String { value: "???".to_string(), source: "???".to_source(), }], SourceInfo::new(Pos::new(1, 7), Pos::new(1, 20)), )), }, }; let error = eval_filter( &filter, &Value::String("Hello Bob!".to_string()), &variables, false, ) .err() .unwrap(); assert_eq!( error.source_info, SourceInfo::new(Pos::new(1, 7), Pos::new(1, 20)) ); assert_eq!(error.kind, RunnerErrorKind::InvalidRegex); } } hurl-6.1.1/src/runner/filter/replace.rs000064400000000000000000000066661046102023000161670ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::{RegexValue, SourceInfo, Template}; use crate::runner::regex::eval_regex_value; use crate::runner::template::eval_template; use crate::runner::{RunnerError, RunnerErrorKind, Value, VariableSet}; /// Replaces all occurrences of `old_value` with `new_value` in `value`. pub fn eval_replace( value: &Value, variables: &VariableSet, source_info: SourceInfo, assert: bool, old_value: &RegexValue, new_value: &Template, ) -> Result, RunnerError> { match value { Value::String(v) => { let re = eval_regex_value(old_value, variables)?; let new_value = eval_template(new_value, variables)?; let s = re.replace_all(v, new_value).to_string(); Ok(Some(Value::String(s))) } v => { let kind = RunnerErrorKind::FilterInvalidInput(v.repr()); Err(RunnerError::new(source_info, kind, assert)) } } } #[cfg(test)] mod tests { use hurl_core::ast::{ Filter, FilterValue, RegexValue, SourceInfo, Template, TemplateElement, Whitespace, }; use hurl_core::reader::Pos; use hurl_core::typing::ToSource; use crate::runner::filter::eval::eval_filter; use crate::runner::{Value, VariableSet}; #[test] fn eval_filter_replace() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::Replace { old_value: RegexValue::Template(Template::new( None, vec![TemplateElement::String { value: "\\s+".to_string(), source: ",".to_source(), }], SourceInfo::new(Pos::new(1, 7), Pos::new(1, 20)), )), new_value: Template::new( Some('"'), vec![TemplateElement::String { value: ",".to_string(), source: ",".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }, space1: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }, }, }; assert_eq!( eval_filter( &filter, &Value::String("1 2\t3 4".to_string()), &variables, false ) .unwrap() .unwrap(), Value::String("1,2,3,4".to_string()) ); } } hurl-6.1.1/src/runner/filter/split.rs000064400000000000000000000057401046102023000156770ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::{SourceInfo, Template}; use crate::runner::template::eval_template; use crate::runner::{RunnerError, RunnerErrorKind, Value, VariableSet}; /// Splits the string `value` to a list of strings around occurrences of the specified `delimiter`. pub fn eval_split( value: &Value, variables: &VariableSet, source_info: SourceInfo, assert: bool, delimiter: &Template, ) -> Result, RunnerError> { match value { Value::String(s) => { let delimiter = eval_template(delimiter, variables)?; let values = s .split(&delimiter) .map(|v| Value::String(v.to_string())) .collect(); Ok(Some(Value::List(values))) } v => { let kind = RunnerErrorKind::FilterInvalidInput(v.repr()); Err(RunnerError::new(source_info, kind, assert)) } } } #[cfg(test)] mod tests { use hurl_core::ast::{Filter, FilterValue, SourceInfo, Template, TemplateElement, Whitespace}; use hurl_core::reader::Pos; use hurl_core::typing::ToSource; use crate::runner::filter::eval::eval_filter; use crate::runner::{Value, VariableSet}; #[test] fn eval_filter_split() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::Split { sep: Template::new( Some('"'), vec![TemplateElement::String { value: ",".to_string(), source: ",".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }, }, }; assert_eq!( eval_filter( &filter, &Value::String("1,2,3".to_string()), &variables, false ) .unwrap() .unwrap(), Value::List(vec![ Value::String("1".to_string()), Value::String("2".to_string()), Value::String("3".to_string()), ]) ); } } hurl-6.1.1/src/runner/filter/to_date.rs000064400000000000000000000112401046102023000161530ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use chrono::NaiveDateTime; use hurl_core::ast::{SourceInfo, Template}; use crate::runner::template::eval_template; use crate::runner::{RunnerError, RunnerErrorKind, Value, VariableSet}; /// Converts a string to a date given a specification `format`. /// See pub fn eval_to_date( value: &Value, format: &Template, variables: &VariableSet, source_info: SourceInfo, assert: bool, ) -> Result, RunnerError> { let format = eval_template(format, variables)?; match value { Value::String(v) => match NaiveDateTime::parse_from_str(v, format.as_str()) { Ok(v) => Ok(Some(Value::Date( v.and_local_timezone(chrono::Utc).unwrap(), ))), Err(_) => { let kind = RunnerErrorKind::FilterInvalidInput(value.repr()); Err(RunnerError::new(source_info, kind, assert)) } }, v => { let kind = RunnerErrorKind::FilterInvalidInput(v.repr()); Err(RunnerError::new(source_info, kind, assert)) } } } #[cfg(test)] mod tests { use chrono::{DateTime, NaiveDate, Utc}; use hurl_core::ast::{Filter, FilterValue, SourceInfo, Template, TemplateElement, Whitespace}; use hurl_core::reader::Pos; use hurl_core::typing::ToSource; use crate::runner::filter::eval::eval_filter; use crate::runner::{Value, VariableSet}; #[test] fn eval_filter_to_date() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::ToDate { fmt: Template::new( Some('"'), vec![TemplateElement::String { value: "%Y %b %d %H:%M:%S%.3f %z".to_string(), source: "%Y %b %d %H:%M:%S%.3f %z".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }, }, }; let naive_datetime_utc = NaiveDate::from_ymd_opt(1983, 4, 13) .unwrap() .and_hms_micro_opt(12, 9, 14, 274000) .unwrap(); let datetime_utc = DateTime::::from_naive_utc_and_offset(naive_datetime_utc, Utc); assert_eq!( eval_filter( &filter, &Value::String("1983 Apr 13 12:09:14.274 +0000".to_string()), &variables, false ) .unwrap() .unwrap(), Value::Date(datetime_utc) ); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::ToDate { fmt: Template::new( Some('"'), vec![TemplateElement::String { value: "%a, %d %b %Y %H:%M:%S GMT".to_string(), source: "%a, %d %b %Y %H:%M:%S GMT".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }, }, }; let naivedatetime_utc = NaiveDate::from_ymd_opt(2015, 10, 21) .unwrap() .and_hms_opt(7, 28, 0) .unwrap(); let datetime_utc = DateTime::::from_naive_utc_and_offset(naivedatetime_utc, Utc); assert_eq!( eval_filter( &filter, &Value::String("Wed, 21 Oct 2015 07:28:00 GMT".to_string()), &variables, false ) .unwrap() .unwrap(), Value::Date(datetime_utc) ); } } hurl-6.1.1/src/runner/filter/to_float.rs000064400000000000000000000077641046102023000163630ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::SourceInfo; use crate::runner::{Number, RunnerError, RunnerErrorKind, Value}; /// Converts `value` to float number. pub fn eval_to_float( value: &Value, source_info: SourceInfo, assert: bool, ) -> Result, RunnerError> { match value { Value::Number(Number::Float(v)) => Ok(Some(Value::Number(Number::Float(*v)))), Value::Number(Number::Integer(v)) => Ok(Some(Value::Number(Number::Float(*v as f64)))), Value::String(v) => match v.parse::() { Ok(f) => Ok(Some(Value::Number(Number::Float(f)))), _ => { let kind = RunnerErrorKind::FilterInvalidInput(value.repr()); Err(RunnerError::new(source_info, kind, assert)) } }, v => { let kind = RunnerErrorKind::FilterInvalidInput(v.repr()); Err(RunnerError::new(source_info, kind, assert)) } } } #[cfg(test)] mod tests { use hurl_core::ast::{Filter, FilterValue, SourceInfo}; use hurl_core::reader::Pos; use crate::runner::filter::eval::eval_filter; use crate::runner::{Number, RunnerErrorKind, Value, VariableSet}; #[allow(clippy::approx_constant)] #[test] fn eval_filter_to_float() { let variable = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::ToFloat, }; assert_eq!( eval_filter( &filter, &Value::String("3.1415".to_string()), &variable, false ) .unwrap() .unwrap(), Value::Number(Number::Float(3.1415)) ); assert_eq!( eval_filter( &filter, &Value::Number(Number::Float(3.1415)), &variable, false ) .unwrap() .unwrap(), Value::Number(Number::Float(3.1415)) ); assert_eq!( eval_filter( &filter, &Value::Number(Number::Float(3.0)), &variable, false ) .unwrap() .unwrap(), Value::Number(Number::Float(3.0)) ); assert_eq!( eval_filter( &filter, &Value::Number(Number::Integer(3)), &variable, false ) .unwrap() .unwrap(), Value::Number(Number::Float(3.0)) ); } #[test] fn eval_filter_to_float_error() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::ToFloat, }; let err = eval_filter( &filter, &Value::String("3x.1415".to_string()), &variables, false, ) .err() .unwrap(); assert_eq!( err.kind, RunnerErrorKind::FilterInvalidInput("string <3x.1415>".to_string()) ); let err = eval_filter(&filter, &Value::Bool(true), &variables, false) .err() .unwrap(); assert_eq!( err.kind, RunnerErrorKind::FilterInvalidInput("boolean ".to_string()) ); } } hurl-6.1.1/src/runner/filter/to_int.rs000064400000000000000000000072411046102023000160360ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::SourceInfo; use crate::runner::{Number, RunnerError, RunnerErrorKind, Value}; /// Converts `value` to an integer. pub fn eval_to_int( value: &Value, source_info: SourceInfo, assert: bool, ) -> Result, RunnerError> { match value { Value::Number(Number::Integer(v)) => Ok(Some(Value::Number(Number::Integer(*v)))), Value::Number(Number::Float(v)) => Ok(Some(Value::Number(Number::Integer(*v as i64)))), Value::String(v) => match v.parse::() { Ok(i) => Ok(Some(Value::Number(Number::Integer(i)))), _ => { let kind = RunnerErrorKind::FilterInvalidInput(value.repr()); Err(RunnerError::new(source_info, kind, assert)) } }, v => { let kind = RunnerErrorKind::FilterInvalidInput(v.repr()); Err(RunnerError::new(source_info, kind, assert)) } } } #[cfg(test)] mod tests { use hurl_core::ast::{Filter, FilterValue, SourceInfo}; use hurl_core::reader::Pos; use crate::runner::filter::eval::eval_filter; use crate::runner::{Number, RunnerErrorKind, Value, VariableSet}; #[test] fn eval_filter_to_int() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::ToInt, }; assert_eq!( eval_filter( &filter, &Value::String("123".to_string()), &variables, false ) .unwrap() .unwrap(), Value::Number(Number::Integer(123)) ); assert_eq!( eval_filter( &filter, &Value::Number(Number::Integer(123)), &variables, false ) .unwrap() .unwrap(), Value::Number(Number::Integer(123)) ); assert_eq!( eval_filter( &filter, &Value::Number(Number::Float(1.6)), &variables, false ) .unwrap() .unwrap(), Value::Number(Number::Integer(1)) ); } #[test] fn eval_filter_to_int_error() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::ToInt, }; let err = eval_filter( &filter, &Value::String("123x".to_string()), &variables, false, ) .err() .unwrap(); assert_eq!( err.kind, RunnerErrorKind::FilterInvalidInput("string <123x>".to_string()) ); let err = eval_filter(&filter, &Value::Bool(true), &variables, false) .err() .unwrap(); assert_eq!( err.kind, RunnerErrorKind::FilterInvalidInput("boolean ".to_string()) ); } } hurl-6.1.1/src/runner/filter/to_string.rs000064400000000000000000000051261046102023000165520ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::SourceInfo; use crate::runner::{RunnerError, RunnerErrorKind, Value}; /// Converts `value` to an string. /// /// return a RunnerError if the value is not renderable pub fn eval_to_string( value: &Value, source_info: SourceInfo, assert: bool, ) -> Result, RunnerError> { match value.render() { None => { let kind = RunnerErrorKind::FilterInvalidInput(format!( "{} can not be converted to a string", value.repr() )); Err(RunnerError::new(source_info, kind, assert)) } Some(value) => Ok(Some(Value::String(value))), } } #[cfg(test)] mod tests { use hurl_core::ast::{Filter, FilterValue, SourceInfo}; use hurl_core::reader::Pos; use crate::runner::filter::eval::eval_filter; use crate::runner::{Number, RunnerErrorKind, Value, VariableSet}; #[test] fn eval_filter_to_string() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::ToString, }; assert_eq!( eval_filter( &filter, &Value::Number(Number::Integer(100)), &variables, false ) .unwrap() .unwrap(), Value::String("100".to_string()) ); } #[test] fn eval_filter_to_string_error() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::ToString, }; let err = eval_filter(&filter, &Value::List(vec![]), &variables, false) .err() .unwrap(); assert_eq!( err.kind, RunnerErrorKind::FilterInvalidInput( "list <[]> can not be converted to a string".to_string() ) ); } } hurl-6.1.1/src/runner/filter/url_decode.rs000064400000000000000000000045761046102023000166570ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::SourceInfo; use crate::runner::{RunnerError, RunnerErrorKind, Value}; /// Replaces `%xx` URL escapes in `value` with their single-character equivalent. pub fn eval_url_decode( value: &Value, source_info: SourceInfo, assert: bool, ) -> Result, RunnerError> { match value { Value::String(value) => { match percent_encoding::percent_decode(value.as_bytes()).decode_utf8() { Ok(decoded) => Ok(Some(Value::String(decoded.to_string()))), Err(_) => { let kind = RunnerErrorKind::FilterInvalidInput("Invalid UTF-8 stream".to_string()); Err(RunnerError::new(source_info, kind, assert)) } } } v => { let kind = RunnerErrorKind::FilterInvalidInput(v.kind().to_string()); Err(RunnerError::new(source_info, kind, assert)) } } } #[cfg(test)] mod tests { use hurl_core::ast::{Filter, FilterValue, SourceInfo}; use hurl_core::reader::Pos; use crate::runner::filter::eval::eval_filter; use crate::runner::{Value, VariableSet}; #[test] fn eval_filter_url_decode() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), value: FilterValue::UrlDecode, }; assert_eq!( eval_filter( &filter, &Value::String("https://mozilla.org/?x=%D1%88%D0%B5%D0%BB%D0%BB%D1%8B".to_string()), &variables, false, ) .unwrap() .unwrap(), Value::String("https://mozilla.org/?x=шеллы".to_string()) ); } } hurl-6.1.1/src/runner/filter/url_encode.rs000064400000000000000000000052161046102023000166610ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::SourceInfo; use percent_encoding::AsciiSet; use crate::runner::{RunnerError, RunnerErrorKind, Value}; /// Percent-encodes all the characters in `value` which are not included in unreserved chars /// (see [RFC3986](https://www.rfc-editor.org/rfc/rfc3986)) with the exception of forward slash (/). /// Does not encode forward slash (/) like Jinja template () pub fn eval_url_encode( value: &Value, source_info: SourceInfo, assert: bool, ) -> Result, RunnerError> { match value { Value::String(value) => { const FRAGMENT: &AsciiSet = &percent_encoding::NON_ALPHANUMERIC .remove(b'-') .remove(b'.') .remove(b'_') .remove(b'~') .remove(b'/'); let encoded = percent_encoding::percent_encode(value.as_bytes(), FRAGMENT).to_string(); Ok(Some(Value::String(encoded))) } v => { let kind = RunnerErrorKind::FilterInvalidInput(v.kind().to_string()); Err(RunnerError::new(source_info, kind, assert)) } } } #[cfg(test)] mod tests { use hurl_core::ast::{Filter, FilterValue, SourceInfo}; use hurl_core::reader::Pos; use crate::runner::filter::eval::eval_filter; use crate::runner::{Value, VariableSet}; #[test] fn eval_filter_url_encode() { let variables = VariableSet::new(); let filter = Filter { source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), value: FilterValue::UrlEncode, }; assert_eq!( eval_filter( &filter, &Value::String("https://mozilla.org/?x=шеллы".to_string()), &variables, false, ) .unwrap() .unwrap(), Value::String( "https%3A//mozilla.org/%3Fx%3D%D1%88%D0%B5%D0%BB%D0%BB%D1%8B".to_string() ) ); } } hurl-6.1.1/src/runner/filter/xpath.rs000064400000000000000000000121431046102023000156630ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::{SourceInfo, Template}; use crate::runner::template::eval_template; use crate::runner::xpath::{Document, Format, XPathError}; use crate::runner::{RunnerError, RunnerErrorKind, Value, VariableSet}; /// Evaluates a XPath expression `expr` against a `value`. pub fn eval_xpath( value: &Value, expr: &Template, variables: &VariableSet, source_info: SourceInfo, assert: bool, ) -> Result, RunnerError> { match value { Value::String(xml) => { // The filter will use the HTML parser that should also work with XML input let Ok(doc) = Document::parse(xml, Format::Html) else { return Err(RunnerError::new( source_info, RunnerErrorKind::QueryInvalidXml, false, )); }; eval_xpath_doc(&doc, expr, variables) } v => { let kind = RunnerErrorKind::FilterInvalidInput(v.kind().to_string()); Err(RunnerError::new(source_info, kind, assert)) } } } pub fn eval_xpath_doc( doc: &Document, expr: &Template, variables: &VariableSet, ) -> Result, RunnerError> { let expr_str = eval_template(expr, variables)?; let result = doc.eval_xpath(&expr_str); match result { Ok(value) => Ok(Some(value)), Err(XPathError::Eval) => Err(RunnerError::new( expr.source_info, RunnerErrorKind::QueryInvalidXpathEval, false, )), Err(XPathError::Unsupported) => { panic!("Unsupported xpath {expr}"); // good usecase for panic - I could not reproduce this usecase myself } } } #[cfg(test)] mod tests { use hurl_core::ast::{Filter, FilterValue, SourceInfo, Template, TemplateElement, Whitespace}; use hurl_core::reader::Pos; use hurl_core::typing::ToSource; use super::*; use crate::runner::filter::eval::eval_filter; use crate::runner::VariableSet; /// Helper function to return a new filter given a `expr` fn new_xpath_filter(expr: &str) -> Filter { // Example: xpath "string(//body/text())" Filter { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), value: FilterValue::XPath { space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(6, 1), Pos::new(7, 1)), }, expr: Template::new( None, vec![TemplateElement::String { value: expr.to_string(), source: expr.to_source(), }], SourceInfo::new(Pos::new(7, 1), Pos::new(7 + expr.len(), 1)), ), }, } } #[test] fn eval_filter_xpath_doc_ok() { let variables = VariableSet::new(); let html = "你好世界"; let filter = new_xpath_filter("string(//body/text())"); let ret = eval_filter(&filter, &Value::String(html.to_string()), &variables, false); assert_eq!(ret.unwrap().unwrap(), Value::String("你好世界".to_string())); } #[test] fn eval_filter_xpath_doc_ko_invalid_xpath() { let variables = VariableSet::new(); let html = "你好世界"; let filter = new_xpath_filter("str(//body/text())"); let ret = eval_filter(&filter, &Value::String(html.to_string()), &variables, false); assert_eq!( ret.unwrap_err().kind, RunnerErrorKind::QueryInvalidXpathEval ); } #[test] fn eval_filter_xpath_doc_ko_invalid_xml() { let variables = VariableSet::new(); let html = ""; let filter = new_xpath_filter("string(//body/text())"); let ret = eval_filter(&filter, &Value::String(html.to_string()), &variables, false); assert_eq!(ret.unwrap_err().kind, RunnerErrorKind::QueryInvalidXml); } #[test] fn eval_filter_xpath_doc_ko_invalid_input() { let variables = VariableSet::new(); let filter = new_xpath_filter("string(//body/text())"); let ret = eval_filter( &filter, &Value::Bytes(vec![0xc4, 0xe3, 0xba, 0xc3, 0xca, 0xc0, 0xbd, 0xe7]), &variables, false, ); assert_eq!( ret.unwrap_err().kind, RunnerErrorKind::FilterInvalidInput("bytes".to_string()) ); } } hurl-6.1.1/src/runner/function.rs000064400000000000000000000022471046102023000151030ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use chrono::Utc; use hurl_core::ast::Function; use uuid::Uuid; use crate::runner::error::RunnerError; use crate::runner::value::Value; /// Evaluates the function `function`, returns a [`Value`] on success or an [`RunnerError`] . pub fn eval(function: &Function) -> Result { match &function { Function::NewDate => { let now = Utc::now(); Ok(Value::Date(now)) } Function::NewUuid => { let uuid = Uuid::new_v4(); Ok(Value::String(uuid.to_string())) } } } hurl-6.1.1/src/runner/hurl_file.rs000064400000000000000000000477101046102023000152330ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::thread; use std::time::Instant; use chrono::Utc; use hurl_core::ast::{Entry, OptionKind, SourceInfo}; use hurl_core::error::DisplaySourceError; use hurl_core::input::Input; use hurl_core::parser; use hurl_core::typing::Count; use crate::http::{Call, Client}; use crate::runner::event::EventListener; use crate::runner::runner_options::RunnerOptions; use crate::runner::{entry, options, EntryResult, HurlResult, VariableSet}; use crate::util::logger::{ErrorFormat, Logger, LoggerOptions}; use crate::util::term::{Stderr, Stdout, WriteMode}; /// Runs a Hurl `content` and returns a [`HurlResult`] upon completion. /// /// If `content` is a syntactically correct Hurl file, an [`HurlResult`] is always returned on /// run completion. The success or failure of the run (due to assertions checks, runtime failures /// etc...) can be read in the [`HurlResult`] `success` field. If `content` is not syntactically /// correct, a parsing error is returned. This is the only possible way for this function to fail. /// /// `filename` indicates an optional file source, used when displaying errors. /// /// # Example /// /// ``` /// use std::collections::HashMap; /// use hurl::runner; /// use hurl::runner::{Value, RunnerOptionsBuilder, VariableSet}; /// use hurl::util::logger::{LoggerOptionsBuilder, Verbosity}; /// use hurl_core::input::Input; /// /// // A simple Hurl sample /// let content = r#" /// GET http://localhost:8000/hello /// HTTP 200 /// "#; /// /// let filename = Input::new("sample.hurl"); /// /// // Define runner and logger options /// let runner_opts = RunnerOptionsBuilder::new() /// .follow_location(true) /// .build(); /// let logger_opts = LoggerOptionsBuilder::new() /// .verbosity(Some(Verbosity::Verbose)) /// .build(); /// /// // Set variables /// let mut variables = VariableSet::new(); /// variables.insert("name".to_string(), Value::String("toto".to_string())).unwrap(); /// /// // Run the Hurl sample /// let result = runner::run( /// content, /// Some(filename).as_ref(), /// &runner_opts, /// &variables, /// &logger_opts /// ); /// assert!(result.unwrap().success); /// ``` pub fn run( content: &str, filename: Option<&Input>, runner_options: &RunnerOptions, variables: &VariableSet, logger_options: &LoggerOptions, ) -> Result { // In this method, we run Hurl content sequentially. Standard output and standard error messages // are written immediately (in parallel mode, we'll use buffered standard output and error). let mut stdout = Stdout::new(WriteMode::Immediate); let stderr = Stderr::new(WriteMode::Immediate); // We also create a common logger for this run (logger verbosity can eventually be mutated on // each entry). let secrets = variables.secrets(); let mut logger = Logger::new(logger_options, stderr, &secrets); // Try to parse the content let hurl_file = parser::parse_hurl_file(content); let hurl_file = match hurl_file { Ok(h) => h, Err(e) => { logger.error_parsing_rich(content, filename, &e); return Err(e.description()); } }; // Now, we have a syntactically correct HurlFile instance, we can run it. let result = run_entries( &hurl_file.entries, content, filename, runner_options, variables, &mut stdout, None, &mut logger, ); if result.success && result.entries.last().is_none() { let filename = filename.map_or(String::new(), |f| f.to_string()); logger.warning(&format!("No entry have been executed for file {filename}")); } Ok(result) } #[allow(clippy::too_many_arguments)] /// Runs a list of `entries` and returns a [`HurlResult`] upon completion. /// /// `content` is the original source content, used to construct `entries`. It is used to construct /// rich error messages with annotated source code. /// New entry run events are reported to `progress` and are usually used to display a progress bar /// in test mode. pub fn run_entries( entries: &[Entry], content: &str, filename: Option<&Input>, runner_options: &RunnerOptions, variables: &VariableSet, stdout: &mut Stdout, listener: Option<&dyn EventListener>, logger: &mut Logger, ) -> HurlResult { let mut http_client = Client::new(); let mut entries_result = vec![]; let mut variables = variables.clone(); let mut entry_index = runner_options.from_entry.unwrap_or(1); let mut repeat_count = 0; let n = runner_options.to_entry.unwrap_or(entries.len()); let default_verbosity = logger.verbosity; let start = Instant::now(); let timestamp = Utc::now().timestamp(); // Warn deprecations if runner_options.pre_entry.is_some() { logger.warning( "--interactive mode is now deprecated, it will be removed in next Hurl versions", ); } if entries .iter() .any(|e| e.use_multiline_string_body_with_attributes()) { logger.warning( "multilines string attributes are now deprecated, they will be removed in next Hurl versions", ); } log_run_info(entries, runner_options, &variables, logger); // Main loop processing each entry. // The `entry_index` is not always incremented of each loop tick: an entry can be retried upon // errors for instance. Each entry is executed with options that are computed from the global // runner options and the "overridden" request options. // See loop { if entry_index > n { break; } let entry = &entries[entry_index - 1]; if let Some(pre_entry) = runner_options.pre_entry { let exit = pre_entry(entry); if exit { break; } } // We compute the new logger verbosity for this entry, before entering into the `run` // function because entry options can modify the logger verbosity and we want the preamble // "Executing entry..." to be displayed based on the entry level verbosity. logger.verbosity = default_verbosity; let entry_verbosity = options::get_entry_verbosity(entry, default_verbosity, &variables); if let Ok(entry_verbosity) = entry_verbosity { logger.verbosity = entry_verbosity; } log_run_entry(entry_index, logger); // We can report the progression of the run for --test mode. if let Some(listener) = listener { listener.on_running(entry_index - 1, n); } // The real execution of the entry happens here, first: we compute the overridden request // options. let options = options::get_entry_options(entry, runner_options, &mut variables, logger); if let Err(error) = &options { // If we have error evaluating request options, we consider it as a non retryable error // and either break the runner or go to the next entries. let entry_result = EntryResult { entry_index, source_info: entry.source_info(), errors: vec![error.clone()], ..Default::default() }; log_errors(&entry_result, content, filename, false, logger); entries_result.push(entry_result); if runner_options.continue_on_error { entry_index += 1; continue; } else { break; } } let options = options.unwrap(); // Should we skip? if options.skip { logger.debug(""); logger.debug_important(&format!("Entry {entry_index} has been skipped")); entry_index += 1; continue; } // Repeat 0 is equivalent to skip. if options.repeat == Some(Count::Finite(0)) { logger.debug(""); logger.debug_important(&format!("Entry {entry_index} is skipped (repeat 0 times)")); entry_index += 1; continue; } // Should we delay? let delay = options.delay; let delay_ms = delay.as_millis(); if delay_ms > 0 { logger.debug(""); logger.debug_important(&format!("Delay entry {entry_index} (pause {delay_ms} ms)")); thread::sleep(delay); }; // Loop for executing HTTP run requests, with optional retry. Only "HTTP" errors in options // are taken into account for retry (errors while computing entry options and output error // are not retried). let results = run_request( entry, entry_index, content, filename, &mut http_client, &options, &mut variables, stdout, logger, ); let has_error = results.last().is_some_and(|r| !r.errors.is_empty()); entries_result.extend(results); if let Some(post_entry) = runner_options.post_entry { let exit = post_entry(); if exit { break; } } if !runner_options.continue_on_error && has_error { break; } // We pass to the next entry if the repeat count is reached. repeat_count += 1; match options.repeat { None => { repeat_count = 0; entry_index += 1; } Some(Count::Finite(n)) => { if repeat_count >= n { repeat_count = 0; entry_index += 1; } else { logger.debug_important(&format!( "Repeat entry {entry_index} (x{repeat_count}/{n})" )); } } Some(Count::Infinite) => { logger.debug_important(&format!("Repeat entry {entry_index} (x{repeat_count})")); } } } let duration = start.elapsed(); let cookies = http_client.cookie_storage(logger); let success = is_success(&entries_result); HurlResult { entries: entries_result, duration, success, cookies, timestamp, variables, } } /// Runs an HTTP request and optional retry it until there are no HTTP errors. Returns a list of /// [`EntryResult`]. #[allow(clippy::too_many_arguments)] fn run_request( entry: &Entry, entry_index: usize, content: &str, filename: Option<&Input>, http_client: &mut Client, options: &RunnerOptions, variables: &mut VariableSet, stdout: &mut Stdout, logger: &mut Logger, ) -> Vec { let mut results = vec![]; let mut retry_count = 1; loop { let mut result = entry::run(entry, entry_index, http_client, variables, options, logger); // Check if we need to retry. let mut has_error = !result.errors.is_empty(); // The retry threshold can only be reached with a finite positive number of retries let retry_max_reached = if let Some(Count::Finite(r)) = options.retry { retry_count > r } else { false }; // If `retry_max_reached` is true, we print now a warning, before displaying any assert // error so any potential error is the last thing displayed to the user. // If `retry_max_reached` is not true (for instance `retry`is true, or there is no error // we first log the error and a potential warning about retrying. if retry_max_reached { logger.debug_important("Retry max count reached, no more retry"); logger.debug(""); } // We log eventual errors, only if we're not retrying the current entry... // The retry does not take into account a possible output Error let retry = options.retry.is_some() && !retry_max_reached && has_error; // When --output is overridden on a request level, we output the HTTP response only if the // call has succeeded. Output errors are not taken into account for retrying requests. if let Some(output) = &options.output { if !has_error { let source_info = get_output_source_info(entry); if let Err(error) = result.write_response(output, &options.context_dir, stdout, source_info) { result.errors.push(error); has_error = true; } } } if has_error { log_errors(&result, content, filename, retry, logger); } results.push(result); // No retry, we leave the HTTP run requests loop. if !retry { break; } let delay = options.retry_interval.as_millis(); logger.debug(""); logger.debug_important(&format!( "Retry entry {entry_index} (x{retry_count} pause {delay} ms)" )); retry_count += 1; // If we retry the entry, we do not want to display a 'blank' progress bar during the // sleep delay. During the pause, we artificially show the previously erased progress // line. thread::sleep(options.retry_interval); // TODO: We keep this log because we don't want to change stderr with the changes // introduced by log_run_entry(entry_index, logger); } results } /// Use source_info from output option if this option has been defined fn get_output_source_info(entry: &Entry) -> SourceInfo { let mut source_info = entry.source_info(); for option_entry in entry.request.options() { if let OptionKind::Output(value) = &option_entry.kind { source_info = value.source_info; } } source_info } /// Returns `true` if all the entries results are successful, `false` otherwise. /// /// For a given list of entry results, only the last one on the same index is checked. /// /// For instance: /// /// - `entry result a`: `entry index 1` (retried) /// - `entry result b`: `entry index 1` /// - `entry result c`: `entry index 2` /// - `entry result d`: `entry index 3` (retried) /// - `entry result e`: `entry index 3` /// /// Only the entry result b, c and e are checked for the success state. fn is_success(entries: &[EntryResult]) -> bool { let mut next_entries = entries.iter().skip(1); for entry in entries.iter() { match next_entries.next() { None => return entry.errors.is_empty(), Some(next) => { if next.entry_index != entry.entry_index && !entry.errors.is_empty() { return false; } } } } true } // Returns the list of options that have non-default values. fn get_non_default_options(options: &RunnerOptions) -> Vec<(&'static str, String)> { let default_options = RunnerOptions::default(); let mut non_default_options = vec![]; if options.continue_on_error != default_options.continue_on_error { non_default_options.push(("continue_on_error", options.continue_on_error.to_string())); } if options.delay != default_options.delay { // FIXME: the cast to u64 seems not necessary. // If we dont cast from u128 and try to format! or println! // we have a segfault on Alpine Docker images and Rust 1.68.0, whereas it was // ok with Rust >= 1.67.0. non_default_options.push(("delay", format!("{}ms", options.delay.as_millis() as u64))); } if options.follow_location != default_options.follow_location { non_default_options.push(("follow redirect", options.follow_location.to_string())); } if options.insecure != default_options.insecure { non_default_options.push(("insecure", options.insecure.to_string())); } if options.max_redirect != default_options.max_redirect { non_default_options.push(("max redirect", options.max_redirect.to_string())); } if options.proxy != default_options.proxy { if let Some(proxy) = &options.proxy { non_default_options.push(("proxy", proxy.to_string())); } } if options.retry != default_options.retry { let value = match options.retry { Some(retry) => retry.to_string(), None => "none".to_string(), }; non_default_options.push(("retry", value)); } if options.unix_socket != default_options.unix_socket { if let Some(unix_socket) = &options.unix_socket { non_default_options.push(("unix socket", unix_socket.to_string())); } } non_default_options } /// Logs various debug information at the start of `hurl_file` run. fn log_run_info( entries: &[Entry], runner_options: &RunnerOptions, variables: &VariableSet, logger: &mut Logger, ) { if logger.verbosity.is_some() { let non_default_options = get_non_default_options(runner_options); if !non_default_options.is_empty() { logger.debug_important("Options:"); for (name, value) in non_default_options.iter() { logger.debug(&format!(" {name}: {value}")); } } } let variables = variables .iter() .filter(|(_, variable)| !variable.is_secret()) .collect::>(); if !variables.is_empty() { logger.debug_important("Variables:"); for (name, variable) in variables.iter() { logger.debug(&format!(" {name}: {}", variable.value())); } } if let Some(to_entry) = runner_options.to_entry { logger.debug(&format!("Executing {to_entry}/{} entries", entries.len())); } } /// Logs runner `errors`. /// If we're going to `retry` the entry, we log errors only in verbose. Otherwise, we log error on stderr. fn log_errors( entry_result: &EntryResult, content: &str, filename: Option<&Input>, retry: bool, logger: &mut Logger, ) { if retry { entry_result.errors.iter().for_each(|error| { logger.debug_error(content, filename, error, entry_result.source_info); }); return; } if logger.error_format == ErrorFormat::Long { if let Some(Call { response, .. }) = entry_result.calls.last() { response.log_info_all(logger); } } entry_result.errors.iter().for_each(|error| { logger.error_runtime_rich(content, filename, error, entry_result.source_info); }); } /// Logs the header indicating the begin of the entry run. fn log_run_entry(entry_index: usize, logger: &mut Logger) { logger.debug_important( "------------------------------------------------------------------------------", ); logger.debug_important(&format!("Executing entry {entry_index}")); } #[cfg(test)] mod test { use super::*; use crate::runner::RunnerOptionsBuilder; #[test] fn get_non_default_options_returns_empty_when_default() { let options = RunnerOptions::default(); assert!(get_non_default_options(&options).is_empty()); } #[test] fn get_non_default_options_returns_only_non_default_options() { let options = RunnerOptionsBuilder::new() .delay(std::time::Duration::from_millis(500)) .build(); let non_default_options = get_non_default_options(&options); assert_eq!(non_default_options.len(), 1); let first_non_default = non_default_options.first().unwrap(); assert_eq!(first_non_default.0, "delay"); assert_eq!(first_non_default.1, "500ms"); } } hurl-6.1.1/src/runner/json.rs000064400000000000000000000403151046102023000142250ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::{ JsonListElement, JsonObjectElement, JsonValue, Placeholder, Template, TemplateElement, }; use hurl_core::parser::{parse_json_boolean, parse_json_null, parse_json_number}; use hurl_core::reader::Reader; use super::template::eval_template; use crate::runner::error::{RunnerError, RunnerErrorKind}; use crate::runner::{expr, VariableSet}; /// Evaluates a JSON value to a string given a set of `variables`. /// If `keep_whitespace` is true, whitespace is preserved from the JSonValue, otherwise /// it is trimmed. pub fn eval_json_value( json_value: &JsonValue, variables: &VariableSet, keep_whitespace: bool, ) -> Result { match json_value { JsonValue::Null => Ok("null".to_string()), JsonValue::Number(s) => Ok(s.clone()), JsonValue::String(template) => { let s = eval_json_template(template, variables)?; Ok(format!("\"{s}\"")) } JsonValue::Boolean(v) => Ok(v.to_string()), JsonValue::List { space0, elements } => { let mut elems_string = vec![]; for element in elements { let s = eval_json_list_element(element, variables, keep_whitespace)?; elems_string.push(s); } if keep_whitespace { Ok(format!("[{}{}]", space0, elems_string.join(","))) } else { Ok(format!("[{}]", elems_string.join(","))) } } JsonValue::Object { space0, elements } => { let mut elems_string = vec![]; for element in elements { let s = eval_json_object_element(element, variables, keep_whitespace)?; elems_string.push(s); } if keep_whitespace { Ok(format!("{{{}{}}}", space0, elems_string.join(","))) } else { Ok(format!("{{{}}}", elems_string.join(","))) } } JsonValue::Placeholder(Placeholder { expr, .. }) => { let s = expr::render(expr, variables)?; // The String can only be null, a bool, a number // It will be easier when your variables value have a type let mut reader = Reader::new(s.as_str()); let start = reader.cursor(); if parse_json_number(&mut reader).is_ok() { return Ok(s); } reader.seek(start); if parse_json_boolean(&mut reader).is_ok() { return Ok(s); } reader.seek(start); if parse_json_null(&mut reader).is_ok() { return Ok(s); } let kind = RunnerErrorKind::InvalidJson { value: s }; Err(RunnerError::new(expr.source_info, kind, false)) } } } /// Evaluates a JSON list to a string given a set of `variables`. /// If `keep_whitespace` is true, whitespace is preserved from the JSonValue, otherwise /// it is trimmed. fn eval_json_list_element( element: &JsonListElement, variables: &VariableSet, keep_whitespace: bool, ) -> Result { let s = eval_json_value(&element.value, variables, keep_whitespace)?; if keep_whitespace { Ok(format!("{}{}{}", element.space0, s, element.space1)) } else { Ok(s) } } /// Renders a JSON object to a string given a set of `variables`. /// If `keep_whitespace` is true, whitespace is preserved from the JSonValue, otherwise /// it is trimmed. fn eval_json_object_element( element: &JsonObjectElement, variables: &VariableSet, keep_whitespace: bool, ) -> Result { let name = eval_template(&element.name, variables)?; let value = eval_json_value(&element.value, variables, keep_whitespace)?; if keep_whitespace { Ok(format!( "{}\"{}\"{}:{}{}{}", element.space0, name, element.space1, element.space2, value, element.space3 )) } else { Ok(format!("\"{}\":{}", element.name, value)) } } /// Evaluates a JSON template to a string given a set of `variables` /// /// # Example /// /// The template "Hello {{quote}}" with variable quote=" /// will be evaluated to the JSON String "Hello \"" pub fn eval_json_template( template: &Template, variables: &VariableSet, ) -> Result { let Template { elements, .. } = template; { let mut value = String::new(); for elem in elements { match eval_json_template_element(elem, variables) { Ok(v) => value.push_str(v.as_str()), Err(e) => return Err(e), } } Ok(value) } } fn eval_json_template_element( template_element: &TemplateElement, variables: &VariableSet, ) -> Result { match template_element { TemplateElement::String { source, .. } => Ok(source.to_string()), TemplateElement::Placeholder(Placeholder { expr, .. }) => { let s = expr::render(expr, variables)?; Ok(encode_json_string(&s)) } } } fn encode_json_string(s: &str) -> String { s.chars().map(encode_json_char).collect() } fn encode_json_char(c: char) -> String { match c { '"' => "\\\"".to_string(), '\\' => "\\\\".to_string(), '\n' => "\\n".to_string(), '\r' => "\\r".to_string(), '\t' => "\\t".to_string(), c => c.to_string(), } } #[cfg(test)] mod tests { use hurl_core::ast::*; use hurl_core::reader::Pos; use hurl_core::typing::ToSource; use super::super::error::RunnerErrorKind; use super::*; use crate::runner::Value; pub fn json_hello_world_value() -> JsonValue { // "hello\u0020{{name}}!" JsonValue::String(Template::new( Some('"'), vec![ TemplateElement::String { value: "Hello ".to_string(), source: "Hello\\u0020".to_source(), }, TemplateElement::Placeholder(Placeholder { space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(1, 15), Pos::new(1, 15)), }, expr: Expr { kind: ExprKind::Variable(Variable { name: "name".to_string(), source_info: SourceInfo::new(Pos::new(1, 15), Pos::new(1, 19)), }), source_info: SourceInfo::new(Pos::new(1, 15), Pos::new(1, 19)), }, space1: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(1, 19), Pos::new(1, 19)), }, }), TemplateElement::String { value: "!".to_string(), source: "!".to_source(), }, ], SourceInfo::new(Pos::new(1, 2), Pos::new(1, 22)), )) } pub fn json_person_value() -> JsonValue { JsonValue::Object { space0: "\n ".to_string(), elements: vec![JsonObjectElement { space0: String::new(), name: Template::new( None, vec![TemplateElement::String { value: "firstName".to_string(), source: "firstName".to_source(), }], SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), ), space1: String::new(), space2: " ".to_string(), value: JsonValue::String(Template::new( None, vec![TemplateElement::String { value: "John".to_string(), source: "John".to_source(), }], SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), )), space3: "\n".to_string(), }], } } #[test] fn test_scalar_value() { let mut variables = VariableSet::new(); variables .insert("name".to_string(), Value::String("Bob".to_string())) .unwrap(); assert_eq!( eval_json_value(&JsonValue::Null, &variables, true).unwrap(), "null".to_string() ); assert_eq!( eval_json_value(&JsonValue::Number("3.14".to_string()), &variables, true).unwrap(), "3.14".to_string() ); assert_eq!( eval_json_value(&JsonValue::Boolean(false), &variables, true).unwrap(), "false".to_string() ); assert_eq!( eval_json_value(&json_hello_world_value(), &variables, true).unwrap(), "\"Hello\\u0020Bob!\"".to_string() ); } #[test] fn test_error() { let variables = VariableSet::new(); let error = eval_json_value(&json_hello_world_value(), &variables, true) .err() .unwrap(); assert_eq!( error.source_info, SourceInfo::new(Pos::new(1, 15), Pos::new(1, 19)) ); assert_eq!( error.kind, RunnerErrorKind::TemplateVariableNotDefined { name: "name".to_string() } ); } #[test] fn test_list_value() { let mut variables = VariableSet::new(); variables .insert("name".to_string(), Value::String("Bob".to_string())) .unwrap(); assert_eq!( eval_json_value( &JsonValue::List { space0: String::new(), elements: vec![], }, &variables, true, ) .unwrap(), "[]".to_string() ); assert_eq!( eval_json_value( &JsonValue::List { space0: String::new(), elements: vec![ JsonListElement { space0: String::new(), value: JsonValue::Number("1".to_string()), space1: String::new(), }, JsonListElement { space0: " ".to_string(), value: JsonValue::Number("-2".to_string()), space1: String::new(), }, JsonListElement { space0: " ".to_string(), value: JsonValue::Number("3.0".to_string()), space1: String::new(), }, ], }, &variables, true ) .unwrap(), "[1, -2, 3.0]".to_string() ); let template = Template::new( Some('"'), vec![TemplateElement::String { value: "Hi".to_string(), source: "Hi".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ); assert_eq!( eval_json_value( &JsonValue::List { space0: String::new(), elements: vec![ JsonListElement { space0: String::new(), value: JsonValue::String(template), space1: String::new(), }, JsonListElement { space0: " ".to_string(), value: json_hello_world_value(), space1: String::new(), }, ], }, &variables, true ) .unwrap(), "[\"Hi\", \"Hello\\u0020Bob!\"]".to_string() ); } #[test] fn test_object_value() { let variables = VariableSet::new(); assert_eq!( eval_json_value( &JsonValue::Object { space0: String::new(), elements: vec![], }, &variables, true ) .unwrap(), "{}".to_string() ); assert_eq!( eval_json_value(&json_person_value(), &variables, true).unwrap(), r#"{ "firstName": "John" }"# .to_string() ); } #[test] fn test_escape_sequence() { let variables = VariableSet::new(); assert_eq!( eval_json_value( &JsonValue::String(Template::new( None, vec![TemplateElement::String { value: "\n".to_string(), source: "\\n".to_source(), }], SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)) )), &variables, true ) .unwrap(), "\"\\n\"".to_string() ); } #[test] fn test_eval_json_template() { let variables = VariableSet::new(); assert_eq!( eval_json_template( &Template::new( None, vec![TemplateElement::String { value: "\n".to_string(), source: "\\n".to_source(), }], SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), ), &variables, ) .unwrap(), "\\n".to_string() ); let mut variables = VariableSet::new(); variables .insert("quote".to_string(), Value::String("\"".to_string())) .unwrap(); assert_eq!( eval_json_template( &Template::new( Some('"'), vec![ TemplateElement::String { value: "Hello ".to_string(), source: "Hello ".to_source(), }, TemplateElement::Placeholder(Placeholder { space0: whitespace(), expr: Expr { kind: ExprKind::Variable(Variable { name: "quote".to_string(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }, space1: whitespace(), }), ], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), &variables, ) .unwrap(), "Hello \\\"".to_string() ); } fn whitespace() -> Whitespace { Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), } } #[test] fn test_encode_json_string() { assert_eq!(encode_json_string("a"), "a"); assert_eq!(encode_json_string("\""), "\\\""); assert_eq!(encode_json_string("\\"), "\\\\"); } #[test] fn test_not_preserving_spaces() { let variables = VariableSet::new(); assert_eq!( eval_json_value(&json_person_value(), &variables, false).unwrap(), r#"{"firstName":"John"}"#.to_string() ); } } hurl-6.1.1/src/runner/mod.rs000064400000000000000000000031231046102023000140270ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ //! A runner for Hurl files. If you want to execute an Hurl file, this is the right place. pub use self::error::{RunnerError, RunnerErrorKind}; #[doc(hidden)] pub use self::event::EventListener; pub use self::hurl_file::run; #[doc(hidden)] pub use self::hurl_file::run_entries; pub use self::number::Number; pub use self::output::Output; pub use self::result::{AssertResult, CaptureResult, EntryResult, HurlResult}; pub use self::runner_options::{RunnerOptions, RunnerOptionsBuilder}; pub use self::value::{EvalError, Value}; pub use self::variable::{Variable, VariableSet, Visibility}; mod assert; mod body; mod cache; mod capture; mod diff; mod entry; mod error; mod event; mod expr; mod filter; mod function; mod hurl_file; mod json; mod multiline; mod multipart; mod number; mod options; mod output; mod predicate; mod predicate_value; mod query; mod regex; mod request; mod response; mod result; mod runner_options; mod template; mod value; mod value_impl; mod variable; mod xpath; hurl-6.1.1/src/runner/multiline.rs000064400000000000000000000150361046102023000152600ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::{MultilineString, MultilineStringKind}; use serde_json::json; use crate::runner::json::eval_json_value; use crate::runner::template::eval_template; use crate::runner::{RunnerError, VariableSet}; /// Renders to string a multiline body, given a set of variables. pub fn eval_multiline( multiline: &MultilineString, variables: &VariableSet, ) -> Result { match &multiline.kind { MultilineStringKind::Text(value) | MultilineStringKind::Json(value) | MultilineStringKind::Xml(value) => { let s = eval_template(value, variables)?; Ok(s) } MultilineStringKind::GraphQl(graphql) => { let query = eval_template(&graphql.value, variables)?; let body = match &graphql.variables { None => json!({ "query": query.trim()}).to_string(), Some(vars) => { let s = eval_json_value(&vars.value, variables, false)?; let query = json!(query.trim()); format!(r#"{{"query":{query},"variables":{s}}}"#) } }; Ok(body) } } } #[cfg(test)] mod tests { use hurl_core::ast::{ GraphQl, GraphQlVariables, JsonObjectElement, JsonValue, MultilineString, MultilineStringKind, SourceInfo, Template, TemplateElement, Whitespace, }; use hurl_core::reader::Pos; use hurl_core::typing::ToSource; use crate::runner::multiline::eval_multiline; use crate::runner::VariableSet; fn whitespace() -> Whitespace { Whitespace { value: String::from(" "), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), } } fn newline() -> Whitespace { Whitespace { value: String::from("\n"), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), } } fn empty_source_info() -> SourceInfo { SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)) } #[test] fn eval_graphql_multiline_simple() { let query = r#"{ human(id: "1000") { name height(unit: FOOT) } }"#; let variables = VariableSet::new(); let multiline = MultilineString { attributes: vec![], space: whitespace(), newline: newline(), kind: MultilineStringKind::GraphQl(GraphQl { value: Template::new( None, vec![TemplateElement::String { value: query.to_string(), source: query.to_source(), }], empty_source_info(), ), variables: None, }), }; let body = eval_multiline(&multiline, &variables).unwrap(); assert_eq!( body, r#"{"query":"{\n human(id: \"1000\") {\n name\n height(unit: FOOT)\n }\n}"}"# .to_string() ); } #[test] fn eval_graphql_multiline_with_graphql_variables() { let query = r#"{ human(id: "1000") { name height(unit: FOOT) } }"#; let hurl_variables = VariableSet::new(); let graphql_variables = GraphQlVariables { space: whitespace(), value: JsonValue::Object { space0: String::new(), elements: vec![ JsonObjectElement { space0: String::new(), name: Template::new( Some('"'), vec![TemplateElement::String { value: "episode".to_string(), source: "episode".to_source(), }], empty_source_info(), ), space1: String::new(), space2: String::new(), value: JsonValue::String(Template::new( Some('"'), vec![TemplateElement::String { value: "JEDI".to_string(), source: "JEDI".to_source(), }], empty_source_info(), )), space3: String::new(), }, JsonObjectElement { space0: String::new(), name: Template::new( Some('"'), vec![TemplateElement::String { value: "withFriends".to_string(), source: "withFriends".to_source(), }], empty_source_info(), ), space1: String::new(), space2: String::new(), value: JsonValue::Boolean(false), space3: String::new(), }, ], }, whitespace: whitespace(), }; let multiline = MultilineString { attributes: vec![], space: whitespace(), newline: newline(), kind: MultilineStringKind::GraphQl(GraphQl { value: Template::new( None, vec![TemplateElement::String { value: query.to_string(), source: query.to_source(), }], empty_source_info(), ), variables: Some(graphql_variables), }), }; let body = eval_multiline(&multiline, &hurl_variables).unwrap(); assert_eq!(body, r#"{"query":"{\n human(id: \"1000\") {\n name\n height(unit: FOOT)\n }\n}","variables":{"episode":"JEDI","withFriends":false}}"#.to_string()); } } hurl-6.1.1/src/runner/multipart.rs000064400000000000000000000261771046102023000153070ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::ffi::OsStr; use std::path::Path; use hurl_core::ast::{FileParam, FileValue, KeyValue, MultipartParam}; use crate::http; use crate::runner::body::eval_file; use crate::runner::error::RunnerError; use crate::runner::template::eval_template; use crate::runner::VariableSet; use crate::util::path::ContextDir; pub fn eval_multipart_param( multipart_param: &MultipartParam, variables: &VariableSet, context_dir: &ContextDir, ) -> Result { match multipart_param { MultipartParam::Param(KeyValue { key, value, .. }) => { let name = eval_template(key, variables)?; let value = eval_template(value, variables)?; Ok(http::MultipartParam::Param(http::Param { name, value })) } MultipartParam::FileParam(param) => { let file_param = eval_file_param(param, context_dir, variables)?; Ok(http::MultipartParam::FileParam(file_param)) } } } pub fn eval_file_param( file_param: &FileParam, context_dir: &ContextDir, variables: &VariableSet, ) -> Result { let name = eval_template(&file_param.key, variables)?; let filename = eval_template(&file_param.value.filename, variables)?; let data = eval_file(&file_param.value.filename, variables, context_dir)?; let content_type = file_value_content_type(&file_param.value, variables)?; Ok(http::FileParam { name, filename, data, content_type, }) } pub fn file_value_content_type( file_value: &FileValue, variables: &VariableSet, ) -> Result { let value = match &file_value.content_type { Some(content_type) => eval_template(content_type, variables)?, None => { let value = eval_template(&file_value.filename, variables)?; match Path::new(value.as_str()) .extension() .and_then(OsStr::to_str) { Some("gif") => "image/gif".to_string(), Some("jpg") => "image/jpeg".to_string(), Some("jpeg") => "image/jpeg".to_string(), Some("png") => "image/png".to_string(), Some("svg") => "image/svg+xml".to_string(), Some("txt") => "text/plain".to_string(), Some("htm") => "text/html".to_string(), Some("html") => "text/html".to_string(), Some("pdf") => "application/pdf".to_string(), Some("xml") => "application/xml".to_string(), _ => "application/octet-stream".to_string(), } } }; Ok(value) } #[cfg(test)] mod tests { use super::*; use crate::runner::Value; use hurl_core::ast::{ Expr, ExprKind, LineTerminator, Placeholder, SourceInfo, Template, TemplateElement, Variable, Whitespace, }; use hurl_core::reader::Pos; use hurl_core::typing::ToSource; pub fn whitespace() -> Whitespace { Whitespace { value: String::from(" "), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), } } #[test] pub fn test_eval_file_param() { let line_terminator = LineTerminator { space0: whitespace(), comment: None, newline: whitespace(), }; let current_dir = std::env::current_dir().unwrap(); let file_root = Path::new("tests"); let context_dir = ContextDir::new(current_dir.as_path(), file_root); let variables = VariableSet::default(); let param = eval_file_param( &FileParam { line_terminators: vec![], space0: whitespace(), key: Template::new( None, vec![TemplateElement::String { value: "upload1".to_string(), source: "upload1".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), space1: whitespace(), space2: whitespace(), value: FileValue { space0: whitespace(), filename: Template::new( None, vec![TemplateElement::String { value: "hello.txt".to_string(), source: "hello.txt".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), space1: whitespace(), space2: whitespace(), content_type: None, }, line_terminator0: line_terminator, }, &context_dir, &variables, ) .unwrap(); assert_eq!( param, http::FileParam { name: "upload1".to_string(), filename: "hello.txt".to_string(), data: b"Hello World!".to_vec(), content_type: "text/plain".to_string(), } ); } #[test] pub fn test_file_value_content_type() { let mut variables = VariableSet::default(); variables .insert( "ct".to_string(), Value::String("application/json".to_string()), ) .unwrap(); assert_eq!( file_value_content_type( &FileValue { space0: whitespace(), filename: Template::new( None, vec![TemplateElement::String { value: "hello.txt".to_string(), source: "hello.txt".to_source() }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)) ), space1: whitespace(), space2: whitespace(), content_type: None, }, &variables ) .unwrap(), "text/plain".to_string() ); assert_eq!( file_value_content_type( &FileValue { space0: whitespace(), filename: Template::new( None, vec![TemplateElement::String { value: "hello.html".to_string(), source: "hello.html".to_source() }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), space1: whitespace(), space2: whitespace(), content_type: None, }, &variables ) .unwrap(), "text/html".to_string() ); assert_eq!( file_value_content_type( &FileValue { space0: whitespace(), filename: Template::new( None, vec![TemplateElement::String { value: "hello.txt".to_string(), source: "hello.txt".to_source() }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), space1: whitespace(), space2: whitespace(), content_type: Some(Template::new( None, vec![TemplateElement::String { value: "text/html".to_string(), source: "text/html".to_source() }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), )) }, &variables ) .unwrap(), "text/html".to_string() ); assert_eq!( file_value_content_type( &FileValue { space0: whitespace(), filename: Template::new( None, vec![TemplateElement::String { value: "hello".to_string(), source: "hello".to_source() }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), space1: whitespace(), space2: whitespace(), content_type: None, }, &variables ) .unwrap(), "application/octet-stream".to_string() ); assert_eq!( file_value_content_type( &FileValue { space0: whitespace(), filename: Template::new( None, vec![TemplateElement::String { value: "hello.txt".to_string(), source: "hello.txt".to_source() }], SourceInfo::new(Pos::new(1, 1), Pos::new(1, 9)), ), space1: whitespace(), space2: whitespace(), content_type: Some(Template::new( None, vec![TemplateElement::Placeholder(Placeholder { space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(1, 9), Pos::new(1, 9)), }, expr: Expr { kind: ExprKind::Variable(Variable { name: "ct".to_string(), source_info: SourceInfo::new(Pos::new(1, 11), Pos::new(1, 13)), }), source_info: SourceInfo::new(Pos::new(1, 9), Pos::new(1, 15)), }, space1: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(1, 15), Pos::new(1, 15)), }, })], SourceInfo::new(Pos::new(1, 9), Pos::new(1, 15)), )) }, &variables ) .unwrap(), "application/json".to_string() ); } } hurl-6.1.1/src/runner/number.rs000064400000000000000000000172051046102023000145460ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::cmp::Ordering; use std::fmt; /// System types used in Hurl. /// /// Values are used by queries, captures, asserts and predicates. #[derive(Clone, Debug)] pub enum Number { Float(f64), Integer(i64), BigInteger(String), } // You must implement it yourself because of the Float impl PartialEq for Number { fn eq(&self, other: &Self) -> bool { match (self, other) { (Number::Float(v1), Number::Float(v2)) => (v1 - v2).abs() < f64::EPSILON, (Number::Integer(v1), Number::Integer(v2)) => v1 == v2, (Number::BigInteger(v1), Number::BigInteger(v2)) => v1 == v2, _ => false, } } } impl Eq for Number {} impl fmt::Display for Number { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let value = match self { Number::Float(f) => format_float(*f), Number::Integer(x) => x.to_string(), Number::BigInteger(s) => s.to_string(), }; write!(f, "{value}") } } fn format_float(value: f64) -> String { if value.fract() < f64::EPSILON { format!("{value}.0") } else { value.to_string() } } impl From for Number { fn from(value: f64) -> Self { Number::Float(value) } } impl From for Number { fn from(value: i64) -> Self { Number::Integer(value) } } impl Number { pub fn cmp_value(&self, other: &Number) -> Ordering { match (self, other) { (Number::Integer(i1), Number::Integer(i2)) => i1.cmp(i2), (Number::Float(f1), Number::Float(f2)) => compare_float(*f1, *f2), (Number::Integer(i1), Number::Float(f2)) => compare_float(*i1 as f64, *f2), (Number::Float(f1), Number::Integer(i2)) => compare_float(*f1, *i2 as f64), (n1, n2) => compare_number_string(&n1.to_string(), &n2.to_string()), } } } fn compare_float(f1: f64, f2: f64) -> Ordering { if f1 > f2 { Ordering::Greater } else if f1 < f2 { Ordering::Less } else { Ordering::Equal } } fn compare_number_string(n1: &str, n2: &str) -> Ordering { let (neg1, i1, d1) = number_components(n1); let (neg2, i2, d2) = number_components(n2); if neg1 == neg2 { match i1.cmp(i2) { Ordering::Less => Ordering::Less, Ordering::Greater => Ordering::Greater, Ordering::Equal => d1.cmp(d2), } } else if neg1 { Ordering::Less } else { Ordering::Greater } } // return triple (negative, integer, decimals) fn number_components(s: &str) -> (bool, &str, &str) { match s.strip_prefix('-') { None => match s.find('.') { None => (false, s.trim_start_matches('0'), ""), Some(index) => ( false, (s[..index].trim_start_matches('0')), (s[(index + 1)..].trim_end_matches('0')), ), }, Some(s) => { let (_, integer, decimal) = number_components(s); (true, integer, decimal) } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_to_string() { assert_eq!(Number::Float(1.0).to_string(), "1.0".to_string()); assert_eq!(Number::Float(1.1).to_string(), "1.1".to_string()); assert_eq!(Number::from(1.0).to_string(), "1.0".to_string()); assert_eq!(Number::from(1.1).to_string(), "1.1".to_string()); assert_eq!( Number::BigInteger("1.1".to_string()).to_string(), "1.1".to_string() ); assert_eq!( Number::BigInteger("1".to_string()).to_string(), "1".to_string() ); } #[test] fn test_cmp_value() { let integer_zero = Number::from(0); let integer_minus_one = Number::from(-1); let integer_one = Number::from(1); let integer_two = Number::from(2); let integer_max = Number::from(i64::MAX); let integer_min = Number::from(i64::MIN); let float_zero = Number::from(0.0); let float_one = Number::from(1.0); let float_one_plus_epsilon = Number::from(1.000_000_000_000_000_100); let float_one_plus_plus_epsilon = Number::from(1.000_000_000_000_001); let float_two = Number::from(2.0); let float_min = Number::from(f64::MIN); let float_max = Number::from(f64::MAX); let number_one = Number::BigInteger("1".to_string()); let number_two = Number::BigInteger("2".to_string()); let number_two_with_decimal = Number::BigInteger("2.0".to_string()); assert_eq!(integer_minus_one.cmp_value(&integer_zero), Ordering::Less); assert_eq!(integer_one.cmp_value(&integer_one), Ordering::Equal); assert_eq!(integer_one.cmp_value(&number_one), Ordering::Equal); assert_eq!(integer_one.cmp_value(&float_one), Ordering::Equal); assert_eq!(integer_one.cmp_value(&integer_zero), Ordering::Greater); assert_eq!(integer_one.cmp_value(&float_zero), Ordering::Greater); assert_eq!(integer_one.cmp_value(&integer_two), Ordering::Less); assert_eq!(integer_one.cmp_value(&float_two), Ordering::Less); assert_eq!(integer_one.cmp_value(&number_two), Ordering::Less); assert_eq!( integer_one.cmp_value(&number_two_with_decimal), Ordering::Less ); assert_eq!(integer_min.cmp_value(&float_min), Ordering::Greater); assert_eq!(integer_max.cmp_value(&float_max), Ordering::Less); assert_eq!(float_one.cmp_value(&float_one), Ordering::Equal); assert_eq!( float_one.cmp_value(&float_one_plus_epsilon), Ordering::Equal ); assert_eq!( float_one.cmp_value(&float_one_plus_plus_epsilon), Ordering::Less ); // edge cases // the integer 9_007_199_254_740_993 can not be represented by f64 // it will be casted to 9_007_199_254_740_992 for comparison assert_eq!( Number::from(9_007_199_254_740_992.0).cmp_value(&Number::from(9_007_199_254_740_993)), Ordering::Equal ); } #[test] fn test_cmp_number_string() { assert_eq!(compare_number_string("1", "1"), Ordering::Equal); assert_eq!(compare_number_string("1", "1.0"), Ordering::Equal); assert_eq!(compare_number_string("1.000", "1.0"), Ordering::Equal); assert_eq!(compare_number_string("1", "2"), Ordering::Less); assert_eq!(compare_number_string("1.1", "2"), Ordering::Less); assert_eq!(compare_number_string("-001.1000", "-1.1"), Ordering::Equal); } #[test] fn test_number_components() { assert_eq!(number_components("1"), (false, "1", "")); assert_eq!(number_components("1.0"), (false, "1", "")); assert_eq!(number_components("01"), (false, "1", "")); assert_eq!(number_components("1.1"), (false, "1", "1")); assert_eq!(number_components("1.100"), (false, "1", "1")); assert_eq!(number_components("-1.1"), (true, "1", "1")); assert_eq!(number_components("-01.100"), (true, "1", "1")); } } hurl-6.1.1/src/runner/options.rs000064400000000000000000000606261046102023000147560ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::{ BooleanOption, CountOption, DurationOption, Entry, NaturalOption, Number as AstNumber, OptionKind, Placeholder, SectionValue, VariableDefinition, VariableValue, }; use hurl_core::typing::{BytesPerSec, Count, DurationUnit}; use crate::http::{IpResolve, RequestedHttpVersion}; use crate::runner::template::eval_template; use crate::runner::{ expr, Number, Output, RunnerError, RunnerErrorKind, RunnerOptions, Value, VariableSet, }; use crate::util::logger::{Logger, Verbosity}; /// Returns a new [`RunnerOptions`] based on the `entry` optional Options section /// and a default `runner_options`. /// The [`variables`] can also be updated if `variable` keys are present in the section. pub fn get_entry_options( entry: &Entry, runner_options: &RunnerOptions, variables: &mut VariableSet, logger: &mut Logger, ) -> Result { let runner_options = runner_options.clone(); // When used globally (on the command line), `--output` writes the last successful request // to `output` file. We don't want to output every entry's response, so we initialise // output to `None`. let mut entry_options = RunnerOptions { output: None, ..runner_options }; if !has_options(entry) { return Ok(entry_options); } logger.debug(""); logger.debug_important("Entry options:"); for section in &entry.request.sections { if let SectionValue::Options(options) = §ion.value { for option in options.iter() { match &option.kind { OptionKind::AwsSigV4(value) => { let value = eval_template(value, variables)?; entry_options.aws_sigv4 = Some(value); } OptionKind::CaCertificate(filename) => { let value = eval_template(filename, variables)?; entry_options.cacert_file = Some(value); } OptionKind::ClientCert(filename) => { let value = eval_template(filename, variables)?; entry_options.client_cert_file = Some(value); } OptionKind::ClientKey(filename) => { let value = eval_template(filename, variables)?; entry_options.client_key_file = Some(value); } OptionKind::Compressed(value) => { let value = eval_boolean_option(value, variables)?; entry_options.compressed = value; } OptionKind::ConnectTo(value) => { let value = eval_template(value, variables)?; entry_options.connects_to.push(value); } OptionKind::ConnectTimeout(value) => { let value = eval_duration_option(value, variables, DurationUnit::MilliSecond)?; entry_options.connect_timeout = value; } OptionKind::Delay(value) => { let value = eval_duration_option(value, variables, DurationUnit::MilliSecond)?; entry_options.delay = value; } OptionKind::Header(value) => { let value = eval_template(value, variables)?; entry_options.headers.push(value); } // HTTP version options (such as http1.0, http1.1, http2 etc...) are activated // through a flag. In an `[Options]` section, the signification of such a flag is: // // - when set to `true`, it's equivalent as using this option on command line // // ```hurl // # Shell equivalent command: // # $ hurl --http1.1 foo.hurl // GET https://foo.com // [Options] // http1.1: true // ``` // // - when set to `false`, it's as if the user do not want to use such a version. // So, if such a flag is explicitly set to `false`, we downgrade to the lower // HTTP version: // // ```hurl // # Shell equivalent command: // # $ hurl --http1.1 foo.hurl // GET https://foo.com // [Options] // http2: false // ``` // // As libcurl tries to reuse connections as much as possible (see ) // > Note that the HTTP version is just a request. libcurl still prioritizes to reuse // > existing connections so it might then reuse a connection using a HTTP version you // > have not asked for. // we don't allow our HTTP client to reuse connection if the user asks for a specific // HTTP version per request. // OptionKind::Http10(value) => { let value = eval_boolean_option(value, variables)?; if value { entry_options.http_version = RequestedHttpVersion::Http10; } entry_options.allow_reuse = false; } OptionKind::Http11(value) => { let value = eval_boolean_option(value, variables)?; if value { entry_options.http_version = RequestedHttpVersion::Http11; } else { entry_options.http_version = RequestedHttpVersion::Http10; } entry_options.allow_reuse = false; } OptionKind::Http2(value) => { let value = eval_boolean_option(value, variables)?; if value { entry_options.http_version = RequestedHttpVersion::Http2; } else { entry_options.http_version = RequestedHttpVersion::Http11; } entry_options.allow_reuse = false; } OptionKind::Http3(value) => { let value = eval_boolean_option(value, variables)?; if value { entry_options.http_version = RequestedHttpVersion::Http3; } else { entry_options.http_version = RequestedHttpVersion::Http2; } entry_options.allow_reuse = false; } OptionKind::FollowLocation(value) => { let value = eval_boolean_option(value, variables)?; entry_options.follow_location = value; } OptionKind::FollowLocationTrusted(value) => { let value = eval_boolean_option(value, variables)?; if value { entry_options.follow_location = true; } entry_options.follow_location_trusted = value; } OptionKind::Insecure(value) => { let value = eval_boolean_option(value, variables)?; entry_options.insecure = value; } OptionKind::IpV4(value) => { let value = eval_boolean_option(value, variables)?; entry_options.ip_resolve = if value { IpResolve::IpV4 } else { IpResolve::IpV6 } } OptionKind::IpV6(value) => { let value = eval_boolean_option(value, variables)?; entry_options.ip_resolve = if value { IpResolve::IpV6 } else { IpResolve::IpV4 } } OptionKind::LimitRate(value) => { let value = eval_natural_option(value, variables)?; entry_options.max_send_speed = Some(BytesPerSec(value)); entry_options.max_recv_speed = Some(BytesPerSec(value)); } OptionKind::MaxRedirect(value) => { let value = eval_count_option(value, variables)?; entry_options.max_redirect = value; } OptionKind::NetRc(value) => { let value = eval_boolean_option(value, variables)?; entry_options.netrc = value; } OptionKind::NetRcFile(value) => { let filename = eval_template(value, variables)?; entry_options.netrc_file = Some(filename); } OptionKind::NetRcOptional(value) => { let value = eval_boolean_option(value, variables)?; entry_options.netrc_optional = value; } OptionKind::Output(output) => { let filename = eval_template(output, variables)?; let output = Output::new(&filename); entry_options.output = Some(output); } OptionKind::PathAsIs(value) => { let value = eval_boolean_option(value, variables)?; entry_options.path_as_is = value; } OptionKind::Proxy(value) => { let value = eval_template(value, variables)?; entry_options.proxy = Some(value); } OptionKind::Repeat(value) => { let value = eval_count_option(value, variables)?; entry_options.repeat = Some(value); } OptionKind::Resolve(value) => { let value = eval_template(value, variables)?; entry_options.resolves.push(value); } OptionKind::Retry(value) => { let value = eval_count_option(value, variables)?; entry_options.retry = Some(value); } OptionKind::RetryInterval(value) => { let value = eval_duration_option(value, variables, DurationUnit::MilliSecond)?; entry_options.retry_interval = value; } OptionKind::Skip(value) => { let value = eval_boolean_option(value, variables)?; entry_options.skip = value; } OptionKind::UnixSocket(value) => { let value = eval_template(value, variables)?; entry_options.unix_socket = Some(value); } OptionKind::User(value) => { let value = eval_template(value, variables)?; entry_options.user = Some(value); } OptionKind::Variable(VariableDefinition { source_info, name, value, .. }) => { let value = eval_variable_value(value, variables)?; if let Err(err) = variables.insert(name.clone(), value) { return Err(err.to_runner_error(*source_info)); } } // verbose and very-verbose option have been previously processed as they // can impact the logging. We compute here their values to check the potential // templatized error. OptionKind::Verbose(value) => { eval_boolean_option(value, variables)?; } OptionKind::VeryVerbose(value) => { eval_boolean_option(value, variables)?; } } logger.debug(&option.kind.to_string()); } } } Ok(entry_options) } /// Returns [`true`] if this `entry` has an Option section, [`false`] otherwise. fn has_options(entry: &Entry) -> bool { entry .request .sections .iter() .any(|s| matches!(s.value, SectionValue::Options(_))) } /// Returns the overridden `entry` verbosity, or the default `verbosity` file. pub fn get_entry_verbosity( entry: &Entry, default_verbosity: Option, variables: &VariableSet, ) -> Result, RunnerError> { let mut verbosity = default_verbosity; for section in &entry.request.sections { if let SectionValue::Options(options) = §ion.value { for option in options { match &option.kind { OptionKind::Verbose(value) => { let value = eval_boolean_option(value, variables)?; verbosity = if value { Some(Verbosity::Verbose) } else { None } } OptionKind::VeryVerbose(value) => { let value = eval_boolean_option(value, variables)?; verbosity = if value { Some(Verbosity::VeryVerbose) } else { None } } _ => {} } } } } Ok(verbosity) } fn eval_boolean_option( boolean_value: &BooleanOption, variables: &VariableSet, ) -> Result { match boolean_value { BooleanOption::Literal(value) => Ok(*value), BooleanOption::Placeholder(Placeholder { expr, .. }) => { match expr::eval(expr, variables)? { Value::Bool(value) => Ok(value), v => { let kind = RunnerErrorKind::ExpressionInvalidType { value: v.repr(), expecting: "boolean".to_string(), }; Err(RunnerError::new(expr.source_info, kind, false)) } } } } } fn eval_natural_option( natural_value: &NaturalOption, variables: &VariableSet, ) -> Result { match natural_value { NaturalOption::Literal(value) => Ok(value.as_u64()), NaturalOption::Placeholder(Placeholder { expr, .. }) => { match expr::eval(expr, variables)? { Value::Number(Number::Integer(value)) => { if value > 0 { Ok(value as u64) } else { let kind = RunnerErrorKind::ExpressionInvalidType { value: format!("integer <{value}>"), expecting: "integer > 0".to_string(), }; Err(RunnerError::new(expr.source_info, kind, false)) } } v => { let kind = RunnerErrorKind::ExpressionInvalidType { value: v.repr(), expecting: "integer".to_string(), }; Err(RunnerError::new(expr.source_info, kind, false)) } } } } } fn eval_count_option( count_value: &CountOption, variables: &VariableSet, ) -> Result { match count_value { CountOption::Literal(repeat) => Ok(*repeat), CountOption::Placeholder(Placeholder { expr, .. }) => match expr::eval(expr, variables)? { Value::Number(Number::Integer(value)) => { if value == -1 { Ok(Count::Infinite) } else if value >= 0 { Ok(Count::Finite(value as usize)) } else { let kind = RunnerErrorKind::ExpressionInvalidType { value: format!("integer <{value}>"), expecting: "integer >= -1".to_string(), }; Err(RunnerError::new(expr.source_info, kind, false)) } } v => { let kind = RunnerErrorKind::ExpressionInvalidType { value: v.repr(), expecting: "integer".to_string(), }; Err(RunnerError::new(expr.source_info, kind, false)) } }, } } /// return duration value in milliseconds fn eval_duration_option( duration_value: &DurationOption, variables: &VariableSet, default_unit: DurationUnit, ) -> Result { let millis = match duration_value { DurationOption::Literal(literal) => { let unit = literal.unit.unwrap_or(default_unit); match unit { DurationUnit::MilliSecond => literal.value.as_u64(), DurationUnit::Second => literal.value.as_u64() * 1000, DurationUnit::Minute => literal.value.as_u64() * 1000 * 60, } } DurationOption::Placeholder(Placeholder { expr, .. }) => match expr::eval(expr, variables)? { Value::Number(Number::Integer(value)) => { if value < 0 { let kind = RunnerErrorKind::ExpressionInvalidType { value: format!("integer <{value}>"), expecting: "positive integer".to_string(), }; return Err(RunnerError::new(expr.source_info, kind, false)); } else { match default_unit { DurationUnit::MilliSecond => value as u64, DurationUnit::Second => (value * 1000) as u64, DurationUnit::Minute => (value * 1000 * 60) as u64, } } } v => { let kind = RunnerErrorKind::ExpressionInvalidType { value: v.repr(), expecting: "positive integer".to_string(), }; return Err(RunnerError::new(expr.source_info, kind, false)); } }, }; Ok(std::time::Duration::from_millis(millis)) } fn eval_variable_value( variable_value: &VariableValue, variables: &mut VariableSet, ) -> Result { match variable_value { VariableValue::Null => Ok(Value::Null), VariableValue::Bool(v) => Ok(Value::Bool(*v)), VariableValue::Number(v) => Ok(eval_number(v)), VariableValue::String(template) => { let s = eval_template(template, variables)?; Ok(Value::String(s)) } } } fn eval_number(number: &AstNumber) -> Value { match number { AstNumber::Float(value) => Value::Number(Number::Float(value.as_f64())), AstNumber::Integer(value) => Value::Number(Number::Integer(value.as_i64())), AstNumber::BigInteger(value) => Value::Number(Number::BigInteger(value.clone())), } } #[cfg(test)] mod tests { use hurl_core::ast::{Expr, ExprKind, Placeholder, SourceInfo, Variable, Whitespace, U64}; use hurl_core::reader::Pos; use hurl_core::typing::{Duration, DurationUnit, ToSource}; use super::*; use crate::runner::RunnerErrorKind; fn verbose_option_template() -> BooleanOption { // {{verbose}} BooleanOption::Placeholder(Placeholder { space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }, expr: Expr { kind: ExprKind::Variable(Variable { name: "verbose".to_string(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }, space1: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }, }) } fn retry_option_template() -> DurationOption { // {{retry}} DurationOption::Placeholder(Placeholder { space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }, expr: Expr { kind: ExprKind::Variable(Variable { name: "retry".to_string(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }, space1: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }, }) } #[test] fn test_eval_boolean_option() { let mut variables = VariableSet::default(); assert!(eval_boolean_option(&BooleanOption::Literal(true), &variables).unwrap()); variables .insert("verbose".to_string(), Value::Bool(true)) .unwrap(); assert!(eval_boolean_option(&verbose_option_template(), &variables).unwrap()); } #[test] fn test_eval_boolean_option_error() { let mut variables = VariableSet::default(); let error = eval_boolean_option(&verbose_option_template(), &variables) .err() .unwrap(); assert!(!error.assert); assert_eq!( error.kind, RunnerErrorKind::TemplateVariableNotDefined { name: "verbose".to_string() } ); variables .insert("verbose".to_string(), Value::Number(Number::Integer(10))) .unwrap(); let error = eval_boolean_option(&verbose_option_template(), &variables) .err() .unwrap(); assert_eq!( error.kind, RunnerErrorKind::ExpressionInvalidType { value: "integer <10>".to_string(), expecting: "boolean".to_string() } ); } #[test] fn test_eval_natural_option() { let mut variables = VariableSet::default(); assert_eq!( eval_duration_option( &DurationOption::Literal(Duration::new( U64::new(1, "1".to_source()), Some(DurationUnit::Second) )), &variables, DurationUnit::MilliSecond ) .unwrap(), std::time::Duration::from_millis(1000) ); variables .insert("retry".to_string(), Value::Number(Number::Integer(10))) .unwrap(); assert_eq!( eval_duration_option( &retry_option_template(), &variables, DurationUnit::MilliSecond ) .unwrap(), std::time::Duration::from_millis(10) ); } } hurl-6.1.1/src/runner/output.rs000064400000000000000000000121341046102023000146120ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::fs::{File, OpenOptions}; use std::io::Write; use std::path::{Path, PathBuf}; use std::{fmt, io}; use hurl_core::ast::SourceInfo; use crate::runner::{RunnerError, RunnerErrorKind}; use crate::util::path::ContextDir; use crate::util::term::Stdout; /// Represents the output of write operation: can be either a file or standard output. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Output { /// Write to file. File(PathBuf), /// Write to standard output. Stdout, } impl fmt::Display for Output { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let output = match self { Output::File(file) => file.to_string_lossy().to_string(), Output::Stdout => "-".to_string(), }; write!(f, "{output}") } } impl Output { /// Creates a new output from a string filename. pub fn new(filename: &str) -> Self { if filename == "-" { Output::Stdout } else { Output::File(PathBuf::from(filename)) } } /// Writes these `bytes` to the output. /// /// If output is a standard output variant, `stdout` is used to write the bytes. If `append` /// the output file is created in append mode, else any existing file will be truncated. pub fn write(&self, bytes: &[u8], stdout: &mut Stdout, append: bool) -> Result<(), io::Error> { match self { Output::Stdout => stdout.write_all(bytes)?, Output::File(filename) => { let mut file = OpenOptions::new() .create(true) .write(true) .append(append) .open(filename)?; file.write_all(bytes)?; } } Ok(()) } /// Writes these `bytes` to the output. /// /// If output is a standard output variant, `stdout` is used to write the bytes. /// If output is a file variant, `context_dir` is used to check authorized write access. pub fn write_with_context_dir( &self, bytes: &[u8], stdout: &mut Stdout, context_dir: &ContextDir, source_info: SourceInfo, ) -> Result<(), RunnerError> { // TODO: Check if write method above can be reused match self { Output::Stdout => match stdout.write_all(bytes) { Ok(_) => Ok(()), Err(e) => { let filename = Path::new("stdout"); Err(RunnerError::new_file_write_access( filename, &e.to_string(), source_info, )) } }, Output::File(filename) => { if !context_dir.is_access_allowed(filename) { return Err(RunnerError::new_unauthorized_file_access( filename, source_info, )); } // we check if we can write to this filename and compute the new filename given this context dir. let filename = context_dir.resolved_path(filename); let mut file = match File::create(&filename) { Ok(file) => file, Err(e) => { return Err(RunnerError::new_file_write_access( &filename, &e.to_string(), source_info, )) } }; match file.write_all(bytes) { Ok(_) => Ok(()), Err(e) => Err(RunnerError::new_file_write_access( &filename, &e.to_string(), source_info, )), } } } } } impl RunnerError { /// Creates a new file write access error. fn new_file_write_access(path: &Path, error: &str, source_info: SourceInfo) -> RunnerError { let path = path.to_path_buf(); let kind = RunnerErrorKind::FileWriteAccess { path, error: error.to_string(), }; RunnerError::new(source_info, kind, false) } /// Creates a new authorization access error. fn new_unauthorized_file_access(path: &Path, source_info: SourceInfo) -> RunnerError { let path = path.to_path_buf(); let kind = RunnerErrorKind::UnauthorizedFileAccess { path }; RunnerError::new(source_info, kind, false) } } hurl-6.1.1/src/runner/predicate.rs000064400000000000000000001466741046102023000152330ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::cmp::Ordering; use hurl_core::ast::{Predicate, PredicateFunc, PredicateFuncValue, PredicateValue, SourceInfo}; use hurl_core::reader::Pos; use crate::runner::error::RunnerError; use crate::runner::predicate_value::{eval_predicate_value, eval_predicate_value_template}; use crate::runner::result::PredicateResult; use crate::runner::value::{EvalError, Value}; use crate::runner::{Number, RunnerErrorKind, VariableSet}; use crate::util::path::ContextDir; /// Evaluates a `predicate` against an actual `value`. /// /// The predicate is a test with an expected value. The expected value (contained in the `predicate` /// struct) can use a set of `variables`. /// /// For instance, in the following Hurl assert: /// /// ```hurl /// jsonpath "$.books[0].name" startsWith "Dune" /// ``` /// The predicate is `startsWith "Dune"`, a value could be "Foo". /// With variables, we can have: /// /// ```hurl /// jsonpath "$.books[0].name" startsWith "{{name}}" /// ``` /// /// In this case, the predicate is `startsWith "{{name}}"`. pub fn eval_predicate( predicate: &Predicate, variables: &VariableSet, value: &Option, context_dir: &ContextDir, ) -> PredicateResult { let assert_result = eval_predicate_func( &predicate.predicate_func, variables, value.as_ref(), context_dir, )?; // Column error is set to 0 to disable the error display of "^^^" let source_info = SourceInfo::new( Pos::new(predicate.space0.source_info.start.line, 0), Pos::new(predicate.space0.source_info.start.line, 0), ); if assert_result.type_mismatch { let not = if predicate.not { "not " } else { "" }; let expected = format!("{}{}", not, assert_result.expected); let kind = RunnerErrorKind::AssertFailure { actual: assert_result.actual, expected, type_mismatch: true, }; Err(RunnerError::new(source_info, kind, true)) } else if predicate.not && assert_result.success { let kind = RunnerErrorKind::AssertFailure { actual: assert_result.actual, expected: format!("not {}", assert_result.expected), type_mismatch: false, }; Err(RunnerError::new(source_info, kind, true)) } else if !predicate.not && !assert_result.success { let kind = RunnerErrorKind::AssertFailure { actual: assert_result.actual, expected: assert_result.expected, type_mismatch: false, }; Err(RunnerError::new(source_info, kind, true)) } else { Ok(()) } } #[derive(Clone, Debug, PartialEq, Eq)] struct AssertResult { pub success: bool, pub type_mismatch: bool, pub actual: String, pub expected: String, } impl Value { fn format(&self) -> String { match self { Value::Bool(value) => format!("boolean <{value}>"), Value::Bytes(values) => format!( "{} byte{}", values.len(), if values.len() > 1 { "s" } else { "" } ), Value::Date(value) => format!("date <{value}>"), Value::List(value) => format!("list of size {}", value.len()), Value::Nodeset(size) => format!("list of size {size}"), Value::Null => "null".to_string(), Value::Number(number) => number.expected(), Value::Object(values) => format!("list of size {}", values.len()), Value::Regex(value) => format!("regex <{value}>"), Value::String(value) => format!("string <{value}>"), Value::Unit => "something".to_string(), } } } impl Number { fn expected(&self) -> String { match self { Number::Float(f) => format!("float <{}>", format_float(*f)), Number::Integer(value) => format!("integer <{value}>"), Number::BigInteger(s) => format!("number <{s}>"), } } } fn format_float(value: f64) -> String { if value.fract() < f64::EPSILON { format!("{value}.0") } else { value.to_string() } } /// Returns a message formatting an expected value `predicate_func_value`, given /// a set of `variables`, when there is no actual value. fn expected_no_value( predicate_func_value: &PredicateFuncValue, variables: &VariableSet, context_dir: &ContextDir, ) -> Result { match &predicate_func_value { PredicateFuncValue::Equal { value, .. } | PredicateFuncValue::NotEqual { value, .. } => { let value = eval_predicate_value(value, variables, context_dir)?; Ok(value.format()) } PredicateFuncValue::GreaterThan { value, .. } => { let value = eval_predicate_value(value, variables, context_dir)?; Ok(format!("greater than <{}>", value.format())) } PredicateFuncValue::GreaterThanOrEqual { value, .. } => { let value = eval_predicate_value(value, variables, context_dir)?; Ok(format!("greater than or equals to <{}>", value.format())) } PredicateFuncValue::LessThan { value, .. } => { let value = eval_predicate_value(value, variables, context_dir)?; Ok(format!("less than <{}>", value.format())) } PredicateFuncValue::LessThanOrEqual { value, .. } => { let value = eval_predicate_value(value, variables, context_dir)?; Ok(format!("less than or equals to <{}>", value.format())) } PredicateFuncValue::StartWith { value, .. } => { let value = eval_predicate_value(value, variables, context_dir)?; Ok(format!("starts with {}", value.format())) } PredicateFuncValue::EndWith { value, .. } => { let value = eval_predicate_value(value, variables, context_dir)?; Ok(format!("ends with {}", value.format())) } PredicateFuncValue::Contain { value, .. } => { let value = eval_predicate_value(value, variables, context_dir)?; Ok(format!("contains {}", value.format())) } PredicateFuncValue::Include { value, .. } => { let value = eval_predicate_value(value, variables, context_dir)?; Ok(format!("include {}", value.format())) } PredicateFuncValue::Match { value: expected, .. } => { let expected = eval_predicate_value_template(expected, variables)?; Ok(format!("matches regex <{expected}>")) } PredicateFuncValue::IsInteger => Ok("integer".to_string()), PredicateFuncValue::IsFloat => Ok("float".to_string()), PredicateFuncValue::IsBoolean => Ok("boolean".to_string()), PredicateFuncValue::IsString => Ok("string".to_string()), PredicateFuncValue::IsCollection => Ok("collection".to_string()), PredicateFuncValue::IsDate => Ok("date".to_string()), PredicateFuncValue::IsIsoDate => Ok("date".to_string()), PredicateFuncValue::Exist => Ok("something".to_string()), PredicateFuncValue::IsEmpty => Ok("empty".to_string()), PredicateFuncValue::IsNumber => Ok("number".to_string()), PredicateFuncValue::IsIpv4 => Ok("ipv4".to_string()), PredicateFuncValue::IsIpv6 => Ok("ipv6".to_string()), } } /// Evaluates a `predicate_func` against an actual `value`. /// The `predicate_func` is a test with an expected value. The expected value can /// use a set of `variables`. fn eval_predicate_func( predicate_func: &PredicateFunc, variables: &VariableSet, value: Option<&Value>, context_dir: &ContextDir, ) -> Result { let value = match value { Some(value) => value, None => { let expected = expected_no_value(&predicate_func.value, variables, context_dir)?; return Ok(AssertResult { success: false, actual: "none".to_string(), expected, type_mismatch: false, }); } }; match &predicate_func.value { PredicateFuncValue::Equal { value: expected, .. } => eval_equal(expected, variables, value, context_dir), PredicateFuncValue::NotEqual { value: expected, .. } => eval_not_equal(expected, variables, value, context_dir), PredicateFuncValue::GreaterThan { value: expected, .. } => eval_greater_than(expected, variables, value, context_dir), PredicateFuncValue::GreaterThanOrEqual { value: expected, .. } => eval_greater_than_or_equal(expected, variables, value, context_dir), PredicateFuncValue::LessThan { value: expected, .. } => eval_less_than(expected, variables, value, context_dir), PredicateFuncValue::LessThanOrEqual { value: expected, .. } => eval_less_than_or_equal(expected, variables, value, context_dir), PredicateFuncValue::StartWith { value: expected, .. } => eval_start_with(expected, variables, value, context_dir), PredicateFuncValue::EndWith { value: expected, .. } => eval_end_with(expected, variables, value, context_dir), PredicateFuncValue::Contain { value: expected, .. } => eval_contain(expected, variables, value, context_dir), PredicateFuncValue::Include { value: expected, .. } => eval_include(expected, variables, value, context_dir), PredicateFuncValue::Match { value: expected, .. } => eval_match( expected, predicate_func.source_info, variables, value, context_dir, ), PredicateFuncValue::IsInteger => eval_is_integer(value), PredicateFuncValue::IsFloat => eval_is_float(value), PredicateFuncValue::IsBoolean => eval_is_boolean(value), PredicateFuncValue::IsString => eval_is_string(value), PredicateFuncValue::IsCollection => eval_is_collection(value), PredicateFuncValue::IsDate => eval_is_date(value), PredicateFuncValue::IsIsoDate => eval_is_iso_date(value), PredicateFuncValue::Exist => eval_exist(value), PredicateFuncValue::IsEmpty => eval_is_empty(value), PredicateFuncValue::IsNumber => eval_is_number(value), PredicateFuncValue::IsIpv4 => eval_is_ipv4(value), PredicateFuncValue::IsIpv6 => eval_is_ipv6(value), } } /// Evaluates if an `expected` value (using a `variables` set) is equal to an `actual` value. fn eval_equal( expected: &PredicateValue, variables: &VariableSet, actual: &Value, context_dir: &ContextDir, ) -> Result { let expected = eval_predicate_value(expected, variables, context_dir)?; Ok(assert_values_equal(actual, &expected)) } /// Evaluates if an `expected` value (using a `variables` set) is not equal to an `actual` value. fn eval_not_equal( expected: &PredicateValue, variables: &VariableSet, actual: &Value, context_dir: &ContextDir, ) -> Result { let expected = eval_predicate_value(expected, variables, context_dir)?; Ok(assert_values_not_equal(actual, &expected)) } /// Evaluates if an `expected` value (using a `variables` set) is greater than an `actual` value. fn eval_greater_than( expected: &PredicateValue, variables: &VariableSet, actual: &Value, context_dir: &ContextDir, ) -> Result { let expected = eval_predicate_value(expected, variables, context_dir)?; Ok(assert_values_greater(actual, &expected)) } /// Evaluates if an `expected` value (using a `variables` set) is greater than or equal to an `actual` value. fn eval_greater_than_or_equal( expected: &PredicateValue, variables: &VariableSet, actual: &Value, context_dir: &ContextDir, ) -> Result { let expected = eval_predicate_value(expected, variables, context_dir)?; Ok(assert_values_greater_or_equal(actual, &expected)) } /// Evaluates if an `expected` value (using a `variables` set) is less than an `actual` value. fn eval_less_than( expected: &PredicateValue, variables: &VariableSet, actual: &Value, context_dir: &ContextDir, ) -> Result { let expected = eval_predicate_value(expected, variables, context_dir)?; Ok(assert_values_less(actual, &expected)) } /// Evaluates if an `expected` value (using a `variables` set) is less than an `actual` value. fn eval_less_than_or_equal( expected: &PredicateValue, variables: &VariableSet, actual: &Value, context_dir: &ContextDir, ) -> Result { let expected = eval_predicate_value(expected, variables, context_dir)?; Ok(assert_values_less_or_equal(actual, &expected)) } /// Evaluates if an `expected` value (using a `variables` set) starts with an `actual` value. /// This predicate works with string and bytes. fn eval_start_with( expected: &PredicateValue, variables: &VariableSet, actual: &Value, context_dir: &ContextDir, ) -> Result { let expected = eval_predicate_value(expected, variables, context_dir)?; let expected_display = format!("starts with {}", expected.repr()); let actual_display = actual.repr(); match actual.starts_with(&expected) { Ok(success) => Ok(AssertResult { success, actual: actual_display, expected: expected_display, type_mismatch: false, }), Err(_) => Ok(AssertResult { success: false, actual: actual_display, expected: expected_display, type_mismatch: true, }), } } /// Evaluates if an `expected` value (using a `variables` set) ends with an `actual` value. /// This predicate works with string and bytes. fn eval_end_with( expected: &PredicateValue, variables: &VariableSet, actual: &Value, context_dir: &ContextDir, ) -> Result { let expected = eval_predicate_value(expected, variables, context_dir)?; let expected_display = format!("ends with {}", expected.repr()); let actual_display = actual.repr(); match actual.ends_with(&expected) { Ok(success) => Ok(AssertResult { success, actual: actual_display, expected: expected_display, type_mismatch: false, }), Err(_) => Ok(AssertResult { success: false, actual: actual_display, expected: expected_display, type_mismatch: true, }), } } /// Evaluates if an `expected` value (using a `variables` set) contains an `actual` value. /// This predicate works with string and bytes. fn eval_contain( expected: &PredicateValue, variables: &VariableSet, actual: &Value, context_dir: &ContextDir, ) -> Result { let expected = eval_predicate_value(expected, variables, context_dir)?; let expected_display = format!("contains {}", expected.repr()); let actual_display = actual.repr(); match actual.contains(&expected) { Ok(success) => Ok(AssertResult { success, actual: actual_display, expected: expected_display, type_mismatch: false, }), _ => Ok(AssertResult { success: false, actual: actual_display, expected: expected_display, type_mismatch: true, }), } } /// Evaluates if an `expected` value (using a `variables` set) includes an `actual` value. /// This predicate works with list (maybe we should merge it with `eval_contains`?) fn eval_include( expected: &PredicateValue, variables: &VariableSet, actual: &Value, context_dir: &ContextDir, ) -> Result { let expected = eval_predicate_value(expected, variables, context_dir)?; Ok(assert_include(actual, &expected)) } /// Evaluates if an `expected` regex (using a `variables` set) matches an `actual` value. fn eval_match( expected: &PredicateValue, source_info: SourceInfo, variables: &VariableSet, actual: &Value, context_dir: &ContextDir, ) -> Result { let expected = eval_predicate_value(expected, variables, context_dir)?; let actual_display = actual.repr(); let expected_display = format!("matches regex <{expected}>"); match actual.is_match(&expected) { Ok(success) => Ok(AssertResult { success, actual: actual_display, expected: expected_display, type_mismatch: false, }), Err(EvalError::Type) => Ok(AssertResult { success: false, actual: actual_display, expected: expected_display, type_mismatch: true, }), Err(EvalError::InvalidRegex) => Err(RunnerError::new( source_info, RunnerErrorKind::InvalidRegex, false, )), } } /// Evaluates if an `actual` value is an integer. fn eval_is_integer(actual: &Value) -> Result { Ok(AssertResult { success: actual.is_integer(), actual: actual.repr(), expected: "integer".to_string(), type_mismatch: false, }) } /// Evaluates if an `actual` value is a float. fn eval_is_float(actual: &Value) -> Result { Ok(AssertResult { success: actual.is_float(), actual: actual.repr(), expected: "float".to_string(), type_mismatch: false, }) } /// Evaluates if an `actual` value is a boolean. fn eval_is_boolean(actual: &Value) -> Result { Ok(AssertResult { success: actual.is_boolean(), actual: actual.repr(), expected: "boolean".to_string(), type_mismatch: false, }) } /// Evaluates if an `actual` value is a string. fn eval_is_string(actual: &Value) -> Result { Ok(AssertResult { success: actual.is_string(), actual: actual.repr(), expected: "string".to_string(), type_mismatch: false, }) } /// Evaluates if an `actual` value is a collection. fn eval_is_collection(actual: &Value) -> Result { Ok(AssertResult { success: actual.is_collection(), actual: actual.repr(), expected: "collection".to_string(), type_mismatch: false, }) } /// Evaluates if an `actual` value is a date. fn eval_is_date(actual: &Value) -> Result { Ok(AssertResult { success: actual.is_date(), actual: actual.repr(), expected: "date".to_string(), type_mismatch: false, }) } /// Evaluates if `actual` is a string representing a RFC339 date (format YYYY-MM-DDTHH:mm:ss.sssZ). /// /// [`eval_is_date`] performs type check (is the input of [`Value::Date`]), whereas [`eval_is_iso_date`] /// checks if a string conforms to a certain date-time format. fn eval_is_iso_date(actual: &Value) -> Result { match actual.is_iso_date() { Ok(success) => Ok(AssertResult { success, actual: actual.to_string(), expected: "string with format YYYY-MM-DDTHH:mm:ss.sssZ".to_string(), type_mismatch: false, }), _ => Ok(AssertResult { success: false, actual: actual.repr(), expected: "string".to_string(), type_mismatch: true, }), } } /// Evaluates if an `actual` value exists. fn eval_exist(actual: &Value) -> Result { let actual_display = actual.repr(); let expected_display = "something".to_string(); match actual { Value::Nodeset(0) => Ok(AssertResult { success: false, actual: actual_display, expected: expected_display, type_mismatch: false, }), _ => Ok(AssertResult { success: true, actual: actual_display, expected: expected_display, type_mismatch: false, }), } } /// Evaluates if an `actual` is empty. fn eval_is_empty(actual: &Value) -> Result { let expected_display = "count equals to 0".to_string(); match actual.count() { Ok(count) => { let actual_display = format!("count equals to {count}"); Ok(AssertResult { success: count == 0, actual: actual_display, expected: expected_display, type_mismatch: false, }) } _ => Ok(AssertResult { success: false, actual: actual.repr(), expected: expected_display, type_mismatch: true, }), } } /// Evaluates if an `actual` value is a number. fn eval_is_number(actual: &Value) -> Result { Ok(AssertResult { success: actual.is_number(), actual: actual.repr(), expected: "number".to_string(), type_mismatch: false, }) } /// Evaluates if an `actual` value is an IPv4 address. fn eval_is_ipv4(actual: &Value) -> Result { match actual.is_ipv4() { Ok(success) => Ok(AssertResult { success, actual: actual.to_string(), expected: "string in IPv4 format".to_string(), type_mismatch: false, }), _ => Ok(AssertResult { success: false, actual: actual.repr(), expected: "string".to_string(), type_mismatch: true, }), } } /// Evaluates if an `actual` value is an IPv6 address. fn eval_is_ipv6(actual: &Value) -> Result { match actual.is_ipv6() { Ok(success) => Ok(AssertResult { success, actual: actual.to_string(), expected: "string in IPv6 format".to_string(), type_mismatch: false, }), _ => Ok(AssertResult { success: false, actual: actual.repr(), expected: "string".to_string(), type_mismatch: true, }), } } fn assert_values_equal(actual: &Value, expected: &Value) -> AssertResult { let success = actual == expected; let actual = actual.repr(); let expected = expected.repr(); let type_mismatch = false; AssertResult { success, actual, expected, type_mismatch, } } fn assert_values_not_equal(actual: &Value, expected: &Value) -> AssertResult { let success = actual != expected; let actual = actual.repr(); let expected = expected.repr(); let type_mismatch = false; AssertResult { success, actual, expected, type_mismatch, } } fn assert_values_greater(actual_value: &Value, expected_value: &Value) -> AssertResult { let actual = actual_value.repr(); let expected = format!("greater than {}", expected_value.repr()); match actual_value.compare(expected_value) { Ok(ordering) => AssertResult { success: ordering == Ordering::Greater, actual, expected, type_mismatch: false, }, _ => AssertResult { success: false, actual, expected, type_mismatch: true, }, } } fn assert_values_greater_or_equal(actual_value: &Value, expected_value: &Value) -> AssertResult { let actual = actual_value.repr(); let expected = format!("greater or equal than {}", expected_value.repr()); match actual_value.compare(expected_value) { Ok(ordering) => AssertResult { success: ordering == Ordering::Greater || ordering == Ordering::Equal, actual, expected, type_mismatch: false, }, _ => AssertResult { success: false, actual, expected, type_mismatch: true, }, } } fn assert_values_less(actual_value: &Value, expected_value: &Value) -> AssertResult { let actual = actual_value.repr(); let expected = format!("less than {}", expected_value.repr()); match actual_value.compare(expected_value) { Ok(ordering) => AssertResult { success: ordering == Ordering::Less, actual, expected, type_mismatch: false, }, _ => AssertResult { success: false, actual, expected, type_mismatch: true, }, } } fn assert_values_less_or_equal(actual_value: &Value, expected_value: &Value) -> AssertResult { let actual = actual_value.repr(); let expected = format!("less or equal than {}", expected_value.repr()); match actual_value.compare(expected_value) { Ok(ordering) => AssertResult { success: ordering == Ordering::Less || ordering == Ordering::Equal, actual, expected, type_mismatch: false, }, _ => AssertResult { success: false, actual, expected, type_mismatch: true, }, } } fn assert_include(value: &Value, element: &Value) -> AssertResult { let actual = value.repr(); let expected = format!("includes {}", element.repr()); match value.includes(element) { Ok(success) => AssertResult { success, actual, expected, type_mismatch: false, }, Err(_) => AssertResult { success: false, actual, expected, type_mismatch: true, }, } } #[cfg(test)] mod tests { use std::path::Path; use hurl_core::ast::{ Expr, ExprKind, Float, Placeholder, Regex, Template, TemplateElement, Variable, Whitespace, I64, }; use hurl_core::typing::ToSource; use super::{AssertResult, *}; fn whitespace() -> Whitespace { Whitespace { value: String::from(" "), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), } } #[test] fn test_predicate() { // `not == 10` with value `1` OK // `not == 10` with value `10` ValueError // `not == 10` with value `true` => this is now valid let variables = VariableSet::new(); let current_dir = std::env::current_dir().unwrap(); let file_root = Path::new("file_root"); let context_dir = ContextDir::new(current_dir.as_path(), file_root); let whitespace = Whitespace { value: String::from(" "), source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(0, 0)), }; let predicate = Predicate { not: true, space0: whitespace.clone(), predicate_func: PredicateFunc { value: PredicateFuncValue::Equal { space0: whitespace, value: PredicateValue::Number(hurl_core::ast::Number::Integer(I64::new( 10, "10".to_source(), ))), }, source_info: SourceInfo::new(Pos::new(1, 11), Pos::new(1, 12)), }, }; assert!(eval_predicate( &predicate, &variables, &Some(Value::Bool(true)), &context_dir ) .is_ok()); let error = eval_predicate( &predicate, &variables, &Some(Value::Number(Number::Integer(10))), &context_dir, ) .unwrap_err(); assert_eq!( error.kind, RunnerErrorKind::AssertFailure { actual: "integer <10>".to_string(), expected: "not integer <10>".to_string(), type_mismatch: false, } ); assert_eq!( error.source_info, SourceInfo::new(Pos::new(1, 0), Pos::new(1, 0)) ); assert!(eval_predicate( &predicate, &variables, &Some(Value::Number(Number::Integer(1))), &context_dir ) .is_ok()); } #[test] fn test_predicate_type_mismatch() { let variables = VariableSet::new(); let current_dir = std::env::current_dir().unwrap(); let file_root = Path::new("file_root"); let context_dir = ContextDir::new(current_dir.as_path(), file_root); // predicate: `== 10` // value: true let expected = PredicateValue::Number(hurl_core::ast::Number::Integer(I64::new( 10, "10".to_source(), ))); let value = Value::Bool(true); let assert_result = eval_equal(&expected, &variables, &value, &context_dir).unwrap(); assert!(!assert_result.success); // FIXME: should be type_mismatch = true here // assert!(assert_result.type_mismatch); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "boolean "); assert_eq!(assert_result.expected, "integer <10>"); } #[test] fn test_predicate_type_mismatch_with_unit() { let variables = VariableSet::new(); let current_dir = std::env::current_dir().unwrap(); let file_root = Path::new("file_root"); let context_dir = ContextDir::new(current_dir.as_path(), file_root); // predicate: `== 10` // value: Unit let expected = PredicateValue::Number(hurl_core::ast::Number::Integer(I64::new( 10, "10".to_source(), ))); let value = Value::Unit; let assert_result = eval_equal(&expected, &variables, &value, &context_dir).unwrap(); assert!(!assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "unit"); assert_eq!(assert_result.expected, "integer <10>"); } #[test] fn test_predicate_value_error() { let variables = VariableSet::new(); let current_dir = std::env::current_dir().unwrap(); let file_root = Path::new("file_root"); let context_dir = ContextDir::new(current_dir.as_path(), file_root); // predicate: `== 10` // value: 1 let expected = PredicateValue::Number(hurl_core::ast::Number::Integer(I64::new( 10, "10".to_source(), ))); let value = Value::Number(Number::Integer(1)); let assert_result = eval_equal(&expected, &variables, &value, &context_dir).unwrap(); assert!(!assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "integer <1>"); assert_eq!(assert_result.expected, "integer <10>"); // predicate: `== true` // value: false let expected = PredicateValue::Bool(true); let value = Value::Bool(false); let assert_result = eval_equal(&expected, &variables, &value, &context_dir).unwrap(); assert!(!assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "boolean "); assert_eq!(assert_result.expected, "boolean "); // predicate: `== 1.2` // value: 1.1 let expected = PredicateValue::Number(hurl_core::ast::Number::Float(Float::new( 1.2, "1.2".to_source(), ))); let value = Value::Number(Number::Float(1.1)); let assert_result = eval_equal(&expected, &variables, &value, &context_dir).unwrap(); assert!(!assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "float <1.1>"); assert_eq!(assert_result.expected, "float <1.2>"); } #[test] fn test_predicate_exist() { let variables = VariableSet::new(); let current_dir = std::env::current_dir().unwrap(); let file_root = Path::new("file_root"); let context_dir = ContextDir::new(current_dir.as_path(), file_root); // predicate: `exist` // value: Some(Unit) | None let pred_func = PredicateFunc { value: PredicateFuncValue::Exist, source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }; let value = Some(&Value::Unit); let assert_result = eval_predicate_func(&pred_func, &variables, value, &context_dir).unwrap(); assert!(assert_result.success); assert_eq!(assert_result.actual.as_str(), "unit"); assert_eq!(assert_result.expected.as_str(), "something"); let value = None; let assert_result = eval_predicate_func(&pred_func, &variables, value, &context_dir).unwrap(); assert!(!assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "none"); assert_eq!(assert_result.expected, "something"); } #[test] fn test_predicate_value_equals_integers() { let variables = VariableSet::new(); let current_dir = std::env::current_dir().unwrap(); let file_root = Path::new("file_root"); let context_dir = ContextDir::new(current_dir.as_path(), file_root); // predicate: `== 1` // value: 1 let expected = PredicateValue::Number(hurl_core::ast::Number::Integer(I64::new( 1, "1".to_source(), ))); let value = Value::Number(Number::Integer(1)); let assert_result = eval_equal(&expected, &variables, &value, &context_dir).unwrap(); assert!(assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "integer <1>"); assert_eq!(assert_result.expected, "integer <1>"); } #[test] fn test_predicate_value_equals_booleans() { let variables = VariableSet::new(); let current_dir = std::env::current_dir().unwrap(); let file_root = Path::new("file_root"); let context_dir = ContextDir::new(current_dir.as_path(), file_root); // predicate: `== false` // value: false let expected = PredicateValue::Bool(false); let value = Value::Bool(false); let assert_result = eval_equal(&expected, &variables, &value, &context_dir).unwrap(); assert!(assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "boolean "); assert_eq!(assert_result.expected, "boolean "); // predicate: `== true` // value: false let expected = PredicateValue::Bool(true); let value = Value::Bool(false); let assert_result = eval_equal(&expected, &variables, &value, &context_dir).unwrap(); assert!(!assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "boolean "); assert_eq!(assert_result.expected, "boolean "); // predicate: `== true` // value: true let expected = PredicateValue::Bool(true); let value = Value::Bool(true); let assert_result = eval_equal(&expected, &variables, &value, &context_dir).unwrap(); assert!(assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "boolean "); assert_eq!(assert_result.expected, "boolean "); } #[test] fn test_predicate_value_equals_floats() { let variables = VariableSet::new(); let current_dir = std::env::current_dir().unwrap(); let file_root = Path::new("file_root"); let context_dir = ContextDir::new(current_dir.as_path(), file_root); // predicate: `== 1.1` // value: 1.1 let expected = PredicateValue::Number(hurl_core::ast::Number::Float(Float::new( 1.1, "1.1".to_source(), ))); let value = Value::Number(Number::Float(1.1)); let assert_result = eval_equal(&expected, &variables, &value, &context_dir).unwrap(); assert!(assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "float <1.1>"); assert_eq!(assert_result.expected, "float <1.1>"); } #[test] fn test_predicate_value_equals_float_integer() { let variables = VariableSet::new(); let current_dir = std::env::current_dir().unwrap(); let file_root = Path::new("file_root"); let context_dir = ContextDir::new(current_dir.as_path(), file_root); // predicate: `== 1` // value: 1.0 let expected = PredicateValue::Number(hurl_core::ast::Number::Integer(I64::new( 1, "1".to_source(), ))); let value = Value::Number(Number::Float(1.0)); let assert_result = eval_equal(&expected, &variables, &value, &context_dir).unwrap(); assert!(assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "float <1.0>"); assert_eq!(assert_result.expected, "integer <1>"); } #[test] fn test_predicate_value_not_equals() { let variables = VariableSet::new(); let current_dir = std::env::current_dir().unwrap(); let file_root = Path::new("file_root"); let context_dir = ContextDir::new(current_dir.as_path(), file_root); // predicate: `== 1` // value: 2 let expected = PredicateValue::Number(hurl_core::ast::Number::Integer(I64::new( 1, "1".to_source(), ))); let value = Value::Number(Number::Integer(2)); let assert_result = eval_equal(&expected, &variables, &value, &context_dir).unwrap(); assert!(!assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "integer <2>"); assert_eq!(assert_result.expected, "integer <1>"); } #[test] fn test_predicate_value_equals_string() { let variables = VariableSet::new(); let current_dir = std::env::current_dir().unwrap(); let file_root = Path::new("file_root"); let context_dir = ContextDir::new(current_dir.as_path(), file_root); // {{base_url}} let template = Template::new( Some('"'), vec![TemplateElement::Placeholder(Placeholder { space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(1, 11), Pos::new(1, 11)), }, expr: Expr { kind: ExprKind::Variable(Variable { name: "base_url".to_string(), source_info: SourceInfo::new(Pos::new(1, 11), Pos::new(1, 19)), }), source_info: SourceInfo::new(Pos::new(1, 11), Pos::new(1, 19)), }, space1: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(1, 19), Pos::new(1, 19)), }, })], SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), ); // predicate: `== "{{base_url}}"` // value: "http://localhost:8000" // base_url is not defined let expected = PredicateValue::String(template.clone()); let value = Value::String(String::from("http://localhost:8000")); let error = eval_equal(&expected, &variables, &value, &context_dir).unwrap_err(); assert_eq!( error.kind, RunnerErrorKind::TemplateVariableNotDefined { name: String::from("base_url") } ); assert_eq!( error.source_info, SourceInfo::new(Pos::new(1, 11), Pos::new(1, 19)) ); // predicate: `== "{{base_url}}"` // value: "http://localhost:8000" // variables: base_url=http://localhost:8080 let mut variables = VariableSet::new(); variables .insert( String::from("base_url"), Value::String(String::from("http://localhost:8000")), ) .unwrap(); let assert_result = eval_equal(&expected, &variables, &value, &context_dir).unwrap(); assert!(assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "string "); assert_eq!(assert_result.expected, "string "); } #[test] fn test_assert_value_greater() { assert_eq!( assert_values_greater( &Value::Number(Number::Integer(2)), &Value::Number(Number::Integer(1)) ), AssertResult { success: true, type_mismatch: false, actual: "integer <2>".to_string(), expected: "greater than integer <1>".to_string(), } ); assert_eq!( assert_values_greater( &Value::Number(Number::Integer(1)), &Value::Number(Number::Integer(1)) ), AssertResult { success: false, type_mismatch: false, actual: "integer <1>".to_string(), expected: "greater than integer <1>".to_string(), } ); assert_eq!( assert_values_greater( &Value::Number(Number::Float(1.1)), &Value::Number(Number::Integer(1)) ), AssertResult { success: true, type_mismatch: false, actual: "float <1.1>".to_string(), expected: "greater than integer <1>".to_string(), } ); assert_eq!( assert_values_greater( &Value::Number(Number::Float(1.1)), &Value::Number(Number::Integer(2)) ), AssertResult { success: false, type_mismatch: false, actual: "float <1.1>".to_string(), expected: "greater than integer <2>".to_string(), } ); } #[test] fn test_predicate_is_empty_are_false() { // predicate: `isEmpty` // value: [1] let value = Value::List(vec![Value::Number(Number::Integer(1))]); let assert_result = eval_is_empty(&value).unwrap(); assert!(!assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "count equals to 1"); assert_eq!(assert_result.expected, "count equals to 0"); // predicate: `isEmpty` // value: Nodeset(12) let value = Value::Nodeset(12); let assert_result = eval_is_empty(&value).unwrap(); assert!(!assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "count equals to 12"); assert_eq!(assert_result.expected, "count equals to 0"); } #[test] fn test_predicate_is_empty_are_true() { // predicate: `isEmpty` // value: [1] let value = Value::List(vec![]); let assert_result = eval_is_empty(&value).unwrap(); assert!(assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "count equals to 0"); assert_eq!(assert_result.expected, "count equals to 0"); // predicate: `isEmpty` // value: Nodeset(0) let value = Value::Nodeset(0); let assert_result = eval_is_empty(&value).unwrap(); assert!(assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "count equals to 0"); assert_eq!(assert_result.expected, "count equals to 0"); } #[test] fn test_predicate_type() { // predicate: `isInteger` // value: 1 let value = Value::Number(Number::Integer(1)); let assert_result = eval_is_integer(&value).unwrap(); assert!(assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "integer <1>"); assert_eq!(assert_result.expected, "integer"); // predicate: `isInteger` // value: 1 let value = Value::Number(Number::Float(1.0)); let assert_result = eval_is_integer(&value).unwrap(); assert!(!assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "float <1.0>"); assert_eq!(assert_result.expected, "integer"); } #[test] fn test_predicate_not_with_different_types() { let current_dir = std::env::current_dir().unwrap(); let file_root = Path::new("file_root"); let context_dir = ContextDir::new(current_dir.as_path(), file_root); // equals predicate does not generate a type error with an integer value // predicate: `not == null` // value: 1 let predicate = Predicate { not: true, space0: whitespace(), predicate_func: PredicateFunc { source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), value: PredicateFuncValue::Equal { space0: whitespace(), value: PredicateValue::Null, }, }, }; let variables = VariableSet::new(); assert!(eval_predicate( &predicate, &variables, &Some(Value::Number(Number::Integer(1))), &context_dir ) .is_ok()); // startswith predicate generates a type error with an integer value // predicate: `not startWith "toto"` // value: 1 let predicate = Predicate { not: true, space0: whitespace(), predicate_func: PredicateFunc { source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), value: PredicateFuncValue::StartWith { space0: whitespace(), value: PredicateValue::String(Template::new( None, vec![TemplateElement::String { value: "toto".to_string(), source: "toto".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), )), }, }, }; let error = eval_predicate( &predicate, &variables, &Some(Value::Number(Number::Integer(1))), &context_dir, ) .unwrap_err(); assert_eq!( error.kind, RunnerErrorKind::AssertFailure { actual: "integer <1>".to_string(), expected: "not starts with string ".to_string(), type_mismatch: true, } ); } #[test] fn test_date_predicate() { // predicate: `isDate` // value: 2002-06-16T10:10:10 let value = Value::Date( chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2002, 6, 16, 10, 10, 10).unwrap(), ); let assert_result = eval_is_date(&value).unwrap(); assert!(assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "date <2002-06-16 10:10:10 UTC>"); assert_eq!(assert_result.expected, "date"); // predicate: `isDate` // value: "toto" let value = Value::String("toto".to_string()); let assert_result = eval_is_date(&value).unwrap(); assert!(!assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "string "); assert_eq!(assert_result.expected, "date"); } #[test] fn test_no_type_mismatch_with_none_value() { let variables = VariableSet::new(); let current_dir = std::env::current_dir().unwrap(); let file_root = Path::new("file_root"); let context_dir = ContextDir::new(current_dir.as_path(), file_root); // predicate: `== null` let predicate = Predicate { not: false, space0: whitespace(), predicate_func: PredicateFunc { source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), value: PredicateFuncValue::Equal { space0: whitespace(), value: PredicateValue::Null, }, }, }; let error = eval_predicate(&predicate, &variables, &None, &context_dir) .err() .unwrap(); assert_eq!( error.kind, RunnerErrorKind::AssertFailure { actual: "none".to_string(), expected: "null".to_string(), type_mismatch: false, } ); // predicate: `not == null` let predicate = Predicate { not: true, space0: whitespace(), predicate_func: PredicateFunc { source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), value: PredicateFuncValue::Equal { space0: whitespace(), value: PredicateValue::Null, }, }, }; let variables = VariableSet::new(); assert!(eval_predicate(&predicate, &variables, &None, &context_dir).is_ok()); } #[test] fn test_predicate_match() { let variables = VariableSet::new(); let current_dir = std::env::current_dir().unwrap(); let file_root = Path::new("file_root"); let context_dir = ContextDir::new(current_dir.as_path(), file_root); // predicate: `matches /a{3}/` // value: aa let expected = PredicateValue::Regex(Regex { inner: regex::Regex::new(r#"a{3}"#).unwrap(), }); let value = Value::String("aa".to_string()); let source_info = SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)); let assert_result = eval_match(&expected, source_info, &variables, &value, &context_dir).unwrap(); assert!(!assert_result.success); assert!(!assert_result.type_mismatch); assert_eq!(assert_result.actual, "string "); assert_eq!(assert_result.expected, "matches regex "); } #[test] fn test_predicate_is_iso_date() { let value = Value::String("2020-03-09T22:18:26.625Z".to_string()); let res = eval_is_iso_date(&value).unwrap(); assert!(res.success); assert!(!res.type_mismatch); assert_eq!(res.actual, "2020-03-09T22:18:26.625Z"); assert_eq!(res.expected, "string with format YYYY-MM-DDTHH:mm:ss.sssZ"); } #[test] fn test_predicate_is_number() { let value = Value::Number(Number::Integer(1)); let res = eval_is_number(&value).unwrap(); assert!(res.success); assert!(!res.type_mismatch); assert_eq!(res.actual, "integer <1>"); assert_eq!(res.expected, "number"); let value = Value::Number(Number::Float(1.0)); let res = eval_is_number(&value).unwrap(); assert!(res.success); assert!(!res.type_mismatch); assert_eq!(res.actual, "float <1.0>"); assert_eq!(res.expected, "number"); } } hurl-6.1.1/src/runner/predicate_value.rs000064400000000000000000000056741046102023000164210ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::{Number, Placeholder, PredicateValue}; use crate::runner::body::eval_file; // TODO move function out of body module use crate::runner::error::RunnerError; use crate::runner::expr::eval; use crate::runner::multiline::eval_multiline; use crate::runner::template::eval_template; use crate::runner::{Number as ValueNumber, Value, VariableSet}; use crate::util::path::ContextDir; pub fn eval_predicate_value( predicate_value: &PredicateValue, variables: &VariableSet, context_dir: &ContextDir, ) -> Result { match predicate_value { PredicateValue::String(template) => { let s = eval_template(template, variables)?; Ok(Value::String(s)) } PredicateValue::MultilineString(value) => { let s = eval_multiline(value, variables)?; Ok(Value::String(s)) } PredicateValue::Bool(value) => Ok(Value::Bool(*value)), PredicateValue::Null => Ok(Value::Null), PredicateValue::Number(value) => Ok(Value::Number(eval_number(value))), PredicateValue::File(value) => { let value = eval_file(&value.filename, variables, context_dir)?; Ok(Value::Bytes(value)) } PredicateValue::Hex(value) => Ok(Value::Bytes(value.value.clone())), PredicateValue::Base64(value) => Ok(Value::Bytes(value.value.clone())), PredicateValue::Placeholder(Placeholder { expr, .. }) => { let value = eval(expr, variables)?; Ok(value) } PredicateValue::Regex(regex) => Ok(Value::Regex(regex.inner.clone())), } } pub fn eval_predicate_value_template( predicate_value: &PredicateValue, variables: &VariableSet, ) -> Result { match predicate_value { PredicateValue::String(template) => eval_template(template, variables), PredicateValue::Regex(regex) => Ok(regex.inner.to_string()), // All others value should have failed in parsing: _ => panic!("expect a string or a regex predicate value"), } } fn eval_number(number: &Number) -> ValueNumber { match number { Number::Float(value) => ValueNumber::Float(value.as_f64()), Number::Integer(value) => ValueNumber::Integer(value.as_i64()), Number::BigInteger(value) => ValueNumber::BigInteger(value.clone()), } } hurl-6.1.1/src/runner/query.rs000064400000000000000000001317571046102023000144340ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::{ CertificateAttributeName, CookieAttribute, CookieAttributeName, CookiePath, Query, QueryValue, RegexValue, SourceInfo, Template, }; use regex::Regex; use sha2::Digest; use crate::http; use crate::runner::cache::BodyCache; use crate::runner::error::{RunnerError, RunnerErrorKind}; use crate::runner::template::eval_template; use crate::runner::xpath::{Document, Format}; use crate::runner::{filter, Number, Value, VariableSet}; pub type QueryResult = Result, RunnerError>; /// Evaluates this `query` and returns a [`QueryResult`], using the HTTP `response` and `variables`. pub fn eval_query( query: &Query, variables: &VariableSet, response: &http::Response, cache: &mut BodyCache, ) -> QueryResult { match &query.value { QueryValue::Status => eval_query_status(response), QueryValue::Version => eval_query_version(response), QueryValue::Url => eval_query_url(response), QueryValue::Header { name, .. } => eval_query_header(response, name, variables), QueryValue::Cookie { expr: CookiePath { name, attribute }, .. } => eval_query_cookie(response, name, attribute, variables), QueryValue::Body => eval_query_body(response, query.source_info), QueryValue::Xpath { expr, .. } => { eval_query_xpath(response, cache, expr, variables, query.source_info) } QueryValue::Jsonpath { expr, .. } => { eval_query_jsonpath(response, cache, expr, variables, query.source_info) } QueryValue::Regex { value, .. } => { eval_query_regex(response, value, variables, query.source_info) } QueryValue::Variable { name, .. } => eval_query_variable(name, variables), QueryValue::Duration => eval_query_duration(response), QueryValue::Bytes => eval_query_bytes(response, query.source_info), QueryValue::Sha256 => eval_query_sha256(response, query.source_info), QueryValue::Md5 => eval_query_md5(response, query.source_info), QueryValue::Certificate { attribute_name: field, .. } => eval_query_certificate(response, *field), QueryValue::Ip => eval_ip(response), } } /// Evaluates the response status code using the HTTP `response`. fn eval_query_status(response: &http::Response) -> QueryResult { Ok(Some(Value::Number(Number::Integer(i64::from( response.status, ))))) } /// Evaluates the version on the HTTP `response` fn eval_query_version(response: &http::Response) -> QueryResult { Ok(Some(Value::String( response .version .to_string() .strip_prefix("HTTP/") .unwrap() .to_string(), ))) } /// Evaluates the final URL of the HTTP `response`. fn eval_query_url(response: &http::Response) -> QueryResult { Ok(Some(Value::String(response.url.to_string()))) } /// Evaluates a response query header `name`, on the HTTP `response` given a set of `variables`. fn eval_query_header( response: &http::Response, name: &Template, variables: &VariableSet, ) -> QueryResult { let name = eval_template(name, variables)?; let values = response.headers.values(&name); if values.is_empty() { Ok(None) } else if values.len() == 1 { let value = values.first().unwrap().to_string(); Ok(Some(Value::String(value))) } else { let values = values .iter() .map(|v| Value::String(v.to_string())) .collect(); Ok(Some(Value::List(values))) } } /// Evaluates a cookie query `name` with optional attributes, on the HTTP `response` given a set of `variables`. fn eval_query_cookie( response: &http::Response, name: &Template, attribute: &Option, variables: &VariableSet, ) -> QueryResult { let name = eval_template(name, variables)?; match response.get_cookie(&name) { None => Ok(None), Some(cookie) => { let attribute_name = if let Some(attribute) = attribute { attribute.name.clone() } else { CookieAttributeName::Value("Value".to_string()) }; Ok(eval_cookie_attribute_name(attribute_name, cookie)) } } } /// Evaluates the HTTP `response` body as text. /// /// `query_source_info` is the source position of the query, used if an error is returned. fn eval_query_body(response: &http::Response, query_source_info: SourceInfo) -> QueryResult { // Can return a string if encoding is known and utf8. match response.text() { Ok(s) => Ok(Some(Value::String(s))), Err(inner) => Err(RunnerError::new( query_source_info, RunnerErrorKind::Http(inner), false, )), } } /// Evaluates a XPath expression on the HTTP `response` body, given a set of `variables`. /// /// `query_source_info` is the source position of the query, used if an error is returned. fn eval_query_xpath( response: &http::Response, cache: &mut BodyCache, expr: &Template, variables: &VariableSet, query_source_info: SourceInfo, ) -> QueryResult { let doc = match cache.xml() { Some(d) => d, None => parse_cache_xml(response, cache, query_source_info)?, }; filter::eval_xpath_doc(doc, expr, variables) } /// Parse this HTTP `response` body to a structured XML document, and store the document to the /// response `cache`. /// /// `query_source_info` is used for error reporting. fn parse_cache_xml<'cache>( response: &http::Response, cache: &'cache mut BodyCache, query_source_info: SourceInfo, ) -> Result<&'cache Document, RunnerError> { // Get the response as text if possible let text = match response.text() { Ok(t) => t, Err(e) => { return Err(RunnerError::new( query_source_info, RunnerErrorKind::Http(e), false, )) } }; let format = if response.is_html() { Format::Html } else { Format::Xml }; let Ok(doc) = Document::parse(&text, format) else { return Err(RunnerError::new( query_source_info, RunnerErrorKind::QueryInvalidXml, false, )); }; // Everything is ok, we can put the response in the cache cache.set_xml(doc); Ok(cache.xml().unwrap()) } /// Evaluates a JSONPath expression on the HTTP `response` body, given a set of `variables`. /// /// `query_source_info` is the source position of the query, used if an error is returned. fn eval_query_jsonpath( response: &http::Response, cache: &mut BodyCache, expr: &Template, variables: &VariableSet, query_source_info: SourceInfo, ) -> QueryResult { let json = match cache.json() { Some(j) => j, None => parse_cache_json(response, cache, query_source_info)?, }; filter::eval_jsonpath_json(json, expr, variables) } /// Parse this HTTP `response` body to JSON, and store the document to the response `cache`. /// /// `query_source_info` is used for error reporting. fn parse_cache_json<'cache>( response: &http::Response, cache: &'cache mut BodyCache, query_source_info: SourceInfo, ) -> Result<&'cache serde_json::Value, RunnerError> { // Get the response as text if possible let text = match response.text() { Ok(t) => t, Err(e) => { return Err(RunnerError::new( query_source_info, RunnerErrorKind::Http(e), false, )) } }; let json = match serde_json::from_str(&text) { Err(_) => { return Err(RunnerError::new( query_source_info, RunnerErrorKind::QueryInvalidJson, false, )); } Ok(v) => v, }; // Everything is ok, we can put the response in the cache cache.set_json(json); Ok(cache.json().unwrap()) } /// Evaluates a regex query on the HTTP `response` body, given a set of `variables`. /// /// `query_source_info` is the source position of the query, used if an error is returned. fn eval_query_regex( response: &http::Response, regex: &RegexValue, variables: &VariableSet, query_source_info: SourceInfo, ) -> QueryResult { let s = match response.text() { Ok(v) => v, Err(inner) => { return Err(RunnerError::new( query_source_info, RunnerErrorKind::Http(inner), false, )) } }; let re = match regex { RegexValue::Template(t) => { let value = eval_template(t, variables)?; match Regex::new(value.as_str()) { Ok(re) => re, Err(_) => { return Err(RunnerError::new( t.source_info, RunnerErrorKind::InvalidRegex, false, )) } } } RegexValue::Regex(re) => re.inner.clone(), }; match re.captures(s.as_str()) { Some(captures) => match captures.get(1) { Some(v) => Ok(Some(Value::String(v.as_str().to_string()))), None => Ok(None), }, None => Ok(None), } } /// Evaluates a variable, given a set of `variables`. fn eval_query_variable(name: &Template, variables: &VariableSet) -> QueryResult { let name = eval_template(name, variables)?; if let Some(variable) = variables.get(&name) { Ok(Some(variable.value().clone())) } else { Ok(None) } } /// Evaluates the effective duration of the HTTP `response` (only transfer time, assert and captures /// are not taken into account). fn eval_query_duration(response: &http::Response) -> QueryResult { Ok(Some(Value::Number(Number::Integer( response.duration.as_millis() as i64, )))) } /// Evaluates the HTTP `response` body as bytes. /// /// `query_source_info` is the source position of the query, used if an error is returned. fn eval_query_bytes(response: &http::Response, query_source_info: SourceInfo) -> QueryResult { match response.uncompress_body() { Ok(s) => Ok(Some(Value::Bytes(s))), Err(inner) => Err(RunnerError::new( query_source_info, RunnerErrorKind::Http(inner), false, )), } } /// Evaluates the SHA-256 hash of the HTTP `response` body bytes. /// /// `query_source_info` is the source position of the query, used if an error is returned. fn eval_query_sha256(response: &http::Response, query_source_info: SourceInfo) -> QueryResult { let bytes = match response.uncompress_body() { Ok(s) => s, Err(inner) => { return Err(RunnerError::new( query_source_info, RunnerErrorKind::Http(inner), false, )); } }; let mut hasher = sha2::Sha256::new(); hasher.update(bytes); let result = hasher.finalize(); let bytes = Value::Bytes(result[..].to_vec()); Ok(Some(bytes)) } /// Evaluates the MD-5 hash of the HTTP `response` body bytes. /// /// `query_source_info` is the source position of the query, used if an error is returned. fn eval_query_md5(response: &http::Response, query_source_info: SourceInfo) -> QueryResult { let bytes = match response.uncompress_body() { Ok(s) => s, Err(inner) => { return Err(RunnerError::new( query_source_info, RunnerErrorKind::Http(inner), false, )); } }; let bytes = md5::compute(bytes).to_vec(); Ok(Some(Value::Bytes(bytes))) } /// Evaluates the SSL certificate attribute, of the HTTP `response`. fn eval_query_certificate( response: &http::Response, certificate_attribute: CertificateAttributeName, ) -> QueryResult { if let Some(certificate) = &response.certificate { let value = match certificate_attribute { CertificateAttributeName::Subject => Value::String(certificate.subject.clone()), CertificateAttributeName::Issuer => Value::String(certificate.issuer.clone()), CertificateAttributeName::StartDate => Value::Date(certificate.start_date), CertificateAttributeName::ExpireDate => Value::Date(certificate.expire_date), CertificateAttributeName::SerialNumber => { Value::String(certificate.serial_number.clone()) } }; Ok(Some(value)) } else { Ok(None) } } /// Evaluates the ip address of the HTTP `response`. fn eval_ip(response: &http::Response) -> QueryResult { Ok(Some(Value::String(response.ip_addr.to_string()))) } fn eval_cookie_attribute_name( cookie_attribute_name: CookieAttributeName, cookie: http::ResponseCookie, ) -> Option { match cookie_attribute_name { CookieAttributeName::Value(_) => Some(Value::String(cookie.value)), CookieAttributeName::Expires(_) => { if let Some(s) = cookie.expires() { match chrono::DateTime::parse_from_rfc2822(s.as_str()) { Ok(v) => Some(Value::Date(v.with_timezone(&chrono::Utc))), Err(_) => todo!(), } } else { None } } CookieAttributeName::MaxAge(_) => { cookie.max_age().map(|v| Value::Number(Number::Integer(v))) } CookieAttributeName::Domain(_) => cookie.domain().map(Value::String), CookieAttributeName::Path(_) => cookie.path().map(Value::String), CookieAttributeName::Secure(_) => { if cookie.has_secure() { Some(Value::Unit) } else { None } } CookieAttributeName::HttpOnly(_) => { if cookie.has_httponly() { Some(Value::Unit) } else { None } } CookieAttributeName::SameSite(_) => cookie.samesite().map(Value::String), } } impl Value { pub fn from_json(value: &serde_json::Value) -> Value { match value { serde_json::Value::Null => Value::Null, serde_json::Value::Bool(bool) => Value::Bool(*bool), serde_json::Value::Number(n) => { if n.is_f64() { Value::Number(Number::from(n.as_f64().unwrap())) } else if n.is_i64() { Value::Number(Number::from(n.as_i64().unwrap())) } else { Value::Number(Number::BigInteger(n.to_string())) } } serde_json::Value::String(s) => Value::String(s.to_string()), serde_json::Value::Array(elements) => { Value::List(elements.iter().map(Value::from_json).collect()) } serde_json::Value::Object(map) => { let mut elements = vec![]; for (key, value) in map { elements.push((key.to_string(), Value::from_json(value))); // } Value::Object(elements) } } } } #[cfg(test)] pub mod tests { use std::num::ParseIntError; use hurl_core::ast::{SourceInfo, TemplateElement, Whitespace}; use hurl_core::reader::Pos; use hurl_core::typing::ToSource; use super::*; use crate::http::{HeaderVec, HttpError, HttpVersion}; fn default_response() -> http::Response { http::Response { version: HttpVersion::Http10, status: 200, headers: HeaderVec::new(), body: vec![], duration: Default::default(), url: "http://localhost".parse().unwrap(), certificate: None, ip_addr: Default::default(), } } pub fn xpath_invalid_query() -> Query { // xpath ??? let whitespace = Whitespace { value: String::from(" "), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }; Query { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 13)), value: QueryValue::Xpath { space0: whitespace, expr: Template::new( Some('"'), vec![TemplateElement::String { value: "???".to_string(), source: "???".to_source(), }], SourceInfo::new(Pos::new(1, 7), Pos::new(1, 10)), ), }, } } pub fn xpath_count_user_query() -> Query { let whitespace = Whitespace { value: String::from(" "), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }; Query { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 13)), value: QueryValue::Xpath { space0: whitespace, expr: Template::new( Some('"'), vec![TemplateElement::String { value: "count(//user)".to_string(), source: "count(//user)".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), }, } } pub fn xpath_users() -> Query { let whitespace = Whitespace { value: String::from(" "), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }; Query { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 13)), value: QueryValue::Xpath { space0: whitespace, expr: Template::new( Some('"'), vec![TemplateElement::String { value: "//user".to_string(), source: "/user".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), }, } } pub fn jsonpath_success() -> Query { // jsonpath $.success Query { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 19)), value: QueryValue::Jsonpath { space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(1, 9), Pos::new(1, 10)), }, expr: Template::new( Some('"'), vec![TemplateElement::String { value: "$.success".to_string(), source: "$.success".to_source(), }], SourceInfo::new(Pos::new(1, 10), Pos::new(1, 19)), ), }, } } pub fn jsonpath_errors() -> Query { // jsonpath $.errors Query { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 19)), value: QueryValue::Jsonpath { space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(1, 9), Pos::new(1, 10)), }, expr: Template::new( Some('"'), vec![TemplateElement::String { value: "$.errors".to_string(), source: "$.errors".to_source(), }], SourceInfo::new(Pos::new(1, 10), Pos::new(1, 18)), ), }, } } pub fn jsonpath_duration() -> Query { // jsonpath $.errors Query { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 19)), value: QueryValue::Jsonpath { space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(1, 9), Pos::new(1, 10)), }, expr: Template::new( Some('"'), vec![TemplateElement::String { value: "$.duration".to_string(), source: "$.duration".to_source(), }], SourceInfo::new(Pos::new(1, 10), Pos::new(1, 18)), ), }, } } pub fn regex_name() -> Query { // regex "Hello ([a-zA-Z]+)!" Query { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 26)), value: QueryValue::Regex { space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(1, 6), Pos::new(1, 7)), }, value: RegexValue::Template(Template::new( Some('"'), vec![TemplateElement::String { value: "Hello ([a-zA-Z]+)!".to_string(), source: "Hello ([a-zA-Z]+)!".to_source(), }], SourceInfo::new(Pos::new(1, 7), Pos::new(1, 26)), )), }, } } pub fn regex_invalid() -> Query { // regex ???" Query { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 26)), value: QueryValue::Regex { space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(1, 6), Pos::new(1, 7)), }, value: RegexValue::Template(Template::new( Some('"'), vec![TemplateElement::String { value: "???".to_string(), source: "???".to_source(), }], SourceInfo::new(Pos::new(1, 7), Pos::new(1, 10)), )), }, } } #[test] pub fn value_from_json() { let json_number: serde_json::Value = serde_json::from_str("1000").unwrap(); assert_eq!( Value::from_json(&json_number), Value::Number(Number::Integer(1000)) ); let json_number: serde_json::Value = serde_json::from_str("1.0").unwrap(); assert_eq!( Value::from_json(&json_number), Value::Number(Number::Float(1.0)) ); let json_number: serde_json::Value = serde_json::from_str("1000000000000000000000").unwrap(); assert_eq!( Value::from_json(&json_number), Value::Number(Number::BigInteger("1000000000000000000000".to_string())) ); let json_number: serde_json::Value = serde_json::from_str("1000000000000000000000.5").unwrap(); assert_eq!( Value::from_json(&json_number), Value::Number(Number::Float(1000000000000000000000.5f64)) ); } #[test] fn test_query_status() { let variables = VariableSet::new(); let mut cache = BodyCache::new(); assert_eq!( eval_query( &Query { source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), value: QueryValue::Status, }, &variables, &http::hello_http_response(), &mut cache, ) .unwrap() .unwrap(), Value::Number(Number::Integer(200)) ); } #[test] fn test_header_not_found() { let variables = VariableSet::new(); let mut cache = BodyCache::new(); // header Custom let query_header = Query { source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), value: QueryValue::Header { space0: Whitespace { value: String::from(" "), source_info: SourceInfo::new(Pos::new(1, 7), Pos::new(1, 8)), }, name: Template::new( Some('"'), vec![TemplateElement::String { value: "Custom".to_string(), source: "Custom".to_source(), }], SourceInfo::new(Pos::new(2, 8), Pos::new(2, 14)), ), }, }; // let error = query_header.eval(http::hello_http_response()).err().unwrap(); // assert_eq!(error.source_info.start, Pos { line: 1, column: 8 }); // assert_eq!(error.inner, RunnerError::QueryHeaderNotFound); assert_eq!( eval_query( &query_header, &variables, &http::hello_http_response(), &mut cache ) .unwrap(), None ); } #[test] fn test_header() { // header Content-Type let variables = VariableSet::new(); let mut cache = BodyCache::new(); let query_header = Query { source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), value: QueryValue::Header { space0: Whitespace { value: String::from(" "), source_info: SourceInfo::new(Pos::new(1, 7), Pos::new(1, 8)), }, name: Template::new( Some('"'), vec![TemplateElement::String { value: "Content-Type".to_string(), source: "Content-Type".to_source(), }], SourceInfo::new(Pos::new(1, 8), Pos::new(1, 16)), ), }, }; assert_eq!( eval_query( &query_header, &variables, &http::hello_http_response(), &mut cache ) .unwrap() .unwrap(), Value::String(String::from("text/html; charset=utf-8")) ); } #[test] fn test_query_cookie() { let variables = VariableSet::new(); let mut cache = BodyCache::new(); let space = Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }; let mut headers = HeaderVec::new(); headers.push(http::Header::new("Set-Cookie", "LSID=DQAAAKEaem_vYg; Path=/accounts; Expires=Wed, 13 Jan 2021 22:23:01 GMT; Secure; HttpOnly")); let response = http::Response { headers, ..default_response() }; // cookie "LSID" let query = Query { source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), value: QueryValue::Cookie { space0: space.clone(), expr: CookiePath { name: Template::new( Some('"'), vec![TemplateElement::String { value: "LSID".to_string(), source: "LSID".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), attribute: None, }, }, }; assert_eq!( eval_query(&query, &variables, &response, &mut cache) .unwrap() .unwrap(), Value::String("DQAAAKEaem_vYg".to_string()) ); // cookie "LSID[Path]" let query = Query { source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), value: QueryValue::Cookie { space0: space.clone(), expr: CookiePath { name: Template::new( Some('"'), vec![TemplateElement::String { value: "LSID".to_string(), source: "LSID".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), attribute: Some(CookieAttribute { space0: space.clone(), name: CookieAttributeName::Path("Path".to_string()), space1: space.clone(), }), }, }, }; assert_eq!( eval_query(&query, &variables, &response, &mut cache) .unwrap() .unwrap(), Value::String("/accounts".to_string()) ); // cookie "LSID[Secure]" let query = Query { source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), value: QueryValue::Cookie { space0: space.clone(), expr: CookiePath { name: Template::new( Some('"'), vec![TemplateElement::String { value: "LSID".to_string(), source: "LSID".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), attribute: Some(CookieAttribute { space0: space.clone(), name: CookieAttributeName::Secure("Secure".to_string()), space1: space.clone(), }), }, }, }; assert_eq!( eval_query(&query, &variables, &response, &mut cache) .unwrap() .unwrap(), Value::Unit ); // cookie "LSID[Domain]" let query = Query { source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), value: QueryValue::Cookie { space0: space.clone(), expr: CookiePath { name: Template::new( Some('"'), vec![TemplateElement::String { value: "LSID".to_string(), source: "LSID".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), attribute: Some(CookieAttribute { space0: space.clone(), name: CookieAttributeName::Domain("Domain".to_string()), space1: space, }), }, }, }; assert_eq!( eval_query(&query, &variables, &response, &mut cache).unwrap(), None ); } #[test] fn test_eval_cookie_attribute_name() { let cookie = http::ResponseCookie { name: "LSID".to_string(), value: "DQAAAKEaem_vYg".to_string(), attributes: vec![ http::CookieAttribute { name: "Path".to_string(), value: Some("/accounts".to_string()), }, http::CookieAttribute { name: "Expires".to_string(), value: Some("Wed, 13 Jan 2021 22:23:01 GMT".to_string()), }, http::CookieAttribute { name: "Secure".to_string(), value: None, }, http::CookieAttribute { name: "HttpOnly".to_string(), value: None, }, ], }; assert_eq!( eval_cookie_attribute_name(CookieAttributeName::Value("_".to_string()), cookie.clone()) .unwrap(), Value::String("DQAAAKEaem_vYg".to_string()) ); assert_eq!( eval_cookie_attribute_name( CookieAttributeName::Domain("_".to_string()), cookie.clone(), ), None ); assert_eq!( eval_cookie_attribute_name(CookieAttributeName::Path("_".to_string()), cookie.clone()) .unwrap(), Value::String("/accounts".to_string()) ); assert_eq!( eval_cookie_attribute_name( CookieAttributeName::MaxAge("_".to_string()), cookie.clone(), ), None ); assert_eq!( eval_cookie_attribute_name( CookieAttributeName::Expires("_".to_string()), cookie.clone(), ) .unwrap(), Value::Date( chrono::DateTime::parse_from_rfc2822("Wed, 13 Jan 2021 22:23:01 GMT") .unwrap() .with_timezone(&chrono::Utc) ), ); assert_eq!( eval_cookie_attribute_name( CookieAttributeName::Secure("_".to_string()), cookie.clone(), ) .unwrap(), Value::Unit ); assert_eq!( eval_cookie_attribute_name( CookieAttributeName::HttpOnly("_".to_string()), cookie.clone(), ) .unwrap(), Value::Unit ); assert_eq!( eval_cookie_attribute_name(CookieAttributeName::SameSite("_".to_string()), cookie), None ); } #[test] fn test_body() { let variables = VariableSet::new(); let mut cache = BodyCache::new(); assert_eq!( eval_query( &Query { source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), value: QueryValue::Body, }, &variables, &http::hello_http_response(), &mut cache, ) .unwrap() .unwrap(), Value::String(String::from("Hello World!")) ); let error = eval_query( &Query { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 2)), value: QueryValue::Body, }, &variables, &http::bytes_http_response(), &mut cache, ) .err() .unwrap(); assert_eq!( error.source_info, SourceInfo::new(Pos::new(1, 1), Pos::new(1, 2)) ); assert_eq!( error.kind, RunnerErrorKind::Http(HttpError::InvalidDecoding { charset: "utf-8".to_string() }) ); } #[test] fn test_query_invalid_utf8() { let variables = VariableSet::new(); let mut cache = BodyCache::new(); let http_response = http::Response { body: vec![200], ..default_response() }; let error = eval_query(&xpath_users(), &variables, &http_response, &mut cache) .err() .unwrap(); assert_eq!(error.source_info.start, Pos { line: 1, column: 1 }); assert_eq!( error.kind, RunnerErrorKind::Http(HttpError::InvalidDecoding { charset: "utf-8".to_string() }) ); } #[test] fn test_query_xpath_error_eval() { let variables = VariableSet::new(); let mut cache = BodyCache::new(); // xpath ^^^ let query = Query { source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), value: QueryValue::Xpath { space0: Whitespace { value: String::from(" "), source_info: SourceInfo::new(Pos::new(1, 6), Pos::new(1, 7)), }, expr: Template::new( Some('"'), vec![TemplateElement::String { value: "^^^".to_string(), source: "^^^".to_source(), }], SourceInfo::new(Pos::new(1, 7), Pos::new(1, 10)), ), }, }; let error = eval_query( &query, &variables, &http::xml_two_users_http_response(), &mut cache, ) .unwrap_err(); assert_eq!(error.kind, RunnerErrorKind::QueryInvalidXpathEval); assert_eq!(error.source_info.start, Pos { line: 1, column: 7 }); } #[test] fn test_query_xpath() { let variables = VariableSet::new(); let mut cache = BodyCache::new(); assert_eq!( eval_query( &xpath_users(), &variables, &http::xml_two_users_http_response(), &mut cache, ) .unwrap() .unwrap(), Value::Nodeset(2) ); assert_eq!( eval_query( &xpath_count_user_query(), &variables, &http::xml_two_users_http_response(), &mut cache, ) .unwrap() .unwrap(), Value::Number(Number::Float(2.0)) ); } #[cfg(test)] pub fn xpath_html_charset() -> Query { // $x("normalize-space(/html/head/meta/@charset)") let whitespace = Whitespace { value: String::from(" "), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }; Query { source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 13)), value: QueryValue::Xpath { space0: whitespace, expr: Template::new( Some('"'), vec![TemplateElement::String { value: "normalize-space(/html/head/meta/@charset)".to_string(), source: "normalize-space(/html/head/meta/@charset)".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), }, } } #[test] fn test_query_xpath_with_html() { let variables = VariableSet::new(); let mut cache = BodyCache::new(); assert_eq!( eval_query( &xpath_html_charset(), &variables, &http::html_http_response(), &mut cache, ) .unwrap() .unwrap(), Value::String(String::from("UTF-8")) ); } #[test] fn test_query_jsonpath_invalid_expression() { let variables = VariableSet::new(); let mut cache = BodyCache::new(); // jsonpath xxx let jsonpath_query = Query { source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), value: QueryValue::Jsonpath { space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(1, 9), Pos::new(1, 10)), }, expr: Template::new( Some('"'), vec![TemplateElement::String { value: "xxx".to_string(), source: "xxx".to_source(), }], SourceInfo::new(Pos::new(1, 10), Pos::new(1, 13)), ), }, }; let error = eval_query( &jsonpath_query, &variables, &http::json_http_response(), &mut cache, ) .unwrap_err(); assert_eq!( error.source_info.start, Pos { line: 1, column: 10, } ); assert_eq!( error.kind, RunnerErrorKind::QueryInvalidJsonpathExpression { value: "xxx".to_string() } ); } #[test] fn test_query_invalid_json() { let variables = VariableSet::new(); let mut cache = BodyCache::new(); let http_response = http::Response { body: String::into_bytes(String::from("xxx")), ..default_response() }; let error = eval_query(&jsonpath_success(), &variables, &http_response, &mut cache) .err() .unwrap(); assert_eq!(error.source_info.start, Pos { line: 1, column: 1 }); assert_eq!(error.kind, RunnerErrorKind::QueryInvalidJson); } #[test] fn test_query_json_not_found() { let variables = VariableSet::new(); let mut cache = BodyCache::new(); let http_response = http::Response { body: String::into_bytes(String::from("{}")), ..default_response() }; assert_eq!( eval_query(&jsonpath_success(), &variables, &http_response, &mut cache).unwrap(), None ); } #[test] fn test_query_json() { let variables = VariableSet::new(); let mut cache = BodyCache::new(); assert_eq!( eval_query( &jsonpath_success(), &variables, &http::json_http_response(), &mut cache ) .unwrap() .unwrap(), Value::Bool(false) ); assert_eq!( eval_query( &jsonpath_errors(), &variables, &http::json_http_response(), &mut cache ) .unwrap() .unwrap(), Value::List(vec![ Value::Object(vec![( String::from("id"), Value::String(String::from("error1")) )]), Value::Object(vec![( String::from("id"), Value::String(String::from("error2")) )]) ]) ); } #[test] fn test_query_regex() { let variables = VariableSet::new(); let mut cache = BodyCache::new(); assert_eq!( eval_query( ®ex_name(), &variables, &http::hello_http_response(), &mut cache ) .unwrap() .unwrap(), Value::String("World".to_string()) ); let error = eval_query( ®ex_invalid(), &variables, &http::hello_http_response(), &mut cache, ) .err() .unwrap(); assert_eq!( error.source_info, SourceInfo::new(Pos::new(1, 7), Pos::new(1, 10)) ); assert_eq!(error.kind, RunnerErrorKind::InvalidRegex); } #[test] fn test_query_bytes() { let variables = VariableSet::new(); let mut cache = BodyCache::new(); assert_eq!( eval_query( &Query { source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), value: QueryValue::Bytes, }, &variables, &http::hello_http_response(), &mut cache, ) .unwrap() .unwrap(), Value::Bytes(String::into_bytes(String::from("Hello World!"))) ); } fn decode_hex(s: &str) -> Result, ParseIntError> { (0..s.len()) .step_by(2) .map(|i| u8::from_str_radix(&s[i..i + 2], 16)) .collect() } #[test] fn test_query_sha256() { let variables = VariableSet::new(); let mut cache = BodyCache::new(); assert_eq!( eval_query( &Query { source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), value: QueryValue::Sha256 {}, }, &variables, &http::Response { body: vec![0xff], ..default_response() }, &mut cache, ) .unwrap() .unwrap(), Value::Bytes( decode_hex("a8100ae6aa1940d0b663bb31cd466142ebbdbd5187131b92d93818987832eb89") .unwrap() ) ); } #[test] fn test_query_certificate() { assert!(eval_query_certificate( &http::Response { ..default_response() }, CertificateAttributeName::Subject ) .unwrap() .is_none()); assert_eq!( eval_query_certificate( &http::Response { certificate: Some(http::Certificate { subject: "A=B, C=D".to_string(), issuer: String::new(), start_date: Default::default(), expire_date: Default::default(), serial_number: String::new() }), ..default_response() }, CertificateAttributeName::Subject ) .unwrap() .unwrap(), Value::String("A=B, C=D".to_string()) ); } } hurl-6.1.1/src/runner/regex.rs000064400000000000000000000025331046102023000143660ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::RegexValue; use regex::Regex; use crate::runner::template::eval_template; use crate::runner::{RunnerError, RunnerErrorKind, VariableSet}; pub fn eval_regex_value( regex_value: &RegexValue, variables: &VariableSet, ) -> Result { match regex_value { RegexValue::Template(t) => { let value = eval_template(t, variables)?; match Regex::new(value.as_str()) { Ok(re) => Ok(re), Err(_) => Err(RunnerError::new( t.source_info, RunnerErrorKind::InvalidRegex, false, )), } } RegexValue::Regex(re) => Ok(re.inner.clone()), } } hurl-6.1.1/src/runner/request.rs000064400000000000000000000413551046102023000147510ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::str::FromStr; use base64::engine::general_purpose; use base64::Engine; use hurl_core::ast::{ Body, Bytes, Method, MultilineString, MultilineStringKind, Request, Template, }; use crate::http; use crate::http::{HeaderVec, HttpError, Url, AUTHORIZATION}; use crate::runner::error::RunnerError; use crate::runner::{body, multipart, template, RunnerErrorKind, VariableSet}; use crate::util::path::ContextDir; /// Transforms an AST `request` to a spec request given a set of `variables`. pub fn eval_request( request: &Request, variables: &VariableSet, context_dir: &ContextDir, ) -> Result { let method = eval_method(&request.method); let url = eval_url(&request.url, variables)?; // Headers let mut headers = HeaderVec::new(); for header in &request.headers { let name = template::eval_template(&header.key, variables)?; let value = template::eval_template(&header.value, variables)?; let header = http::Header::new(&name, &value); headers.push(header); } // Basic auth if let Some(kv) = &request.basic_auth() { let name = template::eval_template(&kv.key, variables)?; let value = template::eval_template(&kv.value, variables)?; let user_password = format!("{}:{}", name, value); let user_password = user_password.as_bytes(); let authorization = general_purpose::STANDARD.encode(user_password); let value = format!("Basic {authorization}"); let header = http::Header::new(AUTHORIZATION, &value); headers.push(header); } // Query string params let mut querystring = vec![]; for param in request.querystring_params() { let name = template::eval_template(¶m.key, variables)?; let value = template::eval_template(¶m.value, variables)?; let param = http::Param { name, value }; querystring.push(param); } // Form params let mut form = vec![]; for param in request.form_params() { let name = template::eval_template(¶m.key, variables)?; let value = template::eval_template(¶m.value, variables)?; let param = http::Param { name, value }; form.push(param); } // Cookies let mut cookies = vec![]; for cookie in request.cookies() { let name = template::eval_template(&cookie.name, variables)?; let value = template::eval_template(&cookie.value, variables)?; let cookie = http::RequestCookie { name, value }; cookies.push(cookie); } let body = match &request.body { Some(body) => body::eval_body(body, variables, context_dir)?, None => http::Body::Binary(vec![]), }; let mut multipart = vec![]; for multipart_param in request.multipart_form_data() { let param = multipart::eval_multipart_param(multipart_param, variables, context_dir)?; multipart.push(param); } let implicit_content_type = if !form.is_empty() { Some("application/x-www-form-urlencoded".to_string()) } else if !multipart.is_empty() { Some("multipart/form-data".to_string()) } else if let Some(Body { value: Bytes::Json { .. } | Bytes::MultilineString(MultilineString { kind: MultilineStringKind::GraphQl(..), .. }) | Bytes::MultilineString(MultilineString { kind: MultilineStringKind::Json(..), .. }), .. }) = request.body { Some("application/json".to_string()) } else if let Some(Body { value: Bytes::Xml { .. } | Bytes::MultilineString(MultilineString { kind: MultilineStringKind::Xml(..), .. }), .. }) = request.body { Some("application/xml".to_string()) } else { None }; Ok(http::RequestSpec { method, url, headers, querystring, form, multipart, cookies, body, implicit_content_type, }) } fn eval_url(url_template: &Template, variables: &VariableSet) -> Result { let url = template::eval_template(url_template, variables)?; Url::from_str(&url).map_err(|e| { let source_info = url_template.source_info; let message = if let HttpError::InvalidUrl(_, message) = e { message } else { String::new() }; let runner_error_kind = RunnerErrorKind::InvalidUrl { url, message }; RunnerError::new(source_info, runner_error_kind, false) }) } /// Experimental feature /// @cookie_storage_add pub fn cookie_storage_set(request: &Request) -> Option { for line_terminator in request.line_terminators.iter() { if let Some(s) = &line_terminator.comment { if s.value.contains("@cookie_storage_set:") { let index = "#@cookie_storage_set:".to_string().len(); let value = &s.value[index..s.value.len()].to_string().trim().to_string(); return Some(value.to_string()); } } } None } /// Experimental feature /// @cookie_storage_clear pub fn cookie_storage_clear(request: &Request) -> bool { for line_terminator in request.line_terminators.iter() { if let Some(s) = &line_terminator.comment { if s.value.contains("@cookie_storage_clear") { return true; } } } false } fn eval_method(method: &Method) -> http::Method { http::Method(method.to_string()) } #[cfg(test)] mod tests { use hurl_core::ast::{ Comment, Expr, ExprKind, KeyValue, LineTerminator, Placeholder, Section, SectionValue, SourceInfo, TemplateElement, Variable, Whitespace, }; use hurl_core::reader::Pos; use hurl_core::typing::ToSource; use super::super::error::RunnerErrorKind; use super::*; use crate::runner::Value; fn whitespace() -> Whitespace { Whitespace { value: String::from(" "), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), } } fn hello_request() -> Request { let line_terminator = LineTerminator { space0: whitespace(), comment: None, newline: whitespace(), }; Request { line_terminators: vec![], space0: whitespace(), method: Method::new("GET"), space1: whitespace(), url: Template::new( None, vec![ TemplateElement::Placeholder(Placeholder { space0: whitespace(), expr: Expr { kind: ExprKind::Variable(Variable { name: "base_url".to_string(), source_info: SourceInfo::new(Pos::new(1, 7), Pos::new(1, 15)), }), source_info: SourceInfo::new(Pos::new(1, 7), Pos::new(1, 15)), }, space1: whitespace(), }), TemplateElement::String { value: "/hello".to_string(), source: "/hello".to_source(), }, ], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), line_terminator0: line_terminator, headers: vec![], sections: vec![], body: None, source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), } } fn simple_key_value(key: Template, value: Template) -> KeyValue { let line_terminator = LineTerminator { space0: whitespace(), comment: None, newline: whitespace(), }; KeyValue { line_terminators: vec![], space0: whitespace(), key, space1: whitespace(), space2: whitespace(), value, line_terminator0: line_terminator, } } fn query_request() -> Request { let line_terminator = LineTerminator { space0: whitespace(), comment: None, newline: whitespace(), }; Request { line_terminators: vec![], space0: whitespace(), method: Method::new("GET"), space1: whitespace(), url: Template::new( None, vec![TemplateElement::String { value: "http://localhost:8000/querystring-params".to_string(), source: "http://localhost:8000/querystring-params".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), line_terminator0: line_terminator.clone(), headers: vec![], sections: vec![Section { line_terminators: vec![], space0: whitespace(), line_terminator0: line_terminator, value: SectionValue::QueryParams( vec![ simple_key_value( Template::new( None, vec![TemplateElement::String { value: "param1".to_string(), source: "param1".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), Template::new( None, vec![TemplateElement::Placeholder(Placeholder { space0: whitespace(), expr: Expr { kind: ExprKind::Variable(Variable { name: "param1".to_string(), source_info: SourceInfo::new( Pos::new(1, 7), Pos::new(1, 15), ), }), source_info: SourceInfo::new( Pos::new(1, 7), Pos::new(1, 15), ), }, space1: whitespace(), })], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), ), simple_key_value( Template::new( None, vec![TemplateElement::String { value: "param2".to_string(), source: "param2".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), Template::new( None, vec![TemplateElement::String { value: "a b".to_string(), source: "a b".to_source(), }], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), ), ), ], false, ), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }], body: None, source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), } } #[test] fn test_error_variable() { let variables = VariableSet::new(); let error = eval_request(&hello_request(), &variables, &ContextDir::default()) .err() .unwrap(); assert_eq!( error.source_info, SourceInfo::new(Pos::new(1, 7), Pos::new(1, 15)) ); assert_eq!( error.kind, RunnerErrorKind::TemplateVariableNotDefined { name: String::from("base_url") } ); } #[test] fn test_hello_request() { let mut variables = VariableSet::new(); variables .insert( String::from("base_url"), Value::String(String::from("http://localhost:8000")), ) .unwrap(); let http_request = eval_request(&hello_request(), &variables, &ContextDir::default()).unwrap(); assert_eq!(http_request, http::hello_http_request()); } #[test] fn test_query_request() { let mut variables = VariableSet::new(); variables .insert( String::from("param1"), Value::String(String::from("value1")), ) .unwrap(); let http_request = eval_request(&query_request(), &variables, &ContextDir::default()).unwrap(); assert_eq!(http_request, http::query_http_request()); } #[test] fn clear_cookie_store() { assert!(!cookie_storage_clear(&hello_request())); let line_terminator = LineTerminator { space0: whitespace(), comment: None, newline: whitespace(), }; assert!(cookie_storage_clear(&Request { line_terminators: vec![LineTerminator { space0: whitespace(), comment: Some(Comment { value: "@cookie_storage_clear".to_string(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }), newline: whitespace(), }], space0: whitespace(), method: Method::new("GET"), space1: whitespace(), url: Template::new( None, vec![TemplateElement::String { value: "http:///localhost".to_string(), source: "http://localhost".to_source(), },], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)) ), line_terminator0: line_terminator, headers: vec![], sections: vec![], body: None, source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), })); } #[test] fn add_cookie_in_storage() { assert_eq!(None, cookie_storage_set(&hello_request())); let line_terminator = LineTerminator { space0: whitespace(), comment: None, newline: whitespace(), }; assert_eq!( Some("localhost\tFALSE\t/\tFALSE\t0\tcookie1\tvalueA".to_string()), cookie_storage_set(&Request { line_terminators: vec![LineTerminator { space0: whitespace(), comment: Some(Comment { value: "@cookie_storage_set: localhost\tFALSE\t/\tFALSE\t0\tcookie1\tvalueA" .to_string(), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }), newline: whitespace(), }], space0: whitespace(), method: Method::new("GET"), space1: whitespace(), url: Template::new( None, vec![TemplateElement::String { value: "http:///localhost".to_string(), source: "http://localhost".to_source(), },], SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)) ), line_terminator0: line_terminator, headers: vec![], sections: vec![], body: None, source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }) ); } } hurl-6.1.1/src/runner/response.rs000064400000000000000000000456421046102023000151220ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::{Base64, Body, Bytes, Hex, Response, SourceInfo, StatusValue}; use crate::http; use crate::runner::cache::BodyCache; use crate::runner::error::{RunnerError, RunnerErrorKind}; use crate::runner::result::{AssertResult, CaptureResult}; use crate::runner::{assert, body, capture, json, multiline, template, Value, VariableSet}; use crate::util::path::ContextDir; /// Returns a list of assert results on the response status code and HTTP version, /// given a set of `variables`, an actual `http_response` and a spec `response`. pub fn eval_version_status_asserts( response: &Response, http_response: &http::Response, ) -> Vec { let mut asserts = vec![]; let version = &response.version; asserts.push(AssertResult::Version { actual: http_response.version.to_string(), expected: version.value.to_string(), source_info: version.source_info, }); let status = &response.status; if let StatusValue::Specific(v) = status.value { asserts.push(AssertResult::Status { actual: http_response.status as u64, expected: v, source_info: status.source_info, }); } asserts } /// Returns a list of assert results, given a set of `variables`, an actual `http_response` and a spec `response`. /// /// Asserts on status and version and not run in this function, there are run with `eval_version_status_asserts` /// as they're semantically stronger. /// /// The `cache` is used to store XML / JSON structured response data and avoid redundant parsing /// operation on the response. pub fn eval_asserts( response: &Response, variables: &VariableSet, http_response: &http::Response, cache: &mut BodyCache, context_dir: &ContextDir, ) -> Vec { let mut asserts = vec![]; // First, evaluates implicit asserts on response headers. for header in &response.headers { match template::eval_template(&header.value, variables) { Err(e) => { let result = AssertResult::Header { actual: Err(e), expected: String::new(), source_info: header.key.source_info, }; asserts.push(result); } Ok(expected) => { match template::eval_template(&header.key, variables) { Ok(header_name) => { let actuals = http_response.headers.values(&header_name); if actuals.is_empty() { let result = AssertResult::Header { actual: Err(RunnerError::new( header.key.source_info, RunnerErrorKind::QueryHeaderNotFound, false, )), expected, source_info: header.key.source_info, }; asserts.push(result); } else if actuals.len() == 1 { let actual = actuals.first().unwrap().to_string(); let result = AssertResult::Header { actual: Ok(actual), expected, source_info: header.value.source_info, }; asserts.push(result); } else { // failure by default // expected value not found in the list // actual is therefore the full list let mut actual = format!( "[{}]", actuals .iter() .map(|v| format!("\"{v}\"")) .collect::>() .join(", ") ); for value in actuals { if value == expected { actual = value.to_string(); break; } } let result = AssertResult::Header { actual: Ok(actual), expected, source_info: header.value.source_info, }; asserts.push(result); } } Err(e) => { let result = AssertResult::Header { actual: Err(e), expected, source_info: header.value.source_info, }; asserts.push(result); } } } } } // Second, evaluates implicit asserts on response body. if let Some(body) = &response.body { let assert = eval_implicit_body_asserts(body, variables, http_response, context_dir); asserts.push(assert); } // Then, checks all the explicit asserts. for assert in response.asserts() { let assert_result = assert::eval_explicit_assert(assert, variables, http_response, cache, context_dir); asserts.push(assert_result); } asserts } /// Check the body of an actual HTTP response against a spec body, given a set of variables. fn eval_implicit_body_asserts( spec_body: &Body, variables: &VariableSet, http_response: &http::Response, context_dir: &ContextDir, ) -> AssertResult { match &spec_body.value { Bytes::Json(value) => { let expected = match json::eval_json_value(value, variables, true) { Ok(s) => Ok(Value::String(s)), Err(e) => Err(e), }; let actual = match http_response.text() { Ok(s) => Ok(Value::String(s)), Err(e) => { let source_info = SourceInfo { start: spec_body.space0.source_info.end, end: spec_body.space0.source_info.end, }; Err(RunnerError::new( source_info, RunnerErrorKind::Http(e), true, )) } }; AssertResult::Body { actual, expected, source_info: spec_body.space0.source_info, } } Bytes::Xml(value) => { let expected = Ok(Value::String(value.to_string())); let actual = match http_response.text() { Ok(s) => Ok(Value::String(s)), Err(e) => { let source_info = SourceInfo { start: spec_body.space0.source_info.end, end: spec_body.space0.source_info.end, }; Err(RunnerError::new( source_info, RunnerErrorKind::Http(e), true, )) } }; AssertResult::Body { actual, expected, source_info: spec_body.space0.source_info, } } Bytes::OnelineString(value) => { let expected = match template::eval_template(value, variables) { Ok(s) => Ok(Value::String(s)), Err(e) => Err(e), }; let actual = match http_response.text() { Ok(s) => Ok(Value::String(s)), Err(e) => { let source_info = SourceInfo { start: spec_body.space0.source_info.end, end: spec_body.space0.source_info.end, }; Err(RunnerError::new( source_info, RunnerErrorKind::Http(e), true, )) } }; AssertResult::Body { actual, expected, source_info: value.source_info, } } Bytes::MultilineString(multi) => { let expected = match multiline::eval_multiline(multi, variables) { Ok(s) => Ok(Value::String(s)), Err(e) => Err(e), }; let actual = match http_response.text() { Ok(s) => Ok(Value::String(s)), Err(e) => { let source_info = SourceInfo { start: spec_body.space0.source_info.end, end: spec_body.space0.source_info.end, }; Err(RunnerError::new( source_info, RunnerErrorKind::Http(e), true, )) } }; AssertResult::Body { actual, expected, source_info: multi.value().source_info, } } Bytes::Base64(Base64 { value, space0, space1, .. }) => { let expected = Ok(Value::Bytes(value.to_vec())); let actual = match http_response.uncompress_body() { Ok(b) => Ok(Value::Bytes(b)), Err(e) => { let source_info = SourceInfo { start: spec_body.space0.source_info.end, end: spec_body.space0.source_info.end, }; Err(RunnerError::new( source_info, RunnerErrorKind::Http(e), true, )) } }; AssertResult::Body { actual, expected, source_info: SourceInfo { start: space0.source_info.end, end: space1.source_info.start, }, } } Bytes::Hex(Hex { value, space0, space1, .. }) => { let expected = Ok(Value::Bytes(value.to_vec())); let actual = match http_response.uncompress_body() { Ok(b) => Ok(Value::Bytes(b)), Err(e) => { let source_info = SourceInfo { start: spec_body.space0.source_info.end, end: spec_body.space0.source_info.end, }; Err(RunnerError::new( source_info, RunnerErrorKind::Http(e), true, )) } }; AssertResult::Body { actual, expected, source_info: SourceInfo { start: space0.source_info.end, end: space1.source_info.start, }, } } Bytes::File { .. } => { let expected = match body::eval_body(spec_body, variables, context_dir) { Ok(body) => Ok(Value::Bytes(body.bytes())), Err(e) => Err(e), }; let actual = match http_response.uncompress_body() { Ok(b) => Ok(Value::Bytes(b)), Err(e) => { let source_info = SourceInfo { start: spec_body.space0.source_info.end, end: spec_body.space0.source_info.end, }; Err(RunnerError::new( source_info, RunnerErrorKind::Http(e), true, )) } }; AssertResult::Body { actual, expected, source_info: spec_body.space0.source_info, } } } } /// Evaluates captures from this HTTP `http_response`, given a set of `variables`. pub fn eval_captures( response: &Response, http_response: &http::Response, cache: &mut BodyCache, variables: &mut VariableSet, ) -> Result, RunnerError> { let mut captures = vec![]; for capture in response.captures() { let capture_result = capture::eval_capture(capture, variables, http_response, cache)?; // Update variables now so the captures set is ready in case // the next captures reference this new variable. let name = capture_result.name.clone(); let value = capture_result.value.clone(); // If the capture is redacted, we try to insert it in the variable set. Only secrets strings // are supported so all other `Value` variants will trigger an error. if capture.redact { match value { Value::String(secret) => { if let Err(error) = variables.insert_secret(name, secret) { let source_info = capture.name.source_info; return Err(error.to_runner_error(source_info)); } } _ => { let source_info = capture.name.source_info; let kind = RunnerErrorKind::UnsupportedSecretType(value.kind().to_string()); return Err(RunnerError::new(source_info, kind, false)); } } } else { // Try to insert a public capture. if let Err(error) = variables.insert(name, value) { let source_info = capture.name.source_info; return Err(error.to_runner_error(source_info)); } } captures.push(capture_result); } Ok(captures) } #[cfg(test)] mod tests { use hurl_core::ast::{ LineTerminator, Section, SectionValue, Status, Version, VersionValue, Whitespace, }; use hurl_core::reader::Pos; use self::super::super::{assert, capture}; use super::*; use crate::runner::Number; pub fn user_response() -> Response { let whitespace = Whitespace { value: String::from(" "), source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), }; let line_terminator = LineTerminator { space0: whitespace.clone(), comment: None, newline: whitespace.clone(), }; // HTTP/1.1 200 Response { line_terminators: vec![], version: Version { value: VersionValue::Version1, source_info: SourceInfo::new(Pos::new(2, 1), Pos::new(2, 9)), }, space0: whitespace.clone(), status: Status { value: StatusValue::Specific(200), source_info: SourceInfo::new(Pos::new(2, 10), Pos::new(2, 13)), }, space1: whitespace.clone(), line_terminator0: line_terminator.clone(), headers: vec![], sections: vec![ Section { line_terminators: vec![], space0: whitespace.clone(), line_terminator0: line_terminator.clone(), value: SectionValue::Asserts(vec![assert::tests::assert_count_user()]), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }, Section { line_terminators: vec![], space0: whitespace, line_terminator0: line_terminator, value: SectionValue::Captures(vec![capture::tests::user_count_capture()]), source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), }, ], body: None, source_info: SourceInfo::new(Pos::new(0, 0), Pos::new(0, 0)), } } #[test] pub fn test_eval_asserts() { let variables = VariableSet::new(); let mut cache = BodyCache::new(); let context_dir = ContextDir::default(); assert_eq!( eval_asserts( &user_response(), &variables, &http::xml_two_users_http_response(), &mut cache, &context_dir, ), vec![AssertResult::Explicit { actual: Ok(Some(Value::Number(Number::Integer(2)))), source_info: SourceInfo::new(Pos::new(1, 22), Pos::new(1, 24)), predicate_result: Some(Err(RunnerError::new( SourceInfo::new(Pos::new(1, 0), Pos::new(1, 0)), RunnerErrorKind::AssertFailure { actual: "integer <2>".to_string(), expected: "integer <3>".to_string(), type_mismatch: false, }, true ))), },] ); } #[test] pub fn test_eval_version_status_asserts() { assert_eq!( eval_version_status_asserts(&user_response(), &http::xml_two_users_http_response(),), vec![ AssertResult::Version { actual: String::from("HTTP/1.0"), expected: String::from("HTTP/1.0"), source_info: SourceInfo::new(Pos::new(2, 1), Pos::new(2, 9)), }, AssertResult::Status { actual: 200, expected: 200, source_info: SourceInfo::new(Pos::new(2, 10), Pos::new(2, 13)), }, ] ); } #[test] pub fn test_eval_captures() { let mut variables = VariableSet::new(); let mut cache = BodyCache::new(); assert_eq!( eval_captures( &user_response(), &http::xml_two_users_http_response(), &mut cache, &mut variables, ) .unwrap(), vec![CaptureResult { name: "UserCount".to_string(), value: Value::Number(Number::Float(2.0)), }] ); } } hurl-6.1.1/src/runner/result.rs000064400000000000000000000164101046102023000145710ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::time::Duration; use hurl_core::ast::SourceInfo; use hurl_core::reader::Pos; use crate::http::{Call, Cookie, CurlCmd}; use crate::runner::error::RunnerError; use crate::runner::output::Output; use crate::runner::value::Value; use crate::runner::{RunnerErrorKind, VariableSet}; use crate::util::path::ContextDir; use crate::util::term::Stdout; /// Represents the result of a valid Hurl file execution. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct HurlResult { /// The entries result for this run. pub entries: Vec, /// Total duration of the run, including asserts and results computation. pub duration: Duration, /// `true` if the run is successful, `false` if there has been runtime or asserts errors. pub success: bool, /// The list of cookies at the end of the run. pub cookies: Vec, /// Start of the run (in "UNIX timestamp"). pub timestamp: i64, /// The set of variables, updated at the end of the run execution. pub variables: VariableSet, } impl HurlResult { /// Returns all the effective errors of this `HurlResult`, with the source information /// of the entry where the error happens. /// /// The errors are only the "effective" ones: those that are due to retry are /// ignored. pub fn errors(&self) -> Vec<(&RunnerError, SourceInfo)> { let mut errors = vec![]; let mut next_entries = self.entries.iter().skip(1); for entry in self.entries.iter() { match next_entries.next() { None => { let new_errors = entry.errors.iter().map(|error| (error, entry.source_info)); errors.extend(new_errors); } Some(next) => { if next.entry_index != entry.entry_index { let new_errors = entry.errors.iter().map(|error| (error, entry.source_info)); errors.extend(new_errors); } } } } errors } } /// Represents the execution result of an entry. #[derive(Clone, Debug, PartialEq, Eq)] pub struct EntryResult { /// 1-based index of the entry on the file execution. pub entry_index: usize, /// Source information of this entry. pub source_info: SourceInfo, /// List of HTTP request / response pair. pub calls: Vec, /// List of captures. pub captures: Vec, /// List of asserts. pub asserts: Vec, /// List of errors. pub errors: Vec, /// Effective duration of all the HTTP transfers, excluding asserts and captures processing. pub transfer_duration: Duration, /// The entry has been executed with `--compressed` option: /// server is requested to send compressed response, and the response should be uncompressed /// when outputted on stdout. pub compressed: bool, /// The debug curl command line from this entry result. pub curl_cmd: CurlCmd, } impl Default for EntryResult { fn default() -> Self { EntryResult { entry_index: 1, source_info: SourceInfo::new(Pos::new(1, 1), Pos::new(1, 1)), calls: vec![], captures: vec![], asserts: vec![], errors: vec![], transfer_duration: Duration::from_millis(0), compressed: false, curl_cmd: CurlCmd::default(), } } } /// Result of a Hurl assertions, either implicit or explicit. /// /// ## Example /// /// ```hurl /// GET https://foo.com /// HTTP 200 /// x-baz: bar /// [Asserts] /// header "toto" == "tutu" /// jsonpath "$.state" = "running" /// ``` /// /// In this Hurl sample, everything after the keyword `HTTP` is an assertion. We distinguish two /// types of assertions: implicit and explicit. /// /// - `HTTP 200`: implicit status code assert /// - `x-baz: bar`: implicit HTTP header assert /// - `header "toto" == "tutu"`: explicit HTTP header assert /// - `jsonpath "$.state" = "running"`: explicit JSONPath assert on HTTP body response #[derive(Clone, Debug, PartialEq, Eq)] pub enum AssertResult { /// Implicit HTTP version assert (like HTTP/3, HTTP/2 etc...). Version { actual: String, expected: String, source_info: SourceInfo, }, /// Implicit HTTP status code assert. Status { actual: u64, expected: u64, source_info: SourceInfo, }, /// Implicit HTTP response header assert. Header { actual: Result, expected: String, source_info: SourceInfo, }, /// Implicit HTTP response body assert. Body { actual: Result, expected: Result, source_info: SourceInfo, }, /// Explicit assert on HTTP response. Explicit { actual: Result, RunnerError>, source_info: SourceInfo, predicate_result: Option, }, } /// Represents a [capture](https://hurl.dev/docs/capturing-response.html) of an HTTP response. /// /// Captures are data extracted by querying the HTTP response. Captures can be part of the response /// body, headers, cookies etc... Captures can be used to re-inject data in next HTTP requests. #[derive(Clone, Debug, PartialEq, Eq)] pub struct CaptureResult { /// Name of the capture. pub name: String, /// Value of the capture. pub value: Value, } pub type PredicateResult = Result<(), RunnerError>; impl EntryResult { /// Writes the last HTTP response of this entry result to this `output`. /// The HTTP response can be decompressed if the entry's `compressed` option has been set. /// This method checks if the response has write access to this output, given a `context_dir`. pub fn write_response( &self, output: &Output, context_dir: &ContextDir, stdout: &mut Stdout, source_info: SourceInfo, ) -> Result<(), RunnerError> { let Some(call) = self.calls.last() else { return Ok(()); }; let response = &call.response; if self.compressed { let bytes = match response.uncompress_body() { Ok(bytes) => bytes, Err(e) => { return Err(RunnerError::new( source_info, RunnerErrorKind::Http(e), false, )); } }; output.write_with_context_dir(&bytes, stdout, context_dir, source_info) } else { output.write_with_context_dir(&response.body, stdout, context_dir, source_info) } } } hurl-6.1.1/src/runner/runner_options.rs000064400000000000000000000513451046102023000163450ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::time::Duration; use hurl_core::ast::Entry; use hurl_core::typing::{BytesPerSec, Count}; use crate::http::{IpResolve, RequestedHttpVersion}; use crate::runner::Output; use crate::util::path::ContextDir; /// Build a [`RunnerOptions`] instance. pub struct RunnerOptionsBuilder { allow_reuse: bool, aws_sigv4: Option, cacert_file: Option, client_cert_file: Option, client_key_file: Option, compressed: bool, connect_timeout: Duration, connects_to: Vec, context_dir: ContextDir, continue_on_error: bool, cookie_input_file: Option, delay: Duration, follow_location: bool, follow_location_trusted: bool, from_entry: Option, headers: Vec, http_version: RequestedHttpVersion, ignore_asserts: bool, insecure: bool, ip_resolve: IpResolve, max_filesize: Option, max_recv_speed: Option, max_redirect: Count, max_send_speed: Option, netrc: bool, netrc_file: Option, netrc_optional: bool, no_proxy: Option, output: Option, path_as_is: bool, post_entry: Option bool>, pre_entry: Option bool>, proxy: Option, repeat: Option, resolves: Vec, retry: Option, retry_interval: Duration, skip: bool, ssl_no_revoke: bool, timeout: Duration, to_entry: Option, unix_socket: Option, user: Option, user_agent: Option, } impl Default for RunnerOptionsBuilder { fn default() -> Self { RunnerOptionsBuilder { allow_reuse: true, aws_sigv4: None, cacert_file: None, client_cert_file: None, client_key_file: None, compressed: false, connect_timeout: Duration::from_secs(300), connects_to: vec![], context_dir: ContextDir::default(), continue_on_error: false, cookie_input_file: None, delay: Duration::from_millis(0), follow_location: false, follow_location_trusted: false, from_entry: None, headers: vec![], http_version: RequestedHttpVersion::default(), ignore_asserts: false, insecure: false, ip_resolve: IpResolve::default(), max_filesize: None, max_recv_speed: None, max_redirect: Count::Finite(50), max_send_speed: None, netrc: false, netrc_file: None, netrc_optional: false, no_proxy: None, output: None, path_as_is: false, post_entry: None, pre_entry: None, proxy: None, repeat: None, resolves: vec![], retry: None, retry_interval: Duration::from_millis(1000), skip: false, ssl_no_revoke: false, timeout: Duration::from_secs(300), to_entry: None, unix_socket: None, user: None, user_agent: None, } } } impl RunnerOptionsBuilder { /// Returns a new Hurl runner options builder with a default values. pub fn new() -> Self { RunnerOptionsBuilder::default() } /// Allow reusing internal connections, `true` by default. Setting this to `false` forces the /// HTTP client to use a new HTTP connection, and also marks this new connection as not reusable. /// The main use-case for not allowing connection reuse is when we want to switch HTTP version /// mid-file with an `[Options]` section. As the HTTP version setter is just a query, and is not /// always honored by libcurl when reusing connection, this allows to be sure that the client /// will set the queried HTTP version. pub fn allow_reuse(&mut self, allow_reuse: bool) -> &mut Self { self.allow_reuse = allow_reuse; self } /// Specifies the AWS SigV4 option pub fn aws_sigv4(&mut self, aws_sigv4: Option) -> &mut Self { self.aws_sigv4 = aws_sigv4; self } /// Specifies the certificate file for peer verification. /// The file may contain multiple CA certificates and must be in PEM format. pub fn cacert_file(&mut self, cacert_file: Option) -> &mut Self { self.cacert_file = cacert_file; self } /// Sets Client certificate file and password. pub fn client_cert_file(&mut self, client_cert_file: Option) -> &mut Self { self.client_cert_file = client_cert_file; self } /// Sets private key file name. pub fn client_key_file(&mut self, client_key_file: Option) -> &mut Self { self.client_key_file = client_key_file; self } /// Requests a compressed response using one of the algorithms br, gzip, deflate and /// automatically decompress the content. pub fn compressed(&mut self, compressed: bool) -> &mut Self { self.compressed = compressed; self } /// Sets maximum time that you allow Hurl’s connection to take. /// /// Default 300 seconds. pub fn connect_timeout(&mut self, connect_timeout: Duration) -> &mut Self { self.connect_timeout = connect_timeout; self } /// Sets hosts mappings. /// /// Each value has the following format HOST1:PORT1:HOST2:PORT2 /// For a request to the given HOST1:PORT1 pair, connect to HOST2:PORT2 instead. pub fn connects_to(&mut self, connects_to: &[String]) -> &mut Self { self.connects_to = connects_to.to_vec(); self } /// Sets delay (timeout) before the request. /// /// Default is 0 ms. pub fn delay(&mut self, delay: Duration) -> &mut Self { self.delay = delay; self } /// Sets root file system to import files in Hurl. /// /// This is used for both files in multipart form data and request body. pub fn context_dir(&mut self, context_dir: &ContextDir) -> &mut Self { self.context_dir = context_dir.clone(); self } /// Sets stopping or continuing executing requests to the end of the Hurl file even when an error occurs. /// /// By default, Hurl exits after an error in the HTTP response. Note that this option does /// not affect the behavior with multiple input Hurl files. pub fn continue_on_error(&mut self, continue_on_error: bool) -> &mut Self { self.continue_on_error = continue_on_error; self } /// Reads cookies from this file (using the Netscape cookie file format). pub fn cookie_input_file(&mut self, cookie_input_file: Option) -> &mut Self { self.cookie_input_file = cookie_input_file; self } /// Sets stopping or continuing executing requests to the end of the Hurl file even when an assert error occurs. /// /// By default, Hurl exits after an assert error in the HTTP response. Note that this option does /// not affect the behavior with multiple input Hurl files. pub fn fail_fast(&mut self, fail_fast: bool) -> &mut Self { self.continue_on_error = !fail_fast; self } /// Sets follow redirect. /// /// To limit the amount of redirects to follow use [`self.max_redirect()`] pub fn follow_location(&mut self, follow_location: bool) -> &mut Self { self.follow_location = follow_location; self } /// Sets follow redirect with trust. /// /// To limit the amount of redirects to follow use [`self.max_redirect()`] pub fn follow_location_trusted(&mut self, follow_location_trusted: bool) -> &mut Self { self.follow_location_trusted = follow_location_trusted; self } /// Executes Hurl file from `from_entry` (starting at 1), ignores the beginning of the file. pub fn from_entry(&mut self, from_entry: Option) -> &mut Self { self.from_entry = from_entry; self } /// Sets additional headers (overrides if a header already exists). pub fn headers(&mut self, header: &[String]) -> &mut Self { self.headers = header.to_vec(); self } /// Set requested HTTP version (can be different of the effective HTTP version). pub fn http_version(&mut self, version: RequestedHttpVersion) -> &mut Self { self.http_version = version; self } /// Ignores all asserts defined in the Hurl file. pub fn ignore_asserts(&mut self, ignore_asserts: bool) -> &mut Self { self.ignore_asserts = ignore_asserts; self } /// Allows Hurl to perform “insecure” SSL connections and transfers. pub fn insecure(&mut self, insecure: bool) -> &mut Self { self.insecure = insecure; self } /// Set IP version. pub fn ip_resolve(&mut self, ip_resolve: IpResolve) -> &mut Self { self.ip_resolve = ip_resolve; self } /// Set the file size limit pub fn max_filesize(&mut self, max_filesize: Option) -> &mut Self { self.max_filesize = max_filesize; self } /// Set maximum number of redirection-followings allowed /// /// By default, the limit is set to 50 redirections pub fn max_redirect(&mut self, max_redirect: Count) -> &mut Self { self.max_redirect = max_redirect; self } /// Set the maximum upload speed. pub fn max_send_speed(&mut self, max_send_speed: Option) -> &mut Self { self.max_send_speed = max_send_speed; self } /// Set the maximum download speed. pub fn max_recv_speed(&mut self, max_recv_speed: Option) -> &mut Self { self.max_recv_speed = max_recv_speed; self } /// Sets the path-as-is flag. pub fn path_as_is(&mut self, path_as_is: bool) -> &mut Self { self.path_as_is = path_as_is; self } /// Sets the netrc flag. pub fn netrc(&mut self, netrc: bool) -> &mut Self { self.netrc = netrc; self } /// Sets the netrc file. pub fn netrc_file(&mut self, netrc_file: Option) -> &mut Self { self.netrc_file = netrc_file; self } /// Sets the optional netrc flag. pub fn netrc_optional(&mut self, netrc_optional: bool) -> &mut Self { self.netrc_optional = netrc_optional; self } /// Sets list of hosts which do not use a proxy. pub fn no_proxy(&mut self, no_proxy: Option) -> &mut Self { self.no_proxy = no_proxy; self } /// Specifies the file to output the HTTP response instead of stdout. pub fn output(&mut self, output: Option) -> &mut Self { self.output = output; self } /// Sets function to be executed after each entry execution. /// /// If the function returns true, the run is stopped. pub fn post_entry(&mut self, post_entry: Option bool>) -> &mut Self { self.post_entry = post_entry; self } /// Sets function to be executed before each entry execution. /// /// If the function returns true, the run is stopped. pub fn pre_entry(&mut self, pre_entry: Option bool>) -> &mut Self { self.pre_entry = pre_entry; self } /// Sets the specified proxy to be used. pub fn proxy(&mut self, proxy: Option) -> &mut Self { self.proxy = proxy; self } /// Set the number of repetition for a given entry. pub fn repeat(&mut self, repeat: Option) -> &mut Self { self.repeat = repeat; self } /// Provides a custom address for a specific host and port pair. pub fn resolves(&mut self, resolves: &[String]) -> &mut Self { self.resolves = resolves.to_vec(); self } /// Sets maximum number of retries. /// /// Default is 0. pub fn retry(&mut self, retry: Option) -> &mut Self { self.retry = retry; self } /// Sets duration between each retry. /// /// Default is 1000 ms. pub fn retry_interval(&mut self, retry_interval: Duration) -> &mut Self { self.retry_interval = retry_interval; self } /// Skip the run without executing any request. pub fn skip(&mut self, skip: bool) -> &mut Self { self.skip = skip; self } /// Disables certificate revocation checks for SSL backends where such behavior is present. pub fn ssl_no_revoke(&mut self, ssl_no_revoke: bool) -> &mut Self { self.ssl_no_revoke = ssl_no_revoke; self } /// Sets maximum time allowed for the transfer. /// /// Default 300 seconds. pub fn timeout(&mut self, timeout: Duration) -> &mut Self { self.timeout = timeout; self } /// Executes Hurl file to `to_entry` (starting at 1), ignores the remaining of the file. pub fn to_entry(&mut self, to_entry: Option) -> &mut Self { self.to_entry = to_entry; self } /// Sets the specified unix domain socket to connect through, instead of using the network. pub fn unix_socket(&mut self, unix_socket: Option) -> &mut Self { self.unix_socket = unix_socket; self } /// Adds basic Authentication header to each request. pub fn user(&mut self, user: Option) -> &mut Self { self.user = user; self } /// Specifies the User-Agent string to send to the HTTP server. pub fn user_agent(&mut self, user_agent: Option) -> &mut Self { self.user_agent = user_agent; self } /// Create an instance of [`RunnerOptions`]. pub fn build(&self) -> RunnerOptions { RunnerOptions { allow_reuse: self.allow_reuse, aws_sigv4: self.aws_sigv4.clone(), cacert_file: self.cacert_file.clone(), client_cert_file: self.client_cert_file.clone(), client_key_file: self.client_key_file.clone(), compressed: self.compressed, connect_timeout: self.connect_timeout, connects_to: self.connects_to.clone(), delay: self.delay, context_dir: self.context_dir.clone(), continue_on_error: self.continue_on_error, cookie_input_file: self.cookie_input_file.clone(), follow_location: self.follow_location, follow_location_trusted: self.follow_location_trusted, from_entry: self.from_entry, headers: self.headers.clone(), http_version: self.http_version, ignore_asserts: self.ignore_asserts, insecure: self.insecure, ip_resolve: self.ip_resolve, max_filesize: self.max_filesize, max_recv_speed: self.max_recv_speed, max_redirect: self.max_redirect, max_send_speed: self.max_send_speed, netrc: self.netrc, netrc_file: self.netrc_file.clone(), netrc_optional: self.netrc_optional, no_proxy: self.no_proxy.clone(), output: self.output.clone(), path_as_is: self.path_as_is, post_entry: self.post_entry, pre_entry: self.pre_entry, proxy: self.proxy.clone(), repeat: self.repeat, resolves: self.resolves.clone(), retry: self.retry, retry_interval: self.retry_interval, skip: self.skip, ssl_no_revoke: self.ssl_no_revoke, timeout: self.timeout, to_entry: self.to_entry, unix_socket: self.unix_socket.clone(), user: self.user.clone(), user_agent: self.user_agent.clone(), } } } /// Represents the configuration options to run an Hurl file. /// /// Most options are used to configure the HTTP client used for running requests, while other /// are used to configure asserts settings, output etc.... #[derive(Clone, Debug, PartialEq, Eq)] pub struct RunnerOptions { /// Allow reusing internal connections. pub(crate) allow_reuse: bool, /// Specifies the AWS SigV4 option. pub(crate) aws_sigv4: Option, /// Specifies the certificate file for peer verification. pub(crate) cacert_file: Option, /// Sets Client certificate file and password. pub(crate) client_cert_file: Option, /// Sets private key file name. pub(crate) client_key_file: Option, /// Requests a compressed response using one of the algorithms br, gzip, deflate and /// automatically decompress the content. pub(crate) compressed: bool, /// Sets maximum time that you allow Hurl’s connection to take. pub(crate) connect_timeout: Duration, /// Sets hosts mappings. pub(crate) connects_to: Vec, /// Sets delay (timeout) before the request. pub(crate) delay: Duration, /// Sets root file system to import files in Hurl. pub(crate) context_dir: ContextDir, /// Sets stopping or continuing executing requests to the end of the Hurl file even when an error occurs. pub(crate) continue_on_error: bool, /// Reads cookies from this file (using the Netscape cookie file format). pub(crate) cookie_input_file: Option, /// Sets follow redirect. pub(crate) follow_location: bool, /// Sets follow redirect with trust. pub(crate) follow_location_trusted: bool, /// Executes Hurl file from from_entry (starting at 1), ignores the beginning of the file. pub(crate) from_entry: Option, /// Sets additional headers (overrides if a header already exists). pub(crate) headers: Vec, /// Set requested HTTP version (can be different of the effective HTTP version). pub(crate) http_version: RequestedHttpVersion, /// Ignores all asserts defined in the Hurl file. pub(crate) ignore_asserts: bool, /// Set IP version. pub(crate) ip_resolve: IpResolve, /// Allows Hurl to perform “insecure” SSL connections and transfers. pub(crate) insecure: bool, /// Set the file size limit. pub(crate) max_filesize: Option, /// Set the maximum download speed. pub(crate) max_recv_speed: Option, /// Set maximum number of redirection-followings allowed. pub(crate) max_redirect: Count, /// Set the maximum upload speed. pub(crate) max_send_speed: Option, /// Sets the netrc flag. pub(crate) netrc: bool, /// Sets the netrc file. pub(crate) netrc_file: Option, /// Sets the optional netrc flag. pub(crate) netrc_optional: bool, /// Sets list of hosts which do not use a proxy. pub(crate) no_proxy: Option, /// Specifies the file to output the HTTP response. pub(crate) output: Option, pub(crate) path_as_is: bool, /// Sets function to be executed before each entry execution. pub(crate) post_entry: Option bool>, /// Sets function to be executed after each entry execution. pub(crate) pre_entry: Option bool>, /// Sets the specified proxy to be used. pub(crate) proxy: Option, /// Set the number of repetition for a given entry. pub(crate) repeat: Option, /// Provides a custom address for a specific host and port pair. pub(crate) resolves: Vec, /// Sets maximum number of retries. pub(crate) retry: Option, /// Sets duration between each retry. pub(crate) retry_interval: Duration, /// Skip the run without executing any request. pub(crate) skip: bool, /// Disables certificate revocation checks for SSL backends where such behavior is present. pub(crate) ssl_no_revoke: bool, /// Sets maximum time allowed for the transfer. pub(crate) timeout: Duration, /// Executes Hurl file to to_entry (starting at 1), ignores the remaining of the file. pub(crate) to_entry: Option, /// Sets the specified unix domain socket to connect through, instead of using the network. pub(crate) unix_socket: Option, /// Adds basic Authentication header to each request. pub(crate) user: Option, /// Specifies the User-Agent string to send to the HTTP server. pub(crate) user_agent: Option, } impl Default for RunnerOptions { fn default() -> Self { RunnerOptionsBuilder::default().build() } } hurl-6.1.1/src/runner/template.rs000064400000000000000000000100311046102023000150570ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use hurl_core::ast::{Placeholder, Template, TemplateElement}; use crate::runner::error::RunnerError; use crate::runner::{expr, VariableSet}; /// Renders to string a `template` given a map of variables. pub fn eval_template(template: &Template, variables: &VariableSet) -> Result { let Template { elements, .. } = template; let mut value = String::new(); for elem in elements { match eval_template_element(elem, variables) { Ok(v) => value.push_str(v.as_str()), Err(e) => return Err(e), } } Ok(value) } fn eval_template_element( template_element: &TemplateElement, variables: &VariableSet, ) -> Result { match template_element { TemplateElement::String { value, .. } => Ok(value.clone()), TemplateElement::Placeholder(Placeholder { expr, .. }) => expr::render(expr, variables), } } #[cfg(test)] mod tests { use hurl_core::ast::{Expr, ExprKind, SourceInfo, Variable, Whitespace}; use hurl_core::reader::Pos; use hurl_core::typing::ToSource; use super::*; use crate::runner::{Number, RunnerErrorKind, Value}; fn template_element_expression() -> TemplateElement { // {{name}} TemplateElement::Placeholder(Placeholder { space0: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(1, 3), Pos::new(1, 3)), }, expr: Expr { kind: ExprKind::Variable(Variable { name: "name".to_string(), source_info: SourceInfo::new(Pos::new(1, 3), Pos::new(1, 7)), }), source_info: SourceInfo::new(Pos::new(1, 3), Pos::new(1, 7)), }, space1: Whitespace { value: String::new(), source_info: SourceInfo::new(Pos::new(1, 3), Pos::new(1, 7)), }, }) } #[test] fn test_template_element() { let variables = VariableSet::new(); assert_eq!( eval_template_element( &TemplateElement::String { value: "World".to_string(), source: "World".to_source(), }, &variables ) .unwrap(), "World".to_string() ); let mut variables = VariableSet::new(); variables .insert("name".to_string(), Value::String("World".to_string())) .unwrap(); assert_eq!( eval_template_element(&template_element_expression(), &variables).unwrap(), "World".to_string() ); } #[test] fn test_template_element_error() { let mut variables = VariableSet::new(); variables .insert( "name".to_string(), Value::List(vec![ Value::Number(Number::Integer(1)), Value::Number(Number::Integer(2)), ]), ) .unwrap(); let error = eval_template_element(&template_element_expression(), &variables) .err() .unwrap(); assert_eq!( error.source_info, SourceInfo::new(Pos::new(1, 3), Pos::new(1, 7)) ); assert_eq!( error.kind, RunnerErrorKind::UnrenderableExpression { value: "[1,2]".to_string() } ); } } hurl-6.1.1/src/runner/value.rs000064400000000000000000000223121046102023000143650ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::cmp::Ordering; use std::fmt; use crate::runner::Number; /// System types used in Hurl. /// /// Values are used by queries, captures, asserts and predicates. #[derive(Clone, Debug)] pub enum Value { /// A boolean value. Bool(bool), /// A buffer of bytes. Bytes(Vec), /// A date. Date(chrono::DateTime), /// A list of [`Value`]. List(Vec), /// A structure to represents node of object (returned from XPath queries). Nodeset(usize), /// The null type. Null, /// A number, can be a float, a 64-bit integer or any precision integer. Number(Number), /// A structure to represents objects (returned from JSONPath queries). Object(Vec<(String, Value)>), /// A regular expression. Regex(regex::Regex), /// A string. String(String), /// The unit type. Unit, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum ValueKind { Bool, Bytes, Date, Float, Integer, List, Nodeset, Null, Object, Regex, Secret, String, Unit, } #[derive(Clone, Debug, PartialEq, Eq)] pub enum EvalError { Type, InvalidRegex, } /// Equality of values /// as used in the predicate == /// /// Any combination of value type can be used in the equality. There isn't any type/mismatch errors. impl PartialEq for Value { fn eq(&self, other: &Self) -> bool { match (self, other) { (Value::Bool(v1), Value::Bool(v2)) => v1 == v2, (Value::Bytes(v1), Value::Bytes(v2)) => v1 == v2, (Value::Date(v1), Value::Date(v2)) => v1 == v2, (Value::Number(v1), Value::Number(v2)) => v1.cmp_value(v2) == Ordering::Equal, (Value::List(v1), Value::List(v2)) => v1 == v2, (Value::Nodeset(v1), Value::Nodeset(v2)) => v1 == v2, (Value::Null, Value::Null) => true, (Value::Object(v1), Value::Object(v2)) => v1 == v2, (Value::String(v1), Value::String(v2)) => v1 == v2, (Value::Unit, Value::Unit) => true, _ => false, } } } impl Eq for Value {} impl fmt::Display for Value { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let value = match self { Value::Bool(x) => x.to_string(), Value::Bytes(v) => hex::encode(v).to_string(), Value::Date(v) => v.to_string(), Value::Number(v) => v.to_string(), Value::List(values) => { let values: Vec = values.iter().map(|e| e.to_string()).collect(); format!("[{}]", values.join(",")) } Value::Nodeset(x) => format!("Nodeset(size={x})"), Value::Null => "null".to_string(), Value::Object(_) => "Object()".to_string(), Value::Regex(x) => { let s = str::replace(x.as_str(), "/", "\\/"); format!("/{s}/") } Value::String(x) => x.clone(), Value::Unit => "Unit".to_string(), }; write!(f, "{value}") } } const FORMAT_ISO: &str = "%Y-%m-%dT%H:%M:%S.%6fZ"; impl Value { pub fn kind(&self) -> ValueKind { match self { Value::Bool(_) => ValueKind::Bool, Value::Bytes(_) => ValueKind::Bytes, Value::Date(_) => ValueKind::Date, Value::Number(Number::Float(_)) => ValueKind::Float, Value::Number(Number::Integer(_)) | Value::Number(Number::BigInteger(_)) => { ValueKind::Integer } Value::List(_) => ValueKind::List, Value::Nodeset(_) => ValueKind::Nodeset, Value::Null => ValueKind::Null, Value::Object(_) => ValueKind::Object, Value::Regex(_) => ValueKind::Regex, Value::String(_) => ValueKind::String, Value::Unit => ValueKind::Unit, } } /// Returns a printable representation of the Value including its type. pub fn repr(&self) -> String { match self.kind() { ValueKind::Unit | ValueKind::Secret => self.kind().to_string(), _ => format!("{} <{}>", self.kind(), self), } } pub fn is_scalar(&self) -> bool { !matches!(self, Value::Nodeset(_) | Value::List(_)) } pub fn render(&self) -> Option { match self { Value::Bool(v) => Some(v.to_string()), Value::Date(d) => Some(d.format(FORMAT_ISO).to_string()), Value::Null => Some("null".to_string()), Value::Number(v) => Some(v.to_string()), Value::String(s) => Some(s.clone()), _ => None, } } } impl fmt::Display for ValueKind { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { ValueKind::Bool => write!(f, "boolean"), ValueKind::Bytes => write!(f, "bytes"), ValueKind::Date => write!(f, "date"), ValueKind::Float => write!(f, "float"), ValueKind::Integer => write!(f, "integer"), ValueKind::List => write!(f, "list"), ValueKind::Nodeset => write!(f, "nodeset"), ValueKind::Null => write!(f, "null"), ValueKind::Object => write!(f, "object"), ValueKind::Regex => write!(f, "regex"), ValueKind::Secret => write!(f, "secret"), ValueKind::String => write!(f, "string"), ValueKind::Unit => write!(f, "unit"), } } } #[cfg(test)] mod tests { use chrono::{DateTime, NaiveDate, Utc}; use regex::Regex; use super::*; #[test] fn test_repr() { assert_eq!(Value::Bool(true).repr(), "boolean ".to_string()); assert_eq!( Value::Bytes(vec![1, 2, 3]).repr(), "bytes <010203>".to_string() ); let datetime_naive = NaiveDate::from_ymd_opt(2000, 1, 1) .unwrap() .and_hms_micro_opt(12, 0, 0, 123000) .unwrap(); let datetime_utc = DateTime::::from_naive_utc_and_offset(datetime_naive, Utc); assert_eq!( Value::Date(datetime_utc).repr(), "date <2000-01-01 12:00:00.123 UTC>".to_string() ); assert_eq!(Value::List(vec![]).repr(), "list <[]>".to_string()); assert_eq!( Value::Nodeset(5).repr(), "nodeset ".to_string() ); assert_eq!(Value::Null.repr(), "null ".to_string()); assert_eq!( Value::Number(Number::Integer(1)).repr(), "integer <1>".to_string() ); assert_eq!( Value::Number(Number::Float(1.0)).repr(), "float <1.0>".to_string() ); assert_eq!( Value::Object(vec![]).repr(), "object ".to_string() ); assert_eq!( Value::Regex(Regex::new(r"[0-9]+").unwrap()).repr(), "regex " ); assert_eq!( Value::String("Hello".to_string()).repr(), "string ".to_string() ); assert_eq!(Value::Unit.repr(), "unit".to_string()); } #[test] fn test_is_scalar() { assert!(Value::Number(Number::Integer(1)).is_scalar()); assert!(!Value::List(vec![]).is_scalar()); } #[test] fn test_eq() { assert!(!(Value::Bool(true) == Value::Bool(false))); assert!(Value::Number(Number::Integer(1)) == Value::Number(Number::Float(1.0))); assert!(!(Value::Bool(true) == Value::String("Hello".to_string()))); } #[test] fn test_format_iso() { let datetime_naive = NaiveDate::from_ymd_opt(2000, 1, 1) .unwrap() .and_hms_micro_opt(12, 0, 0, 123000) .unwrap(); let datetime_utc = DateTime::::from_naive_utc_and_offset(datetime_naive, Utc); assert_eq!( datetime_utc.format(FORMAT_ISO).to_string(), "2000-01-01T12:00:00.123000Z" ); let naive_datetime = NaiveDate::from_ymd_opt(2000, 2, 1) .unwrap() .and_hms_micro_opt(12, 0, 0, 123456) .unwrap(); let datetime_utc = DateTime::::from_naive_utc_and_offset(naive_datetime, Utc); assert_eq!( datetime_utc.format(FORMAT_ISO).to_string(), "2000-02-01T12:00:00.123456Z" ); let naive_datetime = NaiveDate::from_ymd_opt(2000, 2, 1) .unwrap() .and_hms_nano_opt(12, 0, 0, 123456789) .unwrap(); let datetime_utc = DateTime::::from_naive_utc_and_offset(naive_datetime, Utc); assert_eq!( datetime_utc.format(FORMAT_ISO).to_string(), "2000-02-01T12:00:00.123456Z" ); } } hurl-6.1.1/src/runner/value_impl.rs000064400000000000000000000312171046102023000154120ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::cmp::Ordering; use std::net::IpAddr; use std::str::FromStr; use super::value::ValueKind; use super::{EvalError, Value}; impl Value { /// Compare with another value. /// /// Returns a [`EvalError::Type`] if the given value types are not supported. pub fn compare(&self, other: &Value) -> Result { match (self, other) { (Value::String(s1), Value::String(s2)) => Ok(s1.cmp(s2)), (Value::Number(n1), Value::Number(n2)) => Ok(n1.cmp_value(n2)), _ => Err(EvalError::Type), } } /// Returns `true` if the value starts with the given prefix. /// /// Returns `false` if it does not. /// /// Returns a [`EvalError::Type`] if the given value types are not supported. pub fn starts_with(&self, other: &Value) -> Result { match (self, other) { (Value::String(value), Value::String(prefix)) => Ok(value.starts_with(prefix)), (Value::Bytes(value), Value::Bytes(prefix)) => Ok(value.starts_with(prefix)), _ => Err(EvalError::Type), } } /// Returns `true` if the value ends with the given suffix. /// /// Returns `false` if it does not. /// /// Returns a [`EvalError::Type`] if the given value types are not supported. pub fn ends_with(&self, other: &Value) -> Result { match (self, other) { (Value::String(value), Value::String(suffix)) => Ok(value.ends_with(suffix)), (Value::Bytes(value), Value::Bytes(suffix)) => Ok(value.ends_with(suffix)), _ => Err(EvalError::Type), } } /// Returns `true` if the value contains another value. /// /// Returns `false` if it does not. /// /// Returns a [`EvalError::Type`] if the given value types are not supported. pub fn contains(&self, other: &Value) -> Result { match (self, other) { (Value::String(s), Value::String(substr)) => Ok(s.as_str().contains(substr.as_str())), (Value::Bytes(s), Value::Bytes(substr)) => { Ok(contains(s.as_slice(), substr.as_slice())) } (Value::List(values), _) => { let mut included = false; for v in values { if v == other { included = true; break; } } Ok(included) } _ => Err(EvalError::Type), } } /// Returns `true` if the list value includes another value. /// /// Returns `false` if it does not. /// /// Returns a [`EvalError::Type`] if the given value types are not supported. /// /// TODO: deprecate method in favor of contains. pub fn includes(&self, other: &Value) -> Result { match self { Value::List(values) => { let mut included = false; for v in values { if v == other { included = true; break; } } Ok(included) } _ => Err(EvalError::Type), } } /// Returns `true` the value is a boolean. /// /// Returns `false` if it is not. pub fn is_boolean(&self) -> bool { self.kind() == ValueKind::Bool } /// Returns `true` the value is a collection. /// /// Returns `false` if it is not. pub fn is_collection(&self) -> bool { self.kind() == ValueKind::Bytes || self.kind() == ValueKind::List || self.kind() == ValueKind::Nodeset || self.kind() == ValueKind::Object } /// Returns `true` the value is a date. /// /// Returns `false` if it is not. pub fn is_date(&self) -> bool { self.kind() == ValueKind::Date } /// Returns `true` the value is a float. /// /// Returns `false` if it is not. pub fn is_float(&self) -> bool { self.kind() == ValueKind::Float } /// Returns `true` the value is an integer. /// /// Returns `false` if it is not. pub fn is_integer(&self) -> bool { self.kind() == ValueKind::Integer } /// Returns `true` the value is a number. /// /// Returns `false` if it is not. pub fn is_number(&self) -> bool { self.kind() == ValueKind::Integer || self.kind() == ValueKind::Float } /// Returns `true` the value is a String. /// /// Returns `false` if it is not. pub fn is_string(&self) -> bool { self.kind() == ValueKind::String || self.kind() == ValueKind::Secret } /// Returns `true` the value is an IPv4 address. /// /// Returns `false` if it is not. /// /// Returns a [`EvalError::Type`] if the given value is not a String. pub fn is_ipv4(&self) -> Result { match self { Value::String(value) => { let is_ipv4 = IpAddr::from_str(value).is_ok_and(|ip| ip.is_ipv4()); Ok(is_ipv4) } _ => Err(EvalError::Type), } } /// Returns `true` the value is a IPv6 address. /// /// Returns `false` if it is not. /// /// Returns a [`EvalError::Type`] if the given value is not a String. pub fn is_ipv6(&self) -> Result { match self { Value::String(value) => { let is_ipv6 = IpAddr::from_str(value).is_ok_and(|ip| ip.is_ipv6()); Ok(is_ipv6) } _ => Err(EvalError::Type), } } /// Returns `true` the string value represents a RFC339 date (format YYYY-MM-DDTHH:mm:ss.sssZ). /// /// Returns `false` if it does not. /// /// Returns a [`EvalError::Type`] if the given value is not a String. pub fn is_iso_date(&self) -> Result { match self { Value::String(value) => Ok(chrono::DateTime::parse_from_rfc3339(value).is_ok()), _ => Err(EvalError::Type), } } /// Returns count of the value. /// /// Returns a [`EvalError::Type`] if the type of the value is not supported. pub fn count(&self) -> Result { match self { Value::List(values) => Ok(values.len()), Value::String(data) => Ok(data.len()), Value::Nodeset(count) => Ok(*count), Value::Object(props) => Ok(props.len()), Value::Bytes(data) => Ok(data.len()), _ => Err(EvalError::Type), } } /// Returns `true` if and only if there is a match for the regex anywhere in the value. /// /// Returns `false` otherwise. /// /// Returns a [`EvalError::Type`] if the type of the value is not supported. /// /// Returns an [`EvalError::InvalidRegex`] if the String is not a valid Regex. pub fn is_match(&self, other: &Value) -> Result { let regex = match other { Value::String(s) => match regex::Regex::new(s.as_str()) { Ok(re) => re, Err(_) => return Err(EvalError::InvalidRegex), }, Value::Regex(re) => re.clone(), _ => { return Err(EvalError::Type); } }; match self { Value::String(value) => Ok(regex.is_match(value.as_str())), _ => Err(EvalError::Type), } } } fn contains(haystack: &[u8], needle: &[u8]) -> bool { haystack .windows(needle.len()) .any(|window| window == needle) } #[cfg(test)] mod tests { use super::*; use crate::runner::Number; #[test] fn test_compare() { assert_eq!( Value::Number(Number::Integer(1)) .compare(&Value::Number(Number::Integer(1))) .unwrap(), Ordering::Equal ); assert_eq!( Value::Number(Number::Integer(1)) .compare(&Value::Number(Number::Integer(2))) .unwrap(), Ordering::Less ); assert_eq!( Value::String("xyz".to_string()) .compare(&Value::String("abc".to_string())) .unwrap(), Ordering::Greater ); assert_eq!( Value::Number(Number::Integer(1)) .compare(&Value::Bool(true)) .unwrap_err(), EvalError::Type ); } #[test] fn test_starts_with() { assert!(Value::String("Hello".to_string()) .starts_with(&Value::String("H".to_string())) .unwrap()); assert!(!Value::Bytes(vec![0, 1, 2]) .starts_with(&Value::Bytes(vec![0, 2])) .unwrap()); } #[test] fn test_ends_with() { assert!(Value::String("Hello".to_string()) .ends_with(&Value::String("o".to_string())) .unwrap()); assert!(!Value::Bytes(vec![0, 1, 2]) .ends_with(&Value::Bytes(vec![0, 2])) .unwrap()); } #[test] fn test_contains() { let haystack = [1, 2, 3]; assert!(contains(&haystack, &[1])); assert!(contains(&haystack, &[1, 2])); assert!(!contains(&haystack, &[1, 3])); assert!(Value::String("abc".to_string()) .contains(&Value::String("ab".to_string())) .unwrap()); let values = Value::List(vec![ Value::Number(Number::Integer(0)), Value::Number(Number::Integer(2)), Value::Number(Number::Integer(3)), ]); assert!(values.contains(&Value::Number(Number::Integer(0))).unwrap()); assert!(!values.contains(&Value::Number(Number::Integer(4))).unwrap()); } #[test] fn test_include() { let values = Value::List(vec![ Value::Number(Number::Integer(0)), Value::Number(Number::Integer(2)), Value::Number(Number::Integer(3)), ]); assert!(values.includes(&Value::Number(Number::Integer(0))).unwrap()); assert!(!values.includes(&Value::Number(Number::Integer(4))).unwrap()); } #[test] fn test_type() { let value1 = Value::Bool(true); assert!(value1.is_boolean()); assert!(!value1.is_collection()); let value2 = Value::Number(Number::Integer(1)); assert!(!value2.is_boolean()); assert!(!value2.is_collection()); assert!(value2.is_number()); assert!(value2.is_integer()); } #[test] fn test_iso_date() { // Some values from assert!(Value::String("1985-04-12T23:20:50.52Z".to_string()) .is_iso_date() .unwrap()); assert!(Value::String("1996-12-19T16:39:57-08:00".to_string()) .is_iso_date() .unwrap()); assert!(Value::String("1990-12-31T23:59:60Z".to_string()) .is_iso_date() .unwrap()); assert!(Value::String("1990-12-31T15:59:60-08:00".to_string()) .is_iso_date() .unwrap()); assert!(Value::String("1937-01-01T12:00:27.87+00:20".to_string()) .is_iso_date() .unwrap()); assert!(!Value::String("1978-01-15".to_string()) .is_iso_date() .unwrap()); assert_eq!( Value::Bool(true).is_iso_date().unwrap_err(), EvalError::Type ); } #[test] fn test_is_match() { let value = Value::String("hello".to_string()); let regex1 = Value::String("he.*".to_string()); assert!(value.is_match(®ex1).unwrap()); let regex2 = Value::Regex(regex::Regex::new("he.*").unwrap()); assert!(value.is_match(®ex2).unwrap()); let regex3 = Value::String("HE.*".to_string()); assert!(!value.is_match(®ex3).unwrap()); let regex4 = Value::String("?HE.*".to_string()); assert_eq!( value.is_match(®ex4).unwrap_err(), EvalError::InvalidRegex ); let regex5 = Value::Bool(true); assert_eq!(value.is_match(®ex5).unwrap_err(), EvalError::Type); } } hurl-6.1.1/src/runner/variable.rs000064400000000000000000000220041046102023000150340ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::collections::HashMap; use hurl_core::ast::SourceInfo; use crate::runner::{RunnerError, RunnerErrorKind, Value}; /// Represents a variable named to hold `Value`. #[derive(Clone, Debug, Eq, PartialEq)] pub struct Variable { /// Name of this variable. name: String, /// Value of this variable. value: Value, /// A variable is either public, or secret. visibility: Visibility, } /// Visibility of a variable value. #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum Visibility { /// The variable's value is always visible. Public, /// The variable's value is redacted from standard error and reports. Secret, } impl Variable { /// Creates a new variable named `name` with this `value` and `visibility`. pub fn new(name: &str, value: &Value, visibility: Visibility) -> Self { Variable { name: name.to_string(), value: value.clone(), visibility, } } /// Returns a reference to this variable's `value`. pub fn value(&self) -> &Value { &self.value } /// Returns `true` if this variable is secret. pub fn is_secret(&self) -> bool { matches!(self.visibility, Visibility::Secret) } } /// Errors raised when trying to insert a public/secret variable into a [`VariableSet`]. #[derive(Clone, Debug, Eq, PartialEq)] pub enum Error { ReadOnlySecret(String), } impl Error { /// Converts an instance of [`Error`] to a [`RunnerError`]. pub fn to_runner_error(&self, source_info: SourceInfo) -> RunnerError { let Error::ReadOnlySecret(name) = self; let kind = RunnerErrorKind::ReadOnlySecret { name: name.clone() }; RunnerError::new(source_info, kind, false) } } /// Represents a set of variables, either injected at the start /// of execution, or inserted during a run. #[derive(Clone, Debug, Eq, PartialEq, Default)] pub struct VariableSet { variables: HashMap, } impl VariableSet { /// Creates a new empty set of variables. pub fn new() -> Self { VariableSet { variables: HashMap::new(), } } /// Creates a new set of public variables from a [`HashMap`]. pub fn from(variables: &HashMap) -> Self { let variables = variables .iter() .map(|(name, value)| { ( name.to_string(), Variable::new(name, value, Visibility::Public), ) }) .collect::>(); VariableSet { variables: variables.clone(), } } /// Inserts a public variable named `name` with `value` /// /// This method fails when a secret value is being inserted whereas there is already a secret /// value with the same name as secret variables can't be overridden. pub fn insert(&mut self, name: String, value: Value) -> Result<(), Error> { // Secret variables can't be overridden by public variables, otherwise secret variables values // becomes public. if let Some(Variable { visibility: Visibility::Secret, .. }) = self.variables.get(&name) { return Err(Error::ReadOnlySecret(name.clone())); } let variable = Variable::new(&name, &value, Visibility::Public); self.variables.insert(name, variable); Ok(()) } /// Inserts a secret string value named `name` with `value`. /// /// This method fails when a secret value is being inserted whereas there is already a secret /// value with the same name as secret variables can't be overridden. pub fn insert_secret(&mut self, name: String, value: String) -> Result<(), Error> { // Secret variables can't be overridden by public variables, otherwise secret variables values // becomes public. if let Some(Variable { visibility: Visibility::Secret, .. }) = self.variables.get(&name) { return Err(Error::ReadOnlySecret(name.clone())); } let value = Value::String(value); let variable = Variable::new(&name, &value, Visibility::Secret); self.variables.insert(name, variable); Ok(()) } /// Returns a reference to the value corresponding to the variable named `name`. pub fn get(&self, name: &str) -> Option<&Variable> { self.variables.get(name) } /// Returns an iterator over all the variables values. pub fn iter(&self) -> impl Iterator { self.variables.iter() } /// Returns the list of all secrets values. pub fn secrets(&self) -> Vec { self.variables .iter() .filter(|(_, variable)| variable.is_secret()) .map(|(_, variable)| variable.value.to_string()) .collect::>() } } #[cfg(test)] mod test { use crate::runner::Number::{Float, Integer}; use crate::runner::{Value, Variable, VariableSet, Visibility}; #[test] fn simple_variable_set() { let mut variables = VariableSet::new(); variables .insert("foo".to_string(), Value::String("xxx".to_string())) .unwrap(); variables .insert("bar".to_string(), Value::Number(Integer(42))) .unwrap(); variables .insert("bar".to_string(), Value::Bool(true)) .unwrap(); variables .insert("baz".to_string(), Value::Number(Float(1.0))) .unwrap(); variables .insert_secret("quic".to_string(), "42".to_string()) .unwrap(); assert_eq!( variables.get("foo"), Some(&Variable::new( "foo", &Value::String("xxx".to_string()), Visibility::Public )) ); assert!(variables.get("Foo").is_none()); assert_eq!( variables.get("bar"), Some(&Variable::new( "bar", &Value::Bool(true), Visibility::Public )) ); assert_eq!( variables.get("baz"), Some(&Variable::new( "baz", &Value::Number(Float(1.0)), Visibility::Public )) ); assert_eq!( variables.get("quic"), Some(&Variable::new( "quic", &Value::String("42".to_string()), Visibility::Secret )) ); assert!(variables.get("BAZ").is_none()); } #[test] fn iter_variable_set() { fn expected_value<'data>( name: &str, data: &'data [(String, Value)], ) -> Option<&'data Value> { for (n, v) in data.iter() { if n == name { return Some(v); } } None } let data = [ ("foo".to_string(), Value::String("yyy".to_string())), ("bar".to_string(), Value::Bool(false)), ("baz".to_string(), Value::Number(Float(12.0))), ]; let mut variables = VariableSet::new(); data.clone().into_iter().for_each(|(name, value)| { variables.insert(name, value).unwrap(); }); // Test iter() for (name, variable) in variables.iter() { let expected = expected_value(name, &data); assert_eq!(expected.unwrap(), &variable.value); } } #[test] fn secret_cant_be_reassigned() { let mut variables = VariableSet::new(); variables .insert_secret("foo".to_string(), "42".to_string()) .unwrap(); assert!(variables .insert("foo".to_string(), Value::String("xxx".to_string())) .is_err()); } #[test] fn get_secrets() { let mut variables = VariableSet::new(); variables .insert_secret("foo".to_string(), "42".to_string()) .unwrap(); variables .insert("bar".to_string(), Value::String("toto".to_string())) .unwrap(); variables .insert("baz".to_string(), Value::Bool(true)) .unwrap(); variables .insert_secret("a".to_string(), "1234".to_string()) .unwrap(); let mut secrets = variables.secrets(); secrets.sort(); assert_eq!(secrets, vec!["1234", "42"]); } } hurl-6.1.1/src/runner/xpath.rs000064400000000000000000000372651046102023000144120ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::ffi::{CStr, CString}; use std::os::raw::c_char; use std::ptr; use libxml::bindings::{htmlReadMemory, xmlReadMemory}; use libxml::parser::{ParseFormat, Parser, XmlParseError}; use crate::runner::{Number, Value}; /// An error for XPath evaluation. #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum XPathError { Eval, Unsupported, } /// A structure to hold a libxml document tree. #[derive(Clone)] pub struct Document { /// The inner libxml document inner: libxml::tree::Document, /// Format use for parsing: HTML or XML format: Format, } #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum Format { Html, Xml, } impl Document { /// Parses a XML/HTML string data. pub fn parse(data: &str, format: Format) -> Result { let parser = match format { Format::Html => Parser::default_html(), Format::Xml => Parser::default(), }; let Ok(doc) = parse_html_string_patched(data, &parser) else { return Err("invalid input data".to_string()); }; // You can have a doc structure even if the input xml is not valid, we check that the root // element exists if doc.get_root_element().is_none() { return Err("no root element".to_string()); } let doc = Document { inner: doc, format }; Ok(doc) } /// Evaluates a XPath 1.0 expression `expr` against a document. pub fn eval_xpath(&self, expr: &str) -> Result { let support_ns = match self.format { Format::Html => false, Format::Xml => true, }; libxml_eval_xpath(&self.inner, expr, support_ns) } } /// FIXME: Here are some patched functions of libxml crate. /// Started from libxml 2.11.1+, we have some encoding issue. /// See: /// - /// - /// /// These two functions should be removed when the issue is fixed in libxml crate. fn try_usize_to_i32(value: usize) -> Result { if cfg!(target_pointer_width = "16") || (value < i32::MAX as usize) { // Cannot safely use our value comparison, but the conversion if always safe. // Or, if the value can be safely represented as a 32-bit signed integer. Ok(value as i32) } else { // Document too large, cannot parse using libxml2. Err(XmlParseError::DocumentTooLarge) } } fn parse_html_string_patched( input: &str, parser: &Parser, ) -> Result { let input_bytes: &[u8] = input.as_ref(); let input_ptr = input_bytes.as_ptr() as *const c_char; let input_len = try_usize_to_i32(input_bytes.len())?; let encoding = CString::new("utf-8").unwrap(); let encoding_ptr = encoding.as_ptr(); let url_ptr = ptr::null(); // HTML_PARSE_RECOVER | HTML_PARSE_NOERROR let options = 1 + 32; match parser.format { ParseFormat::XML => unsafe { let doc_ptr = xmlReadMemory(input_ptr, input_len, url_ptr, encoding_ptr, options); if doc_ptr.is_null() { Err(XmlParseError::GotNullPointer) } else { Ok(libxml::tree::Document::new_ptr(doc_ptr)) } }, ParseFormat::HTML => unsafe { let docptr = htmlReadMemory(input_ptr, input_len, url_ptr, encoding_ptr, options); if docptr.is_null() { Err(XmlParseError::GotNullPointer) } else { Ok(libxml::tree::Document::new_ptr(docptr)) } }, } } extern "C" { pub fn silentErrorFunc( ctx: *mut ::std::os::raw::c_void, msg: *const ::std::os::raw::c_char, ... ); } /// Registers all XML namespaces from a document `doc` to a `context`. fn register_namespaces(doc: &libxml::tree::Document, context: &libxml::xpath::Context) { // We walk through the xml document to register each namespace, // so we can eval xpath queries with namespace. For convenience, we register the // first default namespace with _ prefix. Other default namespaces are not registered // and should be referenced vi `local-name` or `name` XPath functions. let namespaces = document_namespaces(doc); let mut default_registered = false; for n in namespaces { if n.prefix.is_empty() { if !default_registered { context.register_namespace("_", &n.href).unwrap(); default_registered = true; } } else { context.register_namespace(&n.prefix, &n.href).unwrap(); } } } /// Evaluates a XPath 1.0 expression `expr` against an libxml2 document `doc`, optionally using namespace. fn libxml_eval_xpath( doc: &libxml::tree::Document, expr: &str, support_ns: bool, ) -> Result { let context = libxml::xpath::Context::new(doc).expect("error setting context in xpath module"); // libxml2 prints to stdout warning and errors, so we mut it. unsafe { libxml::bindings::initGenericErrorDefaultFunc(&mut Some(silentErrorFunc)); } if support_ns { register_namespaces(doc, &context); } let result = match context.evaluate(expr) { Ok(object) => object, Err(_) => return Err(XPathError::Eval), }; match unsafe { *result.ptr }.type_ { libxml::bindings::xmlXPathObjectType_XPATH_NUMBER => { Ok(Value::Number(Number::from(unsafe { *result.ptr }.floatval))) } libxml::bindings::xmlXPathObjectType_XPATH_BOOLEAN => { Ok(Value::Bool(unsafe { *result.ptr }.boolval != 0)) } libxml::bindings::xmlXPathObjectType_XPATH_STRING => { // TO BE CLEANED let c_s = unsafe { *result.ptr }.stringval; let c_s2 = c_s as *const c_char; let x = unsafe { CStr::from_ptr(c_s2) }; let s = x.to_string_lossy().to_string(); Ok(Value::String(s)) } libxml::bindings::xmlXPathObjectType_XPATH_NODESET => { Ok(Value::Nodeset(result.get_number_of_nodes())) } _ => Err(XPathError::Unsupported), } } /// A XML namespace #[derive(Debug, PartialEq, Eq)] struct Namespace { prefix: String, href: String, } impl Namespace { /// Create a Namespace given a libxml2 namespace reference. fn from(namespace: &libxml::tree::Namespace) -> Namespace { Namespace { prefix: namespace.get_prefix(), href: namespace.get_href(), } } } /// Returns all XML namespaces for a libxml2 document. fn document_namespaces(doc: &libxml::tree::Document) -> Vec { let root = doc.get_root_element(); let root = match root { None => return vec![], Some(r) => r, }; namespaces(&root) } /// Returns all XML namespaces for a given libxml2 node. fn namespaces(node: &libxml::tree::Node) -> Vec { let mut all_ns = Vec::new(); // Get namespaces from the current node let ns: Vec = node .get_namespace_declarations() .into_iter() .map(|n| Namespace::from(&n)) .collect(); all_ns.extend(ns); // Get children namespaces let ns: Vec = node .get_child_nodes() .into_iter() .flat_map(|c| namespaces(&c)) .collect(); all_ns.extend(ns); all_ns } #[cfg(test)] mod tests { use super::*; #[test] fn test_xml() { let xml = r#" "#; let doc = Document::parse(xml, Format::Xml).unwrap(); let xpath = "count(//food/*)"; assert_eq!( doc.eval_xpath(xpath).unwrap(), Value::Number(Number::from(3.0)) ); let xpath = "//food/*"; assert_eq!(doc.eval_xpath(xpath).unwrap(), Value::Nodeset(3)); let xpath = "count(//*[@type='fruit'])"; assert_eq!( doc.eval_xpath(xpath).unwrap(), Value::Number(Number::from(2.0)) ); let xpath = "number(//food/banana/@price)"; assert_eq!( doc.eval_xpath(xpath).unwrap(), Value::Number(Number::from(1.1)) ); } #[test] fn test_error_eval() { let xml = "
"; let doc = Document::parse(xml, Format::Xml).unwrap(); assert_eq!(doc.eval_xpath("^^^").unwrap_err(), XPathError::Eval); assert_eq!(doc.eval_xpath("//").unwrap_err(), XPathError::Eval); // assert_eq!(1,2); } // TBC!!! // Invalid XML not detected at parsing??? => goes into an eval error #[test] fn test_invalid_xml() { let xml = "??"; let doc = Document::parse(xml, Format::Xml); assert!(doc.is_err()); } #[test] fn test_cafe_xml() { let xml = "café"; let doc = Document::parse(xml, Format::Xml).unwrap(); assert_eq!( doc.eval_xpath("normalize-space(//data)").unwrap(), Value::String(String::from("café")) ); } #[test] fn test_cafe_html() { let html = "café"; let doc = Document::parse(html, Format::Html).unwrap(); assert_eq!( doc.eval_xpath("normalize-space(//data)").unwrap(), Value::String(String::from("café")) ); } #[test] fn test_html() { let html = r#"
"#; let doc = Document::parse(html, Format::Html).unwrap(); let xpath = "normalize-space(/html/head/meta/@charset)"; assert_eq!( doc.eval_xpath(xpath).unwrap(), Value::String(String::from("UTF-8")) ); } #[test] fn test_bug() { let html = r#""#; let doc = Document::parse(html, Format::Html).unwrap(); let xpath = "boolean(count(//a[contains(@href,'xxx')]))"; assert_eq!(doc.eval_xpath(xpath).unwrap(), Value::Bool(false)); } #[test] fn test_unregistered_function() { let html = r#""#; let doc = Document::parse(html, Format::Html).unwrap(); let xpath = "strong(//head/title)"; assert_eq!(doc.eval_xpath(xpath).unwrap_err(), XPathError::Eval); } #[test] fn test_namespaces_with_prefix() { let xml = r#" Dune Franck Herbert "#; let doc = Document::parse(xml, Format::Xml).unwrap(); let expr = "string(//a:books/b:book/b:title)"; assert_eq!( doc.eval_xpath(expr).unwrap(), Value::String("Dune".to_string()) ); let expr = "string(//a:books/b:book/c:author)"; assert_eq!( doc.eval_xpath(expr).unwrap(), Value::String("Franck Herbert".to_string()) ); let expr = "string(//*[name()='a:books']/*[name()='b:book']/*[name()='c:author'])"; assert_eq!( doc.eval_xpath(expr).unwrap(), Value::String("Franck Herbert".to_string()) ); let expr = "string(//*[local-name()='books']/*[local-name()='book']/*[local-name()='author'])"; assert_eq!( doc.eval_xpath(expr).unwrap(), Value::String("Franck Herbert".to_string()) ); } #[test] fn test_default_namespaces() { let xml = r#" SVG "#; let doc = Document::parse(xml, Format::Xml).unwrap(); let expr = "string(//_:svg/_:text)"; assert_eq!( doc.eval_xpath(expr).unwrap(), Value::String("SVG".to_string()) ); let expr = "string(//*[name()='svg']/*[name()='text'])"; assert_eq!( doc.eval_xpath(expr).unwrap(), Value::String("SVG".to_string()) ); let expr = "string(//*[local-name()='svg']/*[local-name()='text'])"; assert_eq!( doc.eval_xpath(expr).unwrap(), Value::String("SVG".to_string()) ); } #[test] fn test_soap() { let xml = r#" "#; let doc = Document::parse(xml, Format::Xml).unwrap(); let expr = "string(//soap:Envelope/soap:Body/ns1:OTA_AirAvailRS/@TransactionIdentifier)"; assert_eq!( doc.eval_xpath(expr).unwrap(), Value::String("TID$16459590516432752971.demo2144".to_string()) ); let expr = "string(//*[name()='soap:Envelope']/*[name()='soap:Body']/*[name()='ns1:OTA_AirAvailRS']/@TransactionIdentifier)"; assert_eq!( doc.eval_xpath(expr).unwrap(), Value::String("TID$16459590516432752971.demo2144".to_string()) ); let expr = "string(//*[local-name()='Envelope']/*[local-name()='Body']/*[local-name()='OTA_AirAvailRS']/@TransactionIdentifier)"; assert_eq!( doc.eval_xpath(expr).unwrap(), Value::String("TID$16459590516432752971.demo2144".to_string()) ); } #[test] fn test_namespaces_scoping() { let xml = r#" Cheaper by the Dozen 1568491379

This is a funny book!

"#; let doc = Document::parse(xml, Format::Xml).unwrap(); let expr = "string(//_:book/_:title)"; assert_eq!( doc.eval_xpath(expr).unwrap(), Value::String("Cheaper by the Dozen".to_string()) ); let expr = "string(//_:book/isbn:number)"; assert_eq!( doc.eval_xpath(expr).unwrap(), Value::String("1568491379".to_string()) ); let expr = "//*[name()='book']/*[name()='notes']"; assert_eq!(doc.eval_xpath(expr).unwrap(), Value::Nodeset(1)); let expr = "//_:book/_:notes/*[local-name()='p']"; assert_eq!(doc.eval_xpath(expr).unwrap(), Value::Nodeset(1)); } } hurl-6.1.1/src/util/logger.rs000064400000000000000000000267651046102023000142140ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ //! Log utilities. use hurl_core::ast::SourceInfo; use hurl_core::error::{DisplaySourceError, OutputFormat}; use hurl_core::input::Input; use hurl_core::text::{Format, Style, StyledString}; use crate::runner::Value; use crate::util::redacted::Redact; use crate::util::term::{Stderr, WriteMode}; #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum ErrorFormat { Short, Long, } #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum Verbosity { Verbose, VeryVerbose, } impl Verbosity { pub fn from(verbose: bool, very_verbose: bool) -> Option { match (verbose, very_verbose) { (_, true) => Some(Verbosity::VeryVerbose), (true, false) => Some(Verbosity::Verbose), _ => None, } } } /// A dedicated logger for an Hurl file. This logger can display rich parsing and runtime errors. #[derive(Clone)] pub struct Logger { pub(crate) color: bool, pub(crate) error_format: ErrorFormat, pub(crate) verbosity: Option, pub(crate) stderr: Stderr, secrets: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct LoggerOptions { color: bool, error_format: ErrorFormat, verbosity: Option, } pub struct LoggerOptionsBuilder { color: bool, error_format: ErrorFormat, verbosity: Option, } impl LoggerOptionsBuilder { /// Returns a new Logger builder with a default values. pub fn new() -> Self { LoggerOptionsBuilder::default() } /// Sets color usage. pub fn color(&mut self, color: bool) -> &mut Self { self.color = color; self } /// Control the format of error messages. /// If `error_format` is [`ErrorFormat::Long`], the HTTP request and response that has /// errors is displayed (headers, body, etc..) pub fn error_format(&mut self, error_format: ErrorFormat) -> &mut Self { self.error_format = error_format; self } /// Sets verbose logger. pub fn verbosity(&mut self, verbosity: Option) -> &mut Self { self.verbosity = verbosity; self } /// Creates a new logger. pub fn build(&self) -> LoggerOptions { LoggerOptions { color: self.color, error_format: self.error_format, verbosity: self.verbosity, } } } impl Default for LoggerOptionsBuilder { fn default() -> Self { LoggerOptionsBuilder { color: false, error_format: ErrorFormat::Short, verbosity: None, } } } impl Logger { /// Creates a new instance. pub fn new(options: &LoggerOptions, term: Stderr, secrets: &[String]) -> Self { Logger { color: options.color, error_format: options.error_format, verbosity: options.verbosity, stderr: term, secrets: secrets.to_vec(), } } fn format(&self) -> Format { if self.color { Format::Ansi } else { Format::Plain } } /// Prints a given message to this logger [`Stderr`] instance, no matter what is the verbosity. pub fn info(&mut self, message: &str) { self.eprintln(message); } /// Prints a given debug message to this logger [`Stderr`] instance, in verbose and very verbose mode. /// /// Displayed debug messages start with `*`. pub fn debug(&mut self, message: &str) { if self.verbosity.is_none() { return; } let fmt = self.format(); let mut s = StyledString::new(); s.push_with("*", Style::new().blue().bold()); if !message.is_empty() { s.push(" "); s.push(message); } self.eprintln(&s.to_string(fmt)); } /// Prints a given debug message in bold to this logger [`Stderr`] instance, in verbose and very verbose mode. /// /// Displayed debug messages start with `*`. pub fn debug_important(&mut self, message: &str) { if self.verbosity.is_none() { return; } let fmt = self.format(); let mut s = StyledString::new(); s.push_with("*", Style::new().blue().bold()); if !message.is_empty() { s.push(" "); s.push_with(message, Style::new().bold()); } self.eprintln(&s.to_string(fmt)); } /// Prints a given debug message from libcurl to this logger [`Stderr`] instance, in verbose and very verbose mode. /// /// Displayed libcurl debug messages start with `**`. pub fn debug_curl(&mut self, message: &str) { if self.verbosity.is_none() { return; } let fmt = self.format(); let mut s = StyledString::new(); s.push_with("**", Style::new().blue().bold()); if !message.is_empty() { s.push(" "); s.push(message); } self.eprintln(&s.to_string(fmt)); } /// Prints an error (syntax error or runtime error) to this logger [`Stderr`] instance, in verbose and very verbose mode. pub fn debug_error( &mut self, content: &str, filename: Option<&Input>, error: &E, entry_src_info: SourceInfo, ) { if self.verbosity.is_none() { return; } let filename = filename.map_or(String::new(), |f| f.to_string()); let message = error.to_string( &filename, content, Some(entry_src_info), OutputFormat::Terminal(self.color), ); message.lines().for_each(|l| self.debug(l)); } /// Prints a HTTP response header to this logger [`Stderr`] instance, in verbose and very verbose mode. /// /// Response HTTP headers start with `>`. pub fn debug_headers_in(&mut self, headers: &[(&str, &str)]) { if self.verbosity.is_none() { return; } let fmt = self.format(); for (name, value) in headers { let mut s = StyledString::new(); s.push("< "); s.push_with(name, Style::new().cyan().bold()); s.push(": "); s.push(value); self.eprintln(&s.to_string(fmt)); } self.eprintln("<"); } /// Prints a HTTP request header to this logger [`Stderr`] instance, in verbose and very verbose mode. /// /// Request HTTP headers start with `>`. pub fn debug_headers_out(&mut self, headers: &[(&str, &str)]) { if self.verbosity.is_none() { return; } let fmt = self.format(); for (name, value) in headers { let mut s = StyledString::new(); s.push("> "); s.push_with(name, Style::new().cyan().bold()); s.push(": "); s.push(value); self.eprintln(&s.to_string(fmt)); } self.eprintln(">"); } /// Prints a HTTP response status code to this logger [`Stderr`] instance, in verbose and very verbose mode. pub fn debug_status_version_in(&mut self, line: &str) { if self.verbosity.is_none() { return; } let fmt = self.format(); let mut s = StyledString::new(); s.push("< "); s.push_with(line, Style::new().green().bold()); self.eprintln(&s.to_string(fmt)); } /// Prints a warning given message to this logger [`Stderr`] instance, no matter what is the verbosity. /// /// Displayed warning messages start with `warning:`. pub fn warning(&mut self, message: &str) { let fmt = self.format(); let mut s = StyledString::new(); s.push_with("warning", Style::new().yellow().bold()); s.push(": "); s.push_with(message, Style::new().bold()); self.eprintln(&s.to_string(fmt)); } pub fn error_parsing_rich( &mut self, content: &str, filename: Option<&Input>, error: &E, ) { // FIXME: peut-être qu'on devrait faire rentrer le prefix `error:` qui est // fournit par `self.error_rich` dans la méthode `error.to_string` let filename = filename.map_or(String::new(), |f| f.to_string()); let message = error.to_string(&filename, content, None, OutputFormat::Terminal(self.color)); self.error_rich(&message); } /// Prints a runtime error to this logger [`Stderr`] instance, no matter what is the verbosity. pub fn error_runtime_rich( &mut self, content: &str, filename: Option<&Input>, error: &E, entry_src_info: SourceInfo, ) { let filename = filename.map_or(String::new(), |f| f.to_string()); let message = error.to_string( &filename, content, Some(entry_src_info), OutputFormat::Terminal(self.color), ); self.error_rich(&message); } fn error_rich(&mut self, message: &str) { let fmt = self.format(); let mut s = StyledString::new(); s.push_with("error", Style::new().red().bold()); s.push(": "); s.push(message); s.push("\n"); self.eprintln(&s.to_string(fmt)); } /// Prints the request method and HTTP version to this logger [`Stderr`] instance, in verbose and very verbose mode. pub fn debug_method_version_out(&mut self, line: &str) { if self.verbosity.is_none() { return; } let fmt = self.format(); let mut s = StyledString::new(); s.push("> "); s.push_with(line, Style::new().purple().bold()); self.eprintln(&s.to_string(fmt)); } /// Prints a capture to this logger [`Stderr`] instance, in verbose and very verbose mode. pub fn capture(&mut self, name: &str, value: &Value) { if self.verbosity.is_none() { return; } let value = value.to_string(); let fmt = self.format(); let mut s = StyledString::new(); s.push_with("*", Style::new().blue().bold()); s.push(" "); s.push_with(name, Style::new().yellow().bold()); s.push(": "); s.push(&value); self.eprintln(&s.to_string(fmt)); } /// Update logger with new `secrets`. pub fn set_secrets(&mut self, secrets: Vec) { if self.secrets == secrets { return; } self.secrets = secrets; // When secrets are updated, we need to rewrite the buffered `StdErr` as new secrets // needs to be redacted. if matches!(self.stderr.mode(), WriteMode::Buffered) { let old_buffer = self.stderr.buffer(); let new_buffer = old_buffer.redact(&self.secrets); self.stderr.set_buffer(new_buffer); } } fn eprintln(&mut self, message: &str) { if self.secrets.is_empty() { self.stderr.eprintln(message); return; } let redacted = message.redact(&self.secrets); self.stderr.eprintln(&redacted); } } hurl-6.1.1/src/util/mod.rs000064400000000000000000000014021046102023000134710ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ //! Common utilities like log, path helpers and standard output/error wrapper. pub mod logger; pub mod path; pub mod redacted; pub mod term; hurl-6.1.1/src/util/path.rs000064400000000000000000000231601046102023000136530ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ //! Access controlled path. use std::path::{Component, Path, PathBuf}; /// Represents the directories used to run a Hurl file. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ContextDir { /// The current working directory. /// If current directory is a relative path, the `is_access_allowed` method /// is not guaranteed to be correct. current_dir: PathBuf, /// The file root, either inferred or explicitly positioned by the user. /// As a consequence, it is always defined (and can't be replaced by a `Option`). /// It can be relative (to the current directory) or absolute. file_root: PathBuf, } impl Default for ContextDir { fn default() -> Self { ContextDir { current_dir: PathBuf::new(), file_root: PathBuf::new(), } } } impl ContextDir { /// Returns a context directory with the given current directory and file root. pub fn new(current_dir: &Path, file_root: &Path) -> ContextDir { ContextDir { current_dir: PathBuf::from(current_dir), file_root: PathBuf::from(file_root), } } /// Returns a path (absolute or relative), given a filename. pub fn resolved_path(&self, filename: &Path) -> PathBuf { self.file_root.join(filename) } /// Checks if a given `filename` access is authorized. /// This method is used to check if a local file can be included in POST request or if a /// response can be outputted to a given file when using `output` option in \[Options\] sections. pub fn is_access_allowed(&self, filename: &Path) -> bool { let file = self.resolved_path(filename); let absolute_file = self.current_dir.join(file); let absolute_file_root = self.current_dir.join(&self.file_root); is_descendant(absolute_file.as_path(), absolute_file_root.as_path()) } } /// Return true if `path` is a descendant path of `ancestor`, false otherwise. fn is_descendant(path: &Path, ancestor: &Path) -> bool { let path = normalize_path(path); let ancestor = normalize_path(ancestor); for a in path.ancestors() { if ancestor == a { return true; } } false } /// Returns the absolute form of this `path` with all intermediate components normalized. /// Contrary to the methods [`std::fs::canonicalize`] on [`Path`], this function doesn't require /// the final path to exist. /// /// Borrowed from https://github.com/rust-lang/cargo/blob/master/crates/cargo-util/src/paths.rs fn normalize_path(path: &Path) -> PathBuf { let mut components = path.components().peekable(); let mut ret = if let Some(c @ Component::Prefix(..)) = components.peek().cloned() { components.next(); PathBuf::from(c.as_os_str()) } else { PathBuf::new() }; for component in components { match component { Component::Prefix(..) => unreachable!(), Component::RootDir => { ret.push(component.as_os_str()); } Component::CurDir => {} Component::ParentDir => { ret.pop(); } Component::Normal(c) => { ret.push(c); } } } ret } // Create parent directories, if missing, given a filepath ending with a file name pub fn create_dir_all(filename: &Path) -> Result<(), std::io::Error> { if let Some(parent) = filename.parent() { return std::fs::create_dir_all(parent); } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn check_filename_allowed_access_without_user_file_root() { // ``` // $ cd /tmp // $ hurl test.hurl // ``` let current_dir = Path::new("/tmp"); let file_root = Path::new(""); let ctx = ContextDir::new(current_dir, file_root); assert!(ctx.is_access_allowed(Path::new("foo.bin"))); assert!(ctx.is_access_allowed(Path::new("/tmp/foo.bin"))); assert!(ctx.is_access_allowed(Path::new("a/foo.bin"))); assert!(ctx.is_access_allowed(Path::new("a/b/foo.bin"))); assert!(ctx.is_access_allowed(Path::new("../tmp/a/b/foo.bin"))); assert!(ctx.is_access_allowed(Path::new("../../../tmp/a/b/foo.bin"))); assert!(!ctx.is_access_allowed(Path::new("/file/foo.bin"))); assert!(!ctx.is_access_allowed(Path::new("../foo.bin"))); assert!(!ctx.is_access_allowed(Path::new("../../foo.bin"))); assert!(!ctx.is_access_allowed(Path::new("../../file/foo.bin"))); } #[test] fn check_filename_allowed_access_with_explicit_absolute_user_file_root() { // ``` // $ cd /tmp // $ hurl --file-root /file test.hurl // ``` let current_dir = Path::new("/tmp"); let file_root = Path::new("/file"); let ctx = ContextDir::new(current_dir, file_root); assert!(ctx.is_access_allowed(Path::new("foo.bin"))); // absolute path is /file/foo.bin assert!(ctx.is_access_allowed(Path::new("/file/foo.bin"))); assert!(ctx.is_access_allowed(Path::new("a/foo.bin"))); assert!(ctx.is_access_allowed(Path::new("a/b/foo.bin"))); assert!(ctx.is_access_allowed(Path::new("../../file/foo.bin"))); assert!(!ctx.is_access_allowed(Path::new("/tmp/foo.bin"))); assert!(!ctx.is_access_allowed(Path::new("../tmp/a/b/foo.bin"))); assert!(!ctx.is_access_allowed(Path::new("../foo.bin"))); assert!(!ctx.is_access_allowed(Path::new("../../foo.bin"))); assert!(!ctx.is_access_allowed(Path::new("../../../tmp/a/b/foo.bin"))); let current_dir = Path::new("/tmp"); let file_root = Path::new("../file"); let ctx = ContextDir::new(current_dir, file_root); assert!(ctx.is_access_allowed(Path::new("foo.bin"))); assert!(ctx.is_access_allowed(Path::new("/file/foo.bin"))); assert!(ctx.is_access_allowed(Path::new("a/foo.bin"))); assert!(ctx.is_access_allowed(Path::new("a/b/foo.bin"))); assert!(ctx.is_access_allowed(Path::new("../../file/foo.bin"))); assert!(!ctx.is_access_allowed(Path::new("/tmp/foo.bin"))); assert!(!ctx.is_access_allowed(Path::new("../tmp/a/b/foo.bin"))); assert!(!ctx.is_access_allowed(Path::new("../foo.bin"))); assert!(!ctx.is_access_allowed(Path::new("../../foo.bin"))); assert!(!ctx.is_access_allowed(Path::new("../../../tmp/a/b/foo.bin"))); } #[test] fn check_filename_allowed_access_with_implicit_relative_user_file_root() { // ``` // $ cd /tmp // $ hurl a/b/test.hurl // ``` let current_dir = Path::new("/tmp"); let file_root = Path::new("a/b"); let ctx = ContextDir::new(current_dir, file_root); assert!(ctx.is_access_allowed(Path::new("foo.bin"))); assert!(ctx.is_access_allowed(Path::new("c/foo.bin"))); // absolute path is /tmp/a/b/c/foo.bin assert!(ctx.is_access_allowed(Path::new("/tmp/a/b/foo.bin"))); assert!(ctx.is_access_allowed(Path::new("/tmp/a/b/c/d/foo.bin"))); assert!(ctx.is_access_allowed(Path::new("../../../tmp/a/b/foo.bin"))); assert!(!ctx.is_access_allowed(Path::new("/tmp/foo.bin"))); } #[test] fn check_filename_allowed_access_with_explicit_relative_user_file_root() { // ``` // $ cd /tmp // $ hurl --file-root ../tmp test.hurl // ``` let current_dir = Path::new("/tmp"); let file_root = Path::new("../tmp"); let ctx = ContextDir::new(current_dir, file_root); assert!(ctx.is_access_allowed(Path::new("foo.bin"))); assert!(ctx.is_access_allowed(Path::new("/tmp/foo.bin"))); assert!(ctx.is_access_allowed(Path::new("a/foo.bin"))); assert!(ctx.is_access_allowed(Path::new("a/b/foo.bin"))); assert!(ctx.is_access_allowed(Path::new("../tmp/a/b/foo.bin"))); assert!(ctx.is_access_allowed(Path::new("../../../tmp/a/b/foo.bin"))); assert!(!ctx.is_access_allowed(Path::new("/file/foo.bin"))); assert!(!ctx.is_access_allowed(Path::new("../foo.bin"))); assert!(!ctx.is_access_allowed(Path::new("../../foo.bin"))); assert!(!ctx.is_access_allowed(Path::new("../../file/foo.bin"))); } #[test] fn is_descendant_true() { let child = Path::new("/tmp/foo/bar.txt"); let parent = Path::new("/tmp"); assert!(is_descendant(child, parent)); let child = Path::new("/tmp/foo/../bar.txt"); let parent = Path::new("/tmp"); assert!(is_descendant(child, parent)); let child = Path::new("bar.txt"); let parent = Path::new(""); assert!(is_descendant(child, parent)); } #[test] fn is_descendant_false() { let child = Path::new("/tmp/foo/../../bar.txt"); let parent = Path::new("/tmp"); assert!(!is_descendant(child, parent)); let child = Path::new("/a/bar.txt"); let parent = Path::new("/b"); assert!(!is_descendant(child, parent)); let child = Path::new("/bar.txt"); let parent = Path::new(""); assert!(!is_descendant(child, parent)); } } hurl-6.1.1/src/util/redacted.rs000064400000000000000000000031661046102023000144760ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ pub trait Redact { /// Redacts this given a list of secrets. fn redact(&self, secrets: &[impl AsRef]) -> String; } impl Redact for T where T: AsRef + ToString, { fn redact(&self, secrets: &[impl AsRef]) -> String { let mut value = self.to_string(); for s in secrets { value = value.replace(s.as_ref(), "***"); } value } } #[cfg(test)] mod tests { use crate::util::redacted::Redact; #[test] fn redacted_string_hides_secret() { // Inner function to trigger deref from &RedactedString to &str. fn assert_eq(left: &str, right: &str) { assert_eq!(left, right); } let secrets = ["foo", "bar", "baz"]; assert_eq( &"Hello, here are secrets values: foo".redact(&secrets), "Hello, here are secrets values: ***", ); assert_eq(&"bar".redact(&secrets), "***"); assert_eq(&"Baz is not secret".redact(&secrets), "Baz is not secret"); } } hurl-6.1.1/src/util/term.rs000064400000000000000000000175601046102023000136750ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ //! Wrapper on standard output/error. use std::io; #[cfg(target_family = "windows")] use std::io::IsTerminal; use std::io::Write; /// The way to write on standard output and error: either immediate like `println!` macro, /// or buffered in an internal buffer. #[derive(Copy, Clone, Debug, Eq, PartialEq)] pub enum WriteMode { /// Messages are printed immediately. Immediate, /// Messages are saved to an internal buffer, and can be retrieved with [`Stdout::buffer`] / /// [`Stderr::buffer`]. Buffered, } /// Indirection for standard output. /// /// Depending on `mode`, bytes are immediately printed to standard output, or buffered in an /// internal buffer. pub struct Stdout { /// Write mode of the standard output: immediate or saved to a buffer. mode: WriteMode, /// Internal buffer, filled when `mode` is [`WriteMode::Buffered`] buffer: Vec, } impl Stdout { /// Creates a new standard output, buffered or immediate depending on `mode`. pub fn new(mode: WriteMode) -> Self { Stdout { mode, buffer: Vec::new(), } } /// Attempts to write an entire buffer into standard output. pub fn write_all(&mut self, buf: &[u8]) -> Result<(), io::Error> { match self.mode { WriteMode::Immediate => write_stdout(buf), WriteMode::Buffered => self.buffer.write_all(buf), } } /// Returns the buffered standard output. pub fn buffer(&self) -> &[u8] { &self.buffer } } #[cfg(target_family = "unix")] fn write_stdout(buf: &[u8]) -> Result<(), io::Error> { let mut handle = io::stdout().lock(); handle.write_all(buf)?; Ok(()) } #[cfg(target_family = "windows")] fn write_stdout(buf: &[u8]) -> Result<(), io::Error> { // From : // > When operating in a console, the Windows implementation of this stream does not support // > non-UTF-8 byte sequences. Attempting to write bytes that are not valid UTF-8 will return // > an error. // As a workaround to prevent error, we convert the buffer to an UTF-8 string (with potential // bytes losses) before writing to the standard output of the Windows console. if io::stdout().is_terminal() { println!("{}", String::from_utf8_lossy(buf)); } else { let mut handle = io::stdout().lock(); handle.write_all(buf)?; } Ok(()) } /// Indirection for standard error. /// /// Depending on `mode`, messages are immediately printed to standard error, or buffered in an /// internal buffer. /// /// An optional `progress` string can be used to report temporary progress indication to the user. /// It's always printed as the last lines of the standard error. When the standard error is created /// with [`WriteMode::Buffered`], the progress is not saved in the internal buffer. #[derive(Clone, Debug)] pub struct Stderr { /// Write mode of the standard error: immediate or saved to a buffer. mode: WriteMode, /// Internal buffer, filled when `mode` is [`WriteMode::Buffered`] buffer: String, /// Progress bar: when not empty, it is always displayed at the end of the terminal. progress_bar: String, } impl Stderr { /// Creates a new standard error, buffered or immediate depending on `mode`. pub fn new(mode: WriteMode) -> Self { Stderr { mode, buffer: String::new(), progress_bar: String::new(), } } /// Returns the [`WriteMode`] of this logger. pub fn mode(&self) -> WriteMode { self.mode } /// Prints to the standard error, with a newline. pub fn eprintln(&mut self, message: &str) { match self.mode { WriteMode::Immediate => { let has_progress = !self.progress_bar.is_empty(); if has_progress { self.rewind_cursor(); } eprintln!("{message}"); if has_progress { eprint!("{}", self.progress_bar); } } WriteMode::Buffered => { self.buffer.push_str(message); self.buffer.push('\n'); } } } /// Prints to the standard error. pub fn eprint(&mut self, message: &str) { match self.mode { WriteMode::Immediate => { let has_progress = !self.progress_bar.is_empty(); if has_progress { self.rewind_cursor(); } eprint!("{message}"); if has_progress { eprint!("{}", self.progress_bar); } } WriteMode::Buffered => { self.buffer.push_str(message); } } } /// Sets the progress bar (only in [`WriteMode::Immediate`] mode). pub fn set_progress_bar(&mut self, progress: &str) { match self.mode { WriteMode::Immediate => { self.progress_bar = progress.to_string(); eprint!("{}", self.progress_bar); } WriteMode::Buffered => {} } } /// Clears the progress string (only in [`WriteMode::Immediate`] mode). pub fn clear_progress_bar(&mut self) { self.rewind_cursor(); self.progress_bar.clear(); } /// Returns the buffered standard error. pub fn buffer(&self) -> &str { &self.buffer } /// Set the buffered standard error. pub fn set_buffer(&mut self, buffer: String) { self.buffer = buffer; } /// Clears any progress and reset cursor terminal to the position of the last "real" message. fn rewind_cursor(&self) { if self.progress_bar.is_empty() { return; } match self.mode { WriteMode::Immediate => { // We count the number of new lines \n. We can't use the `String::lines()` because // it counts a line for a single carriage return. We don't want to go up for a // single carriage return. let lines = self.progress_bar.chars().filter(|c| *c == '\n').count(); // We used the following ANSI codes: // - K: "EL - Erase in Line" sequence. It clears from the cursor to the end of line. // - 1A: "Cursor Up". Up to one line // if lines > 0 { (0..lines).for_each(|_| eprint!("\x1B[1A\x1B[K")); } else { eprint!("\x1B[K"); } } WriteMode::Buffered => {} } } } #[cfg(test)] mod tests { use crate::util::term::{Stderr, Stdout, WriteMode}; #[test] fn buffered_stdout() { let mut stdout = Stdout::new(WriteMode::Buffered); stdout.write_all(b"Hello").unwrap(); stdout.write_all(b" ").unwrap(); stdout.write_all(b"World!").unwrap(); assert_eq!(stdout.buffer(), b"Hello World!"); } #[test] fn buffered_stderr() { let mut stderr = Stderr::new(WriteMode::Buffered); stderr.eprintln("toto"); stderr.set_progress_bar("some progress...\r"); stderr.eprintln("tutu"); assert_eq!(stderr.buffer(), "toto\ntutu\n"); } } hurl-6.1.1/tests/bookstore.json000064400000000000000000000013541046102023000146520ustar 00000000000000{ "store": { "book": [ { "category": "reference", "author": "Nigel Rees", "title": "Sayings of the Century", "price": 8.95 }, { "category": "fiction", "author": "Evelyn Waugh", "title": "Sword of Honour", "price": 12.99 }, { "category": "fiction", "author": "Herman Melville", "title": "Moby Dick", "isbn": "0-553-21311-3", "price": 8.99 }, { "category": "fiction", "author": "J. R. R. Tolkien", "title": "The Lord of the Rings", "isbn": "0-395-19395-8", "price": 22.99 } ], "bicycle": { "color": "red", "price": 19.95 } } }hurl-6.1.1/tests/cookies.txt000064400000000000000000000000511046102023000141360ustar 00000000000000localhost FALSE / FALSE 0 cookie2 valueA hurl-6.1.1/tests/data.bin000064400000000000000000000000141046102023000133430ustar 00000000000000Hello World!hurl-6.1.1/tests/hello.txt000064400000000000000000000000141046102023000136040ustar 00000000000000Hello World!hurl-6.1.1/tests/sample.rs000064400000000000000000000117061046102023000136010ustar 00000000000000/* * Hurl (https://hurl.dev) * Copyright (C) 2025 Orange * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ use std::str::FromStr; use std::time::Duration; use hurl::http::{Call, HttpVersion, Request, Response, Url}; use hurl::runner; use hurl::runner::{EntryResult, HurlResult, RunnerOptionsBuilder, VariableSet}; use hurl::util::logger::LoggerOptionsBuilder; use hurl::util::path::ContextDir; use hurl_core::input::Input; use hurl_core::typing::Count; #[test] fn simple_sample() { // The purpose of the check_* functions: // - assert against hard coded values // - check that function parameters type are public through the hurl crate fn check_result(result: &HurlResult) { assert!(result.success); assert_eq!(result.cookies.len(), 0); assert_eq!(result.entries.len(), 1); assert!(result.duration.as_millis() < 1000); } fn check_entry(entry: &EntryResult) { assert_eq!(entry.entry_index, 1); assert_eq!(entry.calls.len(), 1); assert_eq!(entry.captures.len(), 1); assert_eq!(entry.asserts.len(), 3); // HTTP version + status code + implicit body assert_eq!(entry.errors.len(), 0); assert!(entry.transfer_duration.as_millis() < 1000); assert!(!entry.compressed); } fn check_call(_: &Call) {} fn check_request(request: &Request) { assert_eq!( request.url, Url::from_str("http://localhost:8000/hello").unwrap() ); assert_eq!(request.method, "GET"); let header_names = request .headers .iter() .map(|h| h.name.clone()) .collect::>(); assert!(header_names.contains(&"Accept".to_string())); assert!(header_names.contains(&"Host".to_string())); assert!(header_names.contains(&"User-Agent".to_string())); assert_eq!(request.body.len(), 0); } fn check_response(response: &Response) { assert_eq!(response.version, HttpVersion::Http11); assert_eq!(response.status, 200); assert_eq!(response.headers.len(), 6); let header_names = response .headers .iter() .map(|h| h.name.clone()) .collect::>(); assert!(header_names.contains(&"Connection".to_string())); assert!(header_names.contains(&"Content-Length".to_string())); assert!(header_names.contains(&"Content-Type".to_string())); assert!(header_names.contains(&"Date".to_string())); assert!(header_names.contains(&"Server".to_string())); // There are two 'Server' HTTP headers assert_eq!(response.body.len(), 12); assert!(response.duration < Duration::from_secs(1)); assert_eq!( response.url, Url::from_str("http://localhost:8000/hello").unwrap() ); assert!(response.certificate.is_none()); } let content = r#" GET http://localhost:8000/hello HTTP 200 [Captures] data: body `Hello World!` "#; let filename = Some(Input::new("foo.hurl")); // Define runner and logger options let runner_opts = RunnerOptionsBuilder::new() .aws_sigv4(None) .cacert_file(None) .compressed(false) .connect_timeout(Duration::from_secs(300)) .context_dir(&ContextDir::default()) .cookie_input_file(None) .fail_fast(false) .follow_location(false) .ignore_asserts(false) .insecure(false) .max_redirect(Count::Finite(10)) .no_proxy(None) .post_entry(None) .pre_entry(None) .proxy(None) .retry(None) .retry_interval(Duration::from_secs(1)) .timeout(Duration::from_secs(300)) .to_entry(None) .unix_socket(None) .user(None) .user_agent(None) .build(); let logger_opts = LoggerOptionsBuilder::new() .color(false) .verbosity(None) .build(); // Set variables let variables = VariableSet::new(); // Run the hurl file and check data: let result = runner::run( content, filename.as_ref(), &runner_opts, &variables, &logger_opts, ) .unwrap(); check_result(&result); let entry = result.entries.first().unwrap(); check_entry(entry); let call = entry.calls.first().unwrap(); check_call(call); let request = &call.request; check_request(request); let response = &call.response; check_response(response); } hurl-6.1.1/tests/server_cert_selfsigned.pem000064400000000000000000000024161046102023000172010ustar 00000000000000-----BEGIN CERTIFICATE----- MIIDjTCCAnWgAwIBAgIUHuixfxtk2Naz3ocBA9Kk9TNTWrAwDQYJKoZIhvcNAQEL BQAwVjELMAkGA1UEBhMCVVMxDzANBgNVBAgMBkRlbmlhbDEUMBIGA1UEBwwLU3By aW5nZmllbGQxDDAKBgNVBAoMA0RpczESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTIz MDExMDA4Mjk1MloXDTI1MTAzMDA4Mjk1MlowVjELMAkGA1UEBhMCVVMxDzANBgNV BAgMBkRlbmlhbDEUMBIGA1UEBwwLU3ByaW5nZmllbGQxDDAKBgNVBAoMA0RpczES MBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC AQEA2AkdTXgVgU6FuQjQjYYLJ4rVhhZHkC94+XwoDK25R07thCNjE0Fw8s8yRAz2 AwcQH0GGHr5fJO+oqD3tVDJHhtzaKCUebAgwUnAFIiE67qpir/kEYh+H4XDhrNvi 8goc1Xb5gfgUcD056IMfUDuteAUcvps56zb3M47sW8/WPAoRJefxC77UtccgDrnI IxT6hJSBv58zK21Ws50X8EVZXnlOxNGrSDZPNnv6+UadP8szrjbuYw7sdzImOjMB XHF7+wd7t/2n0iop+nd0i4St2leONLHmTsNRI4PhWy+f5MLr7+uxZB8gBN/KbtE0 Ahy2RVlLPDvcnhFkGVFgzJEvMQIDAQABo1MwUTAdBgNVHQ4EFgQUM24E9nTNu0Gr 9MMcwDDQT2staXAwHwYDVR0jBBgwFoAUM24E9nTNu0Gr9MMcwDDQT2staXAwDwYD VR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEATncyirLM2hRDdogwlY1m 4cj5juYMZb3OoLW3PvMr3xHmD7x4mh1RtYEysw+ue5XNkmxR6lZOOEXfa5WKLtjY o0SeXHNFryaOgsqzjUheieMJqYdpYGhdho9KqshZEACQeSEuzu9fH6lrzJei1nzB rF6PfR7nvQBZCtNhuCP4Wbu/8cM9QScZAT/MiQ6p7uGx8j49/givi1rKtB0d4UW6 iZFDoLuG4aAlWiqoZ+M1rv/1tXVqtZXwfxehkfDzOGoNcjhDpPIoEXK32VX6C7D0 xeBlgImjzDTo/kOaDOMOTIYvrotu2q8HiRMrMGkFWirLjzRT+5X6GLI1kzsBp8pw 6Q== -----END CERTIFICATE-----