diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..2f54bc3 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "tests/data/vcard-personal-portfolio"] + path = tests/data/vcard-personal-portfolio + url = https://github.com/codewithsadee/vcard-personal-portfolio diff --git a/.vscode/settings.json b/.vscode/settings.json index c7bd1d1..7a3e930 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,5 @@ "editor.formatOnSave": true, "cSpell.words": [ "etag" - ] -} \ No newline at end of file + ], +} diff --git a/Cargo.lock b/Cargo.lock index d8b8605..0bb251c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 3 [[package]] name = "addr2line" -version = "0.21.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678" dependencies = [ "gimli", ] @@ -17,89 +17,124 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" -[[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.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91429305e9f0a25f6205c5b8e0d2db09e0708a7a6df0f42212bb56c32c8ac97a" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ - "cfg-if", + "getrandom", "once_cell", "version_check", - "zerocopy", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[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 = "anstream" -version = "0.6.4" +version = "0.6.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab91ebe16eb252986481c5b62f6098f3b698a45e34b5b98200cf20dd2484a44" +checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", + "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" -version = "1.0.4" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7079075b41f533b8c61d2a4d073c4676e1f8b249ff94a393b0595db304e0dd87" +checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" [[package]] name = "anstyle-parse" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317b9a89c1868f5ea6ff1d9539a69f45dffc21ce321ac1fd1160dfa48c8e2140" +checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" +checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "anstyle-wincon" -version = "3.0.1" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0699d10d2f4d628a98ee7b57b289abbc98ff3bad977cb3152709d4bf2330628" +checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" dependencies = [ "anstyle", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] name = "anyhow" -version = "1.0.75" +version = "1.0.86" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" + +[[package]] +name = "async-compression" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd066d0b4ef8ecb03a55319dc13aa6910616d0f44008a045bb1835af830abff5" +dependencies = [ + "brotli", + "flate2", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.1.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" [[package]] name = "backtrace" -version = "0.3.69" +version = "0.3.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a" dependencies = [ "addr2line", "cc", @@ -110,6 +145,12 @@ dependencies = [ "rustc-demangle", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -118,9 +159,21 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.4.1" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "327762f6e5a765692301e5bb513e0d9fef63be86bbc14528052b1cd3e6f03e07" +checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] [[package]] name = "block-buffer" @@ -132,20 +185,66 @@ dependencies = [ ] [[package]] -name = "bytes" -version = "1.5.0" +name = "brotli" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" +checksum = "74f7971dbd9326d58187408ab83117d8ac1bb9c17b085fdacd1cf2f598719b6b" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] [[package]] -name = "cc" -version = "1.0.83" +name = "brotli-decompressor" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +checksum = "9a45bd2e4095a8b518033b128020dd4a55aab1c0a381ba4404a472630f4bc362" dependencies = [ - "libc", + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", ] +[[package]] +name = "bytes" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9" + +[[package]] +name = "cc" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74b6a57f98764a267ff415d50a25e6e166f3831a5071af4995296ea97d210490" + [[package]] name = "cfg-if" version = "1.0.0" @@ -154,18 +253,19 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.4.7" +version = "4.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac495e00dcec98c83465d5ad66c5c4fabd652fd6686e7c6269b117e729a6f17b" +checksum = "84b3edb18336f4df585bc9aa31dd99c036dfa5dc5e9a2939a722a188f3a8970d" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] name = "clap_builder" -version = "4.4.7" +version = "4.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c77ed9a32a62e6ca27175d00d29d05ca32e396ea1eb5fb01d8256b669cec7663" +checksum = "c1c09dd5ada6c6c78075d6fd0da3f90d8080651e2d6cc8eb2f1aaa4034ced708" dependencies = [ "anstream", "anstyle", @@ -173,52 +273,70 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_derive" +version = "4.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bac35c6dafb060fd4d275d9a4ffae97917c13a6327903a8be2153cd964f7085" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.68", +] + [[package]] name = "clap_lex" -version = "0.6.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1" +checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70" [[package]] name = "colorchoice" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" [[package]] name = "colored" -version = "2.0.4" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2674ec482fbc38012cf31e6c42ba0177b431a0cb6f15fe40efa5aab1bda516f6" +checksum = "cbf2150cce219b664a8a70df7a1f933836724b503f8a413af9365b4dcc4d90b8" dependencies = [ - "is-terminal", "lazy_static", "windows-sys 0.48.0", ] [[package]] -name = "core2" -version = "0.4.0" +name = "core-foundation" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ - "memchr", + "core-foundation-sys", + "libc", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + [[package]] name = "cpufeatures" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce420fe07aecd3e67c5f910618fe65e94158f6dcc0adf44e00d69ce2bdfe0fd0" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.3.2" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" dependencies = [ "cfg-if", ] @@ -233,17 +351,11 @@ dependencies = [ "typenum", ] -[[package]] -name = "dary_heap" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7762d17f1241643615821a8455a0b2c3e803784b058693d990b11f2dce25a0ca" - [[package]] name = "deranged" -version = "0.3.9" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f32d04922c60427da6f9fef14d042d9edddef64cb9d4ce0d64d0685fbeb1fd3" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", ] @@ -260,18 +372,49 @@ dependencies = [ [[package]] name = "either" -version = "1.9.0" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "encoding_rs" +version = "0.8.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b45de904aa0b010bce2ab45264d0631681847fa7b6f2eaa7dab7619943bc4f59" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] name = "errno" -version = "0.3.6" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c18ee0ed65a5f1f81cac6b1d213b69c35fa47d4252ad41f1486dbd8226fe36e" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a" + +[[package]] +name = "flate2" +version = "1.0.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f54427cfd1c7829e2a139fcefea601bf088ebca651d2bf53ebc600eac295dae" +dependencies = [ + "crc32fast", + "miniz_oxide", ] [[package]] @@ -280,43 +423,123 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futures" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff4dd66668b557604244583e3e1e1eada8c5c2e96a6d0d6653ede395b78bbacb" +checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" dependencies = [ "futures-core", + "futures-sink", ] [[package]] name = "futures-core" -version = "0.3.29" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-executor" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb1d22c66e66d9d72e1758f0bd7d4fd0bee04cad842ee34587d68c07e45d088c" +checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" + +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.68", +] [[package]] name = "futures-sink" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e36d3378ee38c2a36ad710c5d30c2911d752cb941c00c72dbabfb786a7970817" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" [[package]] name = "futures-task" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd193069b0ddadc69c46389b740bbccdd97203899b48d09c5f7969591d6bae2" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" [[package]] name = "futures-util" -version = "0.3.29" +version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19526d624e703a3179b3d322efec918b6246ea0fa51d41124525f00f1cc8104" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", + "slab", ] [[package]] @@ -329,23 +552,34 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "gimli" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fb8d784f27acf97159b40fc4db5ecd8aa23b9ad5ef69cdd136d3bc80665f0c0" +checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd" [[package]] name = "h2" -version = "0.3.21" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91fc23aa11be92976ef4729127f1a74adf36d8436f7816b185d18df956790833" +checksum = "fa82e28a107a8cc405f0839610bdc9b15f1e25ec7d696aa5cf173edbcb1486ab" dependencies = [ + "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "futures-util", "http", "indexmap", "slab", @@ -359,27 +593,39 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] [[package]] name = "hashbrown" -version = "0.13.2" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" -dependencies = [ - "ahash", -] +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.3.3" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" [[package]] name = "http" -version = "0.2.9" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd6effc99afb63425aff9b05836f029929e345a6148a14b7ecd5ab67af944482" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" dependencies = [ "bytes", "fnv", @@ -388,20 +634,32 @@ dependencies = [ [[package]] name = "http-body" -version = "0.4.5" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5f38f16d184e36f2408a55281cd658ecbd3ca05cce6d6510a176eca393e26d1" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", + "futures-util", "http", + "http-body", "pin-project-lite", ] [[package]] name = "httparse" -version = "1.8.0" +version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" +checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" [[package]] name = "httpdate" @@ -411,13 +669,12 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "0.14.27" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffb1cfd654a8219eaef89881fdb3bb3b1cdc5fa75ded05d6933b2b382e395468" +checksum = "c4fe55fb7a772d59a5ff1dfbff4fe0258d19b89fec4b233e75d35d5d2316badc" dependencies = [ "bytes", "futures-channel", - "futures-core", "futures-util", "h2", "http", @@ -426,105 +683,167 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "socket2 0.4.10", + "smallvec", "tokio", - "tower-service", - "tracing", "want", ] [[package]] -name = "indexmap" -version = "1.9.3" +name = "hyper-rustls" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +checksum = "5ee4be2c948921a1a5320b629c4193916ed787a7f7f293fd3f7f5a6c9de74155" dependencies = [ - "autocfg", - "hashbrown 0.12.3", + "futures-util", + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", ] [[package]] -name = "is-terminal" -version = "0.4.9" +name = "hyper-tls" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ - "hermit-abi", - "rustix", - "windows-sys 0.48.0", + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", ] [[package]] -name = "itertools" -version = "0.11.0" +name = "hyper-util" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +checksum = "3ab92f4f49ee4fb4f997c784b7a2e0fa70050211e0b6a287f898c3c9785ca956" dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af150ab688ff2122fcef229be89cb50dd66af9e01a4ff320cc137eecc9bacc38" - -[[package]] -name = "keccak" -version = "0.1.4" + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "pin-project-lite", + "socket2", + "tokio", + "tower", + "tower-service", + "tracing", +] + +[[package]] +name = "idna" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f6d5ed8676d904364de097082f4e7d240b571b67989ced0240f08b7f966f940" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" dependencies = [ - "cpufeatures", + "unicode-bidi", + "unicode-normalization", ] [[package]] -name = "lazy_static" -version = "1.4.0" +name = "include_bytes_aligned" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "e04cb7c34f1605722a91ca2ddf1fd071a9ce4fe1ac82d57fe36437331c87ec3b" [[package]] -name = "libc" -version = "0.2.150" +name = "indexmap" +version = "2.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" +checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +dependencies = [ + "equivalent", + "hashbrown 0.14.5", +] [[package]] -name = "libflate" -version = "2.0.0" +name = "ipnet" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7d5654ae1795afc7ff76f4365c2c8791b0feb18e8996a96adad8ffd7c3b2bf" +checksum = "8f518f335dce6725a761382244631d86cf0ccb2863413590b31338feb467f9c3" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" dependencies = [ - "adler32", - "core2", - "crc32fast", - "dary_heap", - "libflate_lz77", + "either", ] [[package]] -name = "libflate_lz77" -version = "2.0.0" +name = "itertools" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be5f52fb8c451576ec6b79d3f4deb327398bc05bbdbd99021a6e77a4c855d524" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ - "core2", - "hashbrown 0.13.2", - "rle-decode-fast", + "either", ] +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "js-sys" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + +[[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.155" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" + [[package]] name = "linux-raw-sys" -version = "0.4.11" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "969488b55f8ac402214f3f5fd243ebb7206cf82de60d3172994707a4bcc2b829" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "lock_api" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" dependencies = [ "autocfg", "scopeguard", @@ -532,15 +851,24 @@ dependencies = [ [[package]] name = "log" -version = "0.4.20" +version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" [[package]] name = "memchr" -version = "2.6.4" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "memmap2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167" +checksum = "fe751422e4a8caa417e13c3ea66452215d7d63e19e604f4980461212f3ae1322" +dependencies = [ + "libc", +] [[package]] name = "mime" @@ -550,9 +878,9 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mime_guess" -version = "2.0.4" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4192263c238a5f0d0c6bfd21f336a313a4ce1c450542449ca191bb657b4642ef" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" dependencies = [ "mime", "unicase", @@ -560,24 +888,47 @@ dependencies = [ [[package]] name = "miniz_oxide" -version = "0.7.1" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" +checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" dependencies = [ "adler", ] [[package]] name = "mio" -version = "0.8.9" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3dce281c5e46beae905d4de1870d8b1509a9142b62eedf18b443b011ca8343d0" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", "wasi", "windows-sys 0.48.0", ] +[[package]] +name = "native-tls" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8614eb2c83d59d1c8cc974dd3f920198647674a0a035e1af1fa58707e317466" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num_cpus" version = "1.16.0" @@ -590,33 +941,102 @@ dependencies = [ [[package]] name = "num_threads" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2819ce041d2ee131036f4fc9d6ae7ae125a3a40e97ba64d04fe799ad9dabbb44" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" dependencies = [ "libc", ] [[package]] name = "object" -version = "0.32.1" +version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf5f9dd3933bd50a9e1f149ec995f39ae2c496d31fd772c1fd45ebc27e902b0" +checksum = "081b846d1d56ddfc18fdf1a922e4f6e07a11768ea1b92dec44e42b72712ccfce" dependencies = [ "memchr", ] [[package]] name = "once_cell" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8b5dd2ae5ed71462c540258bedcb51965123ad7e7ccf4b9a8cafaa4a63576d" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "openssl" +version = "0.10.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a0481286a310808298130d22dd1fef0fa571e05a8f44ec801801e84b216b1f" +dependencies = [ + "bitflags 2.6.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.68", +] + +[[package]] +name = "openssl-probe" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" + +[[package]] +name = "openssl-sys" +version = "0.9.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c597637d56fbc83893a35eb0dd04b2b8e7a50c91e64e9493e398b5df4fb45fa2" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ouroboros" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "944fa20996a25aded6b4795c6d63f10014a7a83f8be9828a11860b08c5fc4a67" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39b0deead1528fd0e5947a8546a9642a9777c25f6e1e26f34c97b204bbb465bd" +dependencies = [ + "heck 0.4.1", + "itertools 0.12.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.68", +] [[package]] name = "parking_lot" -version = "0.12.1" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" dependencies = [ "lock_api", "parking_lot_core", @@ -624,22 +1044,48 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.9" +version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets", + "windows-targets 0.52.5", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.68", ] [[package]] name = "pin-project-lite" -version = "0.2.13" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" [[package]] name = "pin-utils" @@ -647,6 +1093,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec" + [[package]] name = "powerfmt" version = "0.2.0" @@ -655,183 +1107,525 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "proc-macro2" -version = "1.0.69" +version = "1.0.86" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "134c189feb4956b20f6f547d2cf727d4c0fe06722b20a0eec87ed445a97f92da" +checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" dependencies = [ "unicode-ident", ] [[package]] -name = "quote" -version = "1.0.33" +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.68", + "version_check", + "yansi", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "redox_syscall" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c82cf8cff14456045f55ec4241383baeff27af886adb72ffb2162f99911de0fd" +dependencies = [ + "bitflags 2.6.0", +] + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d6d2a27d57148378eb5e111173f4276ad26340ecc5c49a4a2152167a2d6a37" +dependencies = [ + "async-compression", + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cba464629b3394fc4dbc6f940ff8f5b4ff5c7aef40f29166fd4ad12acbc99c0" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7dddfff8de25e6f62b9d64e6e432bf1c6736c57d20323e15ee10435fbda7c65" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustix" +version = "0.38.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f" +dependencies = [ + "bitflags 2.6.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05cff451f60db80f490f3c182b77c35260baace73209e9cdbbe526bfe3a4d402" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29993a25686778eb88d4189742cd713c9bce943bc54251a33509dc63cbacf73d" +dependencies = [ + "base64", + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976295e77ce332211c0d24d92c0e83e50f5c5f046d11082cea19f3df13a3562d" + +[[package]] +name = "rustls-webpki" +version = "0.102.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff448f7e92e913c4b7d4c6d8e4540a1724b319b4152b8aef6d4cf8339712b33e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbc91545643bcf3a0bbb6569265615222618bdf33ce4ffbbd13c4bbd4c093534" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c627723fd09706bacdb5cf41499e95098555af3c3c29d014dc3c458ef6be11c0" +dependencies = [ + "bitflags 2.6.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317936bbbd05227752583946b9e66d7ce3b489f84e11a94a510b4437fef407d7" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.203" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.203" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.68", +] + +[[package]] +name = "serde_json" +version = "1.0.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest", + "keccak", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5267fca4496028628a95160fc423a33e8b2e6af8a5302579e322e4b520293cae" +checksum = "a9e9e0b4211b72e7b8b6e85c807d36c212bdb33ea8587f7569562a84df5465b1" dependencies = [ - "proc-macro2", + "libc", ] [[package]] -name = "redox_syscall" -version = "0.4.1" +name = "simdutf8" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a" + +[[package]] +name = "simple_logger" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +checksum = "e8c5dfa5e08767553704aa0ffd9d9794d527103c736aba9854773851fd7497eb" dependencies = [ - "bitflags 1.3.2", + "colored", + "log", + "time", + "windows-sys 0.48.0", ] [[package]] -name = "rle-decode-fast" -version = "1.0.3" +name = "slab" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] [[package]] -name = "rustc-demangle" -version = "0.1.23" +name = "smallvec" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" [[package]] -name = "rustix" -version = "0.38.21" +name = "socket2" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b426b0506e5d50a7d8dafcf2e81471400deb602392c7dd110815afb4eaf02a3" +checksum = "ce305eb0b4296696835b71df73eb912e0f1ffd2556a501fcede6e0c50349191c" dependencies = [ - "bitflags 2.4.1", - "errno", "libc", - "linux-raw-sys", - "windows-sys 0.48.0", + "windows-sys 0.52.0", ] [[package]] -name = "same-file" -version = "1.0.6" +name = "spin" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" [[package]] -name = "scopeguard" -version = "1.2.0" +name = "static_assertions" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] -name = "serde" -version = "1.0.192" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bca2a08484b285dcb282d0f67b26cadc0df8b19f8c12502c13d966bf9482f001" -dependencies = [ - "serde_derive", -] +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] -name = "serde_derive" -version = "1.0.192" +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6c7207fbec9faa48073f3e3074cbe553af6ea512d7c21ba46e434e70ea9fbc1" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", "quote", - "syn", + "unicode-ident", ] [[package]] -name = "sha3" -version = "0.10.8" +name = "syn" +version = "2.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +checksum = "901fa70d88b9d6c98022e23b4136f9f3e54e4662c3bc1bd1d84a42a9a0f0c1e9" dependencies = [ - "digest", - "keccak", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "signal-hook-registry" -version = "1.4.1" +name = "sync_wrapper" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" -dependencies = [ - "libc", -] +checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" [[package]] -name = "simple_logger" -version = "4.2.0" +name = "system-configuration" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2230cd5c29b815c9b699fb610b49a5ed65588f3509d9f0108be3a885da629333" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ - "colored", - "log", - "time", - "windows-sys 0.42.0", + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", ] [[package]] -name = "slab" -version = "0.4.9" +name = "system-configuration-sys" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" dependencies = [ - "autocfg", + "core-foundation-sys", + "libc", ] [[package]] -name = "smallvec" -version = "1.11.1" +name = "tap" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "942b4a808e05215192e39f4ab80813e599068285906cc91aa64f923db842bd5a" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] -name = "socket2" -version = "0.4.10" +name = "tempfile" +version = "3.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d" +checksum = "85b77fafb263dd9d05cbeac119526425676db3784113aa9295c88498cbf8bff1" dependencies = [ - "libc", - "winapi", + "cfg-if", + "fastrand", + "rustix", + "windows-sys 0.52.0", ] [[package]] -name = "socket2" -version = "0.5.5" +name = "test-case" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" dependencies = [ - "libc", - "windows-sys 0.48.0", + "test-case-macros", ] [[package]] -name = "strsim" -version = "0.10.0" +name = "test-case-core" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.68", +] [[package]] -name = "syn" -version = "2.0.39" +name = "test-case-macros" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23e78b90f2fcf45d3e842032ce32e3f2d1545ba6636271dcbf24fa306d87be7a" +checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" dependencies = [ "proc-macro2", "quote", - "unicode-ident", + "syn 2.0.68", + "test-case-core", ] [[package]] name = "time" -version = "0.3.30" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4a34ab300f2dee6e562c10a046fc05e358b29f9bf92277f30c3c8d82275f6f5" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", "itoa", "libc", + "num-conv", "num_threads", "powerfmt", "serde", @@ -847,18 +1641,34 @@ checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.15" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ad70d68dba9e1f8aceda7aa6711965dfec1cac869f311a51bd08b3a2ccbce20" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" dependencies = [ + "num-conv", "time-core", ] +[[package]] +name = "tinyvec" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c55115c6fbe2d2bef26eb09ad74bde02d8255476fc0c7b515ef09fbb35742d82" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" -version = "1.33.0" +version = "1.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f38200e3ef7995e5ef13baec2f432a6da0aa9ac495b2c0e8f3b7eec2c92d653" +checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a" dependencies = [ "backtrace", "bytes", @@ -868,36 +1678,77 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.5.5", + "socket2", "tokio-macros", "windows-sys 0.48.0", ] [[package]] name = "tokio-macros" -version = "2.1.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630bdcf245f78637c13ec01ffae6187cca34625e8c63150d424b59e55af2675e" +checksum = "5f5ae998a069d4b5aba8ee9dad856af7d520c3699e6159b185c2acd48155d39a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.68", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +dependencies = [ + "rustls", + "rustls-pki-types", + "tokio", ] [[package]] name = "tokio-util" -version = "0.7.10" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15" +checksum = "9cf6b47b3771c49ac75ad09a6162f53ad4b8088b76ac60e8ec1455b31a189fe1" dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio", - "tracing", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0" + [[package]] name = "tower-service" version = "0.3.2" @@ -925,9 +1776,9 @@ dependencies = [ [[package]] name = "try-lock" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3528ecfd12c466c6f163363caf2d02a71161dd5e1cc6ae7b34207ea2d42d81ed" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" @@ -944,17 +1795,61 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + [[package]] name = "unicode-ident" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" +[[package]] +name = "unicode-normalization" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + [[package]] name = "utf8parse" -version = "0.2.1" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +checksum = "5de17fd2f7da591098415cff336e12965a28061ddace43b59cb3c430179c9439" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" @@ -964,9 +1859,9 @@ checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" [[package]] name = "walkdir" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ "same-file", "winapi-util", @@ -987,89 +1882,168 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" +[[package]] +name = "wasm-bindgen" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn 2.0.68", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76bc14366121efc8dbb487ab05bcc9d346b3b5ec0eaa76e46594cabbe51762c0" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.68", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" + [[package]] name = "web-static-pack" -version = "0.4.4" +version = "0.5.0-beta.1" dependencies = [ "anyhow", "http", "http-body", + "http-body-util", "hyper", - "lazy_static", - "log", - "simple_logger", + "rkyv", + "test-case", "tokio", + "web-static-pack-common", +] + +[[package]] +name = "web-static-pack-common" +version = "0.5.0-beta.1" +dependencies = [ + "rkyv", ] [[package]] name = "web-static-pack-packer" -version = "0.1.6" +version = "0.5.0-beta.1" dependencies = [ "anyhow", - "bytes", + "brotli", "clap", - "itertools", - "libflate", - "log", + "flate2", + "itertools 0.13.0", "mime_guess", + "rkyv", "sha3", - "simple_logger", + "test-case", "walkdir", + "web-static-pack-common", ] [[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +name = "web-static-pack-tests" +version = "0.5.0-beta.1" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "anyhow", + "futures", + "http", + "hyper", + "hyper-util", + "include_bytes_aligned", + "log", + "memmap2", + "ouroboros", + "reqwest", + "simple_logger", + "test-case", + "tokio", + "web-static-pack", + "web-static-pack-common", + "web-static-pack-packer", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" +name = "web-sys" +version = "0.3.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +checksum = "77afa9a11836342370f4817622a2f0f418b134426d91a82dfb48f532d2ec13ef" +dependencies = [ + "js-sys", + "wasm-bindgen", +] [[package]] name = "winapi-util" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596" +checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" dependencies = [ - "winapi", + "windows-sys 0.52.0", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-sys" -version = "0.42.0" +version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", + "windows-targets 0.48.5", ] [[package]] name = "windows-sys" -version = "0.48.0" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.5", ] [[package]] @@ -1088,10 +2062,20 @@ dependencies = [ ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" +name = "windows-targets" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +dependencies = [ + "windows_aarch64_gnullvm 0.52.5", + "windows_aarch64_msvc 0.52.5", + "windows_i686_gnu 0.52.5", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.5", + "windows_x86_64_gnu 0.52.5", + "windows_x86_64_gnullvm 0.52.5", + "windows_x86_64_msvc 0.52.5", +] [[package]] name = "windows_aarch64_gnullvm" @@ -1100,10 +2084,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" [[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" +name = "windows_aarch64_gnullvm" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" [[package]] name = "windows_aarch64_msvc" @@ -1112,10 +2096,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" [[package]] -name = "windows_i686_gnu" -version = "0.42.2" +name = "windows_aarch64_msvc" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" [[package]] name = "windows_i686_gnu" @@ -1124,10 +2108,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" [[package]] -name = "windows_i686_msvc" -version = "0.42.2" +name = "windows_i686_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" [[package]] name = "windows_i686_msvc" @@ -1136,10 +2126,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" [[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" +name = "windows_i686_msvc" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" [[package]] name = "windows_x86_64_gnu" @@ -1148,10 +2138,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" [[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" +name = "windows_x86_64_gnu" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" [[package]] name = "windows_x86_64_gnullvm" @@ -1160,10 +2150,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" [[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" +name = "windows_x86_64_gnullvm" +version = "0.52.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" [[package]] name = "windows_x86_64_msvc" @@ -1172,21 +2162,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" [[package]] -name = "zerocopy" -version = "0.7.25" +name = "windows_x86_64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" + +[[package]] +name = "winreg" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cd369a67c0edfef15010f980c3cbe45d7f651deac2cd67ce097cd801de16557" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" dependencies = [ - "zerocopy-derive", + "cfg-if", + "windows-sys 0.48.0", ] [[package]] -name = "zerocopy-derive" -version = "0.7.25" +name = "wyz" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2f140bda219a26ccc0cdb03dba58af72590c53b22642577d88a927bc5c87d6b" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" dependencies = [ - "proc-macro2", - "quote", - "syn", + "tap", ] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/Cargo.toml b/Cargo.toml index 622b494..59a6b5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] -members = ["loader", "packer"] +members = ["common", "loader", "packer", "tests"] resolver = "2" diff --git a/README.md b/README.md index f17c715..f3c2d7f 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,100 @@ # web-static-pack -Embed static resources (GUI, assets, images, styles, html) within executable. -Serve with hyper or any server of your choice. - -[![docs.rs](https://docs.rs/web-static-pack/badge.svg)](https://docs.rs/web-static-pack) - -## Usage scenario: -- Combines given directory tree into single, fast, binary-based single-file representation, called `pack`. Use simple CLI tool `web-static-pack-packer` to create a pack. -- Pack could be embedded into your application using `include_bytes!` single macro. -- Super-fast, zero-copy `loader` provides by-name access to files. -- Easy-to-use `hyper_loader` allows super-quick integration with hyper-based server. - -## Features: -- Super fast, low overhead -- 100% 'static access, zero data copy -- 100% pack-time calculated `Content-Type`, `ETag` (using sha3) -- 100% pack-time calculated gzip-compressed files -- Almost no external dependencies - -## Limitations: -- By default all files with guesses text/ content type are treated as utf-8 -- Packs are not guaranteed to be portable across versions / architectures - -## Future goals: -- You tell me - -## Non-Goals: -- Directory listings -- automatic index.html resolving -- Uploads - -## Example: -1. Create a pack from `cargo doc`: +web-static-pack is a set of tools for embedding static resources (GUI, assets, images, styles, html) inside your app, to be later served with a http server of your choice (like `hyper`). + +It consists of two parts: +- [web-static-pack-packer](https://crates.io/crates/web-static-pack-packer) (aka "packer") - a standalone application (can be used as a library) used to serialize your assets into single file, called `pack`. It will usually be used before you build your target application (eg. in build script / CI / build.rs). During creation of a `pack` all heavy computations are done, eg. calculating `ETag`, compressed (`gzip`, `brotli`) versions, mime guessing etc. As a result a `pack` file is created, to be used by the next part. +- [web-static-pack](https://github.com/peku33/web-static-pack) (aka "loader") - a library to include in your target application that will read the `pack` (preferably included in the application with ). Then `pack` can be used to form a `http` `service` (a function taking a request and returning response) serving files from the `pack`. + +## Features +- Precomputed (in "packer") `ETag` (using `sha3`), compressed bodies (in `gzip` and `brotli` formats), `content-type`, etc. This reduces both runtime overhead and dependencies of your target application. +- Zero-copy deserialization of a `pack` thanks to [rkyv](https://crates.io/crates/rkyv), allows the pack to be read directly from program memory, without allocating additional ram for pack contents. +- `GET`/`HEAD` http methods support, `ETag`/`if-none-match` support, `accept-encoding`/`content-encoding` negotiation, `cache-control`, `content-length` etc. + +### Non goals +- Directory listings. +- index.html resolving. + +### Limitations +- `pack` is not portable across crate versions / architectures. +- Text files `text/*` are assumed to be utf-8 encoded. + +## Examples +For this example lets assume you are building api + gui application in rust. Your gui is pre-built (like with `npm build` or similar) in `./vcard-personal-portfolio` directory (available for real in `tests/data/`) and you want to serve it from `/` of your app. + +### Packing your assets +Refer to [web-static-pack-packer](https://crates.io/crates/web-static-pack-packer) for full documentation. + +To pack whole `./vcard-personal-portfolio` directory into `./vcard-personal-portfolio.pack` execute the following command: ```bash -$ cargo doc --no-deps -$ cargo run ./target/doc/ docs.pack +$ web-static-pack-packer \ + directory-single \ + ./vcard-personal-portfolio \ + ./vcard-personal-portfolio.pack ``` - -2. Serve docs.pack from your web-application (see `examples/docs`) + +This will create a `./vcard-personal-portfolio.pack` file, containing all your files combined, ready to be included in your target application. + +### Serving from your target application +Refer to [web-static-pack](https://github.com/peku33/web-static-pack) for full example. + +You will need to include the pack in your executable with . Then pack needs to be loaded from this binary slice. At the end we construct a http service, that will serve our requests. + ```rust -use anyhow::{Context, Error}; -use hyper::{ - service::{make_service_fn, service_fn}, - Body, Request, Response, Server, -}; -use lazy_static::lazy_static; -use log::LevelFilter; -use simple_logger::SimpleLogger; -use std::{convert::Infallible, include_bytes, net::SocketAddr}; -use web_static_pack::{hyper_loader::Responder, loader::Loader}; - -#[tokio::main] -async fn main() { - SimpleLogger::new() - .env() - .with_level(LevelFilter::Info) - .init() - .unwrap(); - main_result().await.unwrap() -} +use include_bytes_aligned::include_bytes_aligned; -async fn service(request: Request) -> Result, Infallible> { - lazy_static! { - static ref PACK: &'static [u8] = include_bytes!("docs.pack"); - static ref LOADER: Loader = Loader::new(&PACK).unwrap(); - static ref RESPONDER: Responder<'static> = Responder::new(&LOADER); - } +static PACK_ARCHIVED_SERIALIZED: &[u8] = + include_bytes_aligned!(16, "vcard-personal-portfolio.pack"); - Ok(RESPONDER.request_respond(&request)) -} +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Error> { + // load (map / cast) [common::pack::PackArchived] from included bytes + let pack_archived = unsafe { load(PACK_ARCHIVED_SERIALIZED).unwrap() }; + + // create a responder (http service) from `pack` + let responder = Responder::new(pack_archived); -async fn main_result() -> Result<(), Error> { - let address = SocketAddr::from(([0, 0, 0, 0], 8080)); - let server = Server::bind(&address).serve(make_service_fn(|_connection| async { - Ok::<_, Infallible>(service_fn(service)) - })); + // hyper requires service to be static + // we use graceful, no connections will outlive server function + let responder = unsafe { + transmute::< + &Responder<'_, _>, + &Responder<'static, _>, + >(&responder) + }; - log::info!("Server listening on {:?}", address); - server.await.context("server")?; + // make hyper service function + let service_fn = service_fn(|request: Request| async { + // you can probably filter your /api requests here + let (parts, _body) = request.into_parts(); + let response = responder.respond_flatten(parts); + Ok::<_, Infallible>(response) + }); + + // run hyper server using service_fn, as in: + // https://hyper.rs/guides/1/server/graceful-shutdown/ + todo!(); Ok(()) } ``` -# web-static-pack-packer -Executable to build packs for web-static-pack crate -See main crate for details +## Migrating from 0.4.x to 0.5.x +The 0.5.0 is almost a complete rewrite, however the general idea remains the same. +- We still have two parts - packer and loader. There is also a `common` crate and `tests` crate, however they are not meant to be used directly. +- Lots of internals were changed, including [rkyv](https://crates.io/crates/rkyv) for serialization / zero-copy deserialization. This of course makes packs built with previous versions incompatible with current loader and vice versa. +- We are now built around [http](https://crates.io/crates/http) crate, which makes web-static-pack compatible with hyper 1.0 without depending on it directly. -[![docs.rs](https://docs.rs/web-static-pack-packer/badge.svg)](https://docs.rs/web-static-pack-packer) +### BREAKING CHANGES +- Packer is now built around subcommands. The previous behavior was moved to `directory-single` subcommand, and `root_path` parameter was dropped. See examples. +- Since we no longer depend on `hyper` in any way (the `http` crate is common interface), `hyper_loader` feature is no longer present in loader. +- `let loader = loader::Loader::new(...)` is now `let pack_archived = loader::load(...)`. This value is still used for `Responder::new`. +- `hyper_loader::Responder` is now just `responder::Responder`, and it's now built around `http` crate, compatible with `hyper` 1.0. +- `Responder` was rewritten. It now accepts request parts (without body) not whole request. `request_respond_or_error` and `parts_respond_or_error` are now `respond`. `request_respond` and `parts_respond` are now `respond_flatten`. -## Usage -1. Install (`cargo install web-static-pack-packer`) or run locally (`cargo run`) -2. Provide positional arguments: - - `` - the directory to pack - - `` - name of the build pack - - `[root_pach]` - relative path to build pack paths with. use the same as `path` to have all paths in pack root -3. Use `` file with `web-static-pack` (loader) +### New features and improvements +- True zero-copy deserialization with `rkyv`. +- `brotli` compression support. +- `cache-control` support. +- Packer is now a lib + bin, making it usable in build.rs. Multiple useful methods were exposed. +- Good test coverage, including integration tests. +- Lots of internal improvements. diff --git a/common/Cargo.toml b/common/Cargo.toml new file mode 100644 index 0000000..88cd798 --- /dev/null +++ b/common/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "web-static-pack-common" +version = "0.5.0-beta.1" +authors = ["Paweł Kubrak "] +edition = "2021" +license = "MIT" +description = "Common types for web-static-pack and web-static-pack-packer crates." +homepage = "https://github.com/peku33/web-static-pack" +repository = "https://github.com/peku33/web-static-pack" +readme = "README.md" +keywords = ["web", "http", "static", "resources", "hyper"] +categories = ["web-programming"] + +[dependencies] +rkyv = { version = "0.7.44" } # features = ["copy", "copy_unsafe"] diff --git a/common/README.md b/common/README.md new file mode 100644 index 0000000..c8c8e60 --- /dev/null +++ b/common/README.md @@ -0,0 +1,25 @@ +# web-static-pack-common + +Common crate, containing types shared between +[web-static-pack](https://crates.io/crates/web-static-pack) and +[web-static-pack-packer](https://crates.io/crates/web-static-pack-packer). + +For a project documentation, examples, etc. see +[web-static-pack](https://github.com/peku33/web-static-pack). + +The root type of this crate is [pack::Pack]. It's a collection (a hashmap) +of files [file::File] distinguished by [pack_path::PackPath] (a custom type +for path including some sanity checks). + +web-static-pack uses [rkyv] for serialization. Each module provides a rust +native type, used during `pack` building, ex. [pack::Pack] and [rkyv] +macro-generated zero-copy loadable (aka. mmapable) representation, eg. +[pack::PackArchived], used by loader. + +#### Note + +There are also things called `Resolver` (eg. [pack::PackResolver]), that are +needed internally by [rkyv], but are not used directly in this project. They +should be hidden from docs. + +License: MIT diff --git a/common/src/cache_control.rs b/common/src/cache_control.rs new file mode 100644 index 0000000..4f43578 --- /dev/null +++ b/common/src/cache_control.rs @@ -0,0 +1,17 @@ +//! Cache control types used by file. + +use rkyv::{Archive, Serialize}; + +/// Type representing cache control of a file. This will correspond to +/// `cache-control` header set in http response. +#[derive(Archive, Serialize, Clone, Copy, PartialEq, Eq, Debug)] +#[archive(archived = "CacheControlArchived")] +#[archive_attr(derive(Clone, Copy, PartialEq, Eq, Debug))] +pub enum CacheControl { + /// No caching. This corresponds to "cache never" strategy, ex. by setting + /// `no-cache` header value. + NoCache, + /// Max caching. This corresponds to "cache forever" strategy, ex. by + /// setting `max-age=31536000, immutable` header value. + MaxCache, +} diff --git a/common/src/file.rs b/common/src/file.rs new file mode 100644 index 0000000..0f137cb --- /dev/null +++ b/common/src/file.rs @@ -0,0 +1,31 @@ +//! File represents single item of a Pack, accessible under specific path. + +use crate::cache_control::CacheControl; +use rkyv::{Archive, Serialize}; + +/// [File] represents an original file from filesystem with all fields +/// precalculated. It contains `gzip` / `brotli` compressed content, +/// precalculated http headers, like `content-type`, `ETag` and `cache-control`. +/// +/// [File] is created in packing phase (once) to allow fast loading in loader +/// without need to perform expensive computations (like calculating compressed +/// forms) in runtime. +#[derive(Archive, Serialize, Debug)] +#[archive(archived = "FileArchived")] +#[archive_attr(derive(Debug))] +pub struct File { + /// Raw (not compressed) file contents. + pub content: Box<[u8]>, + /// Gzip compressed file contents, if provided, otherwise None. + pub content_gzip: Option>, + /// Brotli compressed file contents, if provided, otherwise None. + pub content_brotli: Option>, + + /// `content-type` header contents for the file, eg. `text/html; + /// charset=utf-8` or `image/webp`. + pub content_type: String, + /// `ETag` header contents for the file, eg. checksum of `content`. + pub etag: String, + /// `cache-control` options for the file. + pub cache_control: CacheControl, +} diff --git a/common/src/lib.rs b/common/src/lib.rs new file mode 100644 index 0000000..7236921 --- /dev/null +++ b/common/src/lib.rs @@ -0,0 +1,34 @@ +//! Common crate, containing types shared between +//! [web-static-pack](https://crates.io/crates/web-static-pack) and +//! [web-static-pack-packer](https://crates.io/crates/web-static-pack-packer). +//! +//! For a project documentation, examples, etc. see +//! [web-static-pack](https://github.com/peku33/web-static-pack). +//! +//! The root type of this crate is [pack::Pack]. It's a collection (a hashmap) +//! of files [file::File] distinguished by [pack_path::PackPath] (a custom type +//! for path including some sanity checks). +//! +//! web-static-pack uses [rkyv] for serialization. Each module provides a rust +//! native type, used during `pack` building, ex. [pack::Pack] and [rkyv] +//! macro-generated zero-copy loadable (aka. mmapable) representation, eg. +//! [pack::PackArchived], used by loader. +//! +//! ### Note +//! +//! There are also things called `Resolver` (eg. [pack::PackResolver]), that are +//! needed internally by [rkyv], but are not used directly in this project. They +//! should be hidden from docs. + +#![warn(missing_docs)] + +pub mod cache_control; +pub mod file; +pub mod pack; +pub mod pack_path; + +/// File magic, used by loader to detect if content might not be a `pack`. +pub const PACK_FILE_MAGIC: u64 = 0x479a01809f24813c; +/// File version, used by loader to detect if loader and packer versions are +/// compatible. +pub const PACK_FILE_VERSION: u64 = 1; diff --git a/common/src/pack.rs b/common/src/pack.rs new file mode 100644 index 0000000..de9eed5 --- /dev/null +++ b/common/src/pack.rs @@ -0,0 +1,25 @@ +//! Pack is the root entity, a collection of files. + +use crate::{file::File, pack_path::PackPath}; +use rkyv::{Archive, Serialize}; +use std::collections::HashMap; + +/// Pack represents a group of files distinguished by their path. +/// +/// [Pack] is a bit like a `zip`, single entity containing directory/file tree. +/// [Pack] will usually be built with +/// [web-static-pack-packer](https://crates.io/crates/web-static-pack-packer) +/// crate, either with command line tool or by a script/build.rs. After [Pack] +/// is built, it will be serialized (called "Archived" by [rkyv] we use for that +/// purpose) into zero-copy deserializable representation - [PackArchived]. This +/// representation will be then included by your target program into binary (or +/// read/mmaped from fs) and served with +/// [web-static-pack](https://crates.io/crates/web-static-pack) +/// crate. +#[derive(Archive, Serialize, Debug)] +#[archive(archived = "PackArchived")] +#[archive_attr(derive(Debug))] +pub struct Pack { + /// List of contained files by their paths. + pub files_by_path: HashMap, +} diff --git a/common/src/pack_path.rs b/common/src/pack_path.rs new file mode 100644 index 0000000..71cf344 --- /dev/null +++ b/common/src/pack_path.rs @@ -0,0 +1,51 @@ +//! Pack path contains custom type for representing path inside a `pack`. + +use rkyv::{Archive, Serialize}; +use std::{borrow::Borrow, ops::Deref}; + +/// [PackPath] represents path inside a `pack`. It will correspond to http +/// request path (aka. uri/url) directly. +/// +/// Custom type is used to enforce some rules, eg. starts with "/", contains +/// only valid characters, etc. +#[derive(Archive, Serialize, PartialEq, Eq, Hash, Debug)] +#[archive(archived = "PackPathArchived")] +#[archive_attr(derive(PartialEq, Eq, Hash, Debug))] +pub struct PackPath { + inner: String, +} +impl PackPath { + /// Construct path from string representation. Refer to [self] for details. + /// Providing invalid path won't result in catastrophic failure, but + /// such will will never be resolved. + pub fn from_string(inner: String) -> Self { + Self { inner } + } +} + +// to allow searching in HashMap directly by http path (which is str) +impl Deref for PackPath { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} +impl Borrow for PackPath { + fn borrow(&self) -> &str { + self.inner.as_str() + } +} + +impl Deref for PackPathArchived { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} +impl Borrow for PackPathArchived { + fn borrow(&self) -> &str { + self.inner.as_str() + } +} diff --git a/loader/Cargo.toml b/loader/Cargo.toml index 7d62f72..54c6746 100644 --- a/loader/Cargo.toml +++ b/loader/Cargo.toml @@ -1,29 +1,26 @@ [package] name = "web-static-pack" -version = "0.4.4" +version = "0.5.0-beta.1" authors = ["Paweł Kubrak "] edition = "2021" -description = "Embed static resources (GUI, assets, images, styles, html) within executable. Serve with hyper or any server of your choice." license = "MIT" +description = "Embed static resources (GUI, assets, images, styles, html) within executable. Serve with hyper or any server of your choice." homepage = "https://github.com/peku33/web-static-pack" repository = "https://github.com/peku33/web-static-pack" -documentation = "https://docs.rs/web-static-pack" readme = "README.md" +keywords = ["web", "http", "static", "resources", "hyper"] +categories = ["web-programming"] [dependencies] -anyhow = "1.0.75" -log = "0.4.20" +web-static-pack-common = { version = "0.5.0-beta.1", path = "../common" } -# For hyper_loader -http = { version = "0.2.9", optional = true } -http-body = { version = "0.4.5", optional = true } -hyper = { version = "0.14.27", features = ["full"], optional = true } +anyhow = "1.0.86" +http = "1.1.0" +http-body = "1.0.0" +rkyv = { version = "0.7.44" } # features = ["copy", "copy_unsafe"] [dev-dependencies] -lazy_static = "1.4.0" -simple_logger = "4.2.0" -tokio = { version = "1.33.0", features = ["full"] } - -[features] -default = ["hyper_loader"] -hyper_loader = ["hyper", "http-body", "http"] +http-body-util = "0.1.2" +hyper = { version = "1.4.0", features = ["full"] } +test-case = "3.3.1" +tokio = { version = "1.38.0", features = ["full"] } diff --git a/loader/README.md b/loader/README.md index fa8a293..cf832a9 100644 --- a/loader/README.md +++ b/loader/README.md @@ -1,83 +1,96 @@ # web-static-pack -Embed static resources (GUI, assets, images, styles, html) within executable. -Serve with hyper or any server of your choice. - -[![docs.rs](https://docs.rs/web-static-pack/badge.svg)](https://docs.rs/web-static-pack) - -## Usage scenario: -- Combines given directory tree into single, fast, binary-based single-file representation, called `pack`. Use simple CLI tool `web-static-pack-packer` to create a pack. -- Pack could be embedded into your application using `include_bytes!` single macro. -- Super-fast, zero-copy `loader` provides by-name access to files. -- Easy-to-use `hyper_loader` allows super-quick integration with hyper-based server. - -## Features: -- Super fast, low overhead -- 100% 'static access, zero data copy -- 100% pack-time calculated `Content-Type`, `ETag` (using sha3) -- 100% pack-time calculated gzip-compressed files -- Almost no external dependencies - -## Limitations: -- By default all files with guesses text/ content type are treated as utf-8 -- Packs are not guaranteed to be portable across versions / architectures - -## Future goals: -- You tell me - -## Non-Goals: -- Directory listings -- automatic index.html resolving -- Uploads - -## Example: -1. Create a pack from `cargo doc`: -```bash -$ cargo doc --no-deps -$ cargo run ./target/doc/ docs.pack -``` - -2. Serve docs.pack from your web-application (see `examples/docs`) + +web-static-pack is the "loader" (2nd stage) part of the +[web-static-pack](https://github.com/peku33/web-static-pack) +project. See project page for a general idea how two parts cooperate. + +Once a `pack` is created with build script / CI / build.rs using +[web-static-pack-packer](https://crates.io/crates/web-static-pack-packer) +it will usually be included in your target application with +. +Then it will be loaded with a [loader::load], utilizing zero-copy +deserialization (so file contents will be sent from executable contents +directly). The pack is then possibly wrapped with [responder::Responder] +http service and used with a web server like hyper. + +The main part of this crate is [responder::Responder]. Its +[responder::Responder::respond_flatten] method makes a [http] service - a +function taking [http::Request] (actually the [http::request::Parts], as we +don't need the body) and returning [http::Response]. + +To make a [responder::Responder], a [common::pack::Pack] is needed. It can +be obtained by [loader::load] function by passing (possibly included in +binary) contents of a `pack` created with the packer. + +## Examples + +### Creating and calling responder ```rust -use anyhow::{Context, Error}; -use hyper::{ - service::{make_service_fn, service_fn}, - Body, Request, Response, Server, -}; -use lazy_static::lazy_static; -use log::LevelFilter; -use simple_logger::SimpleLogger; -use std::{convert::Infallible, include_bytes, net::SocketAddr}; -use web_static_pack::{hyper_loader::Responder, loader::Loader}; - -#[tokio::main] -async fn main() { - SimpleLogger::new() - .env() - .with_level(LevelFilter::Info) - .init() - .unwrap(); - main_result().await.unwrap() -} +use anyhow::Error; +use include_bytes_aligned::include_bytes_aligned; +use http::StatusCode; +use web_static_pack::{loader::load, responder::Responder}; + +// assume we have a vcard-personal-portfolio.pack available from packer examples +static PACK_ARCHIVED_SERIALIZED: &[u8] = + include_bytes_aligned!(16, "vcard-personal-portfolio.pack"); -async fn service(request: Request) -> Result, Infallible> { - lazy_static! { - static ref PACK: &'static [u8] = include_bytes!("docs.pack"); - static ref LOADER: Loader = Loader::new(&PACK).unwrap(); - static ref RESPONDER: Responder<'static> = Responder::new(&LOADER); - } +fn main() -> Result<(), Error> { + // load (map / cast) [common::pack::PackArchived] from included bytes + let pack_archived = unsafe { load(PACK_ARCHIVED_SERIALIZED).unwrap() }; - Ok(RESPONDER.request_respond(&request)) + // create a responder from `pack` + let responder = Responder::new(pack_archived); + + // do some checks on the responder + assert_eq!( + responder.respond_flatten().status(), + StatusCode::OK + ); + + Ok(()) } +``` + +### Adapting to hyper service +This example is based on + +which is a bit complicated. -async fn main_result() -> Result<(), Error> { - let address = SocketAddr::from(([0, 0, 0, 0], 8080)); - let server = Server::bind(&address).serve(make_service_fn(|_connection| async { - Ok::<_, Infallible>(service_fn(service)) - })); +You can run full working example from +`tests/examples/vcard_personal_portfolio_server.rs` - log::info!("Server listening on {:?}", address); - server.await.context("server")?; +```rust +use anyhow::Error; +use web_static_pack::responder::Responder; +use std::{ + convert::Infallible, + mem::transmute, +}; + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Error> { + // lets assume we have a `responder: Responder` object available from previous example + // hyper requires service to be static + // we use graceful, no connections will outlive server function + let responder = unsafe { + transmute::< + &Responder<'_, _>, + &Responder<'static, _>, + >(&responder) + }; + // make hyper service + let service_fn = service_fn(|request: Request| async { + // you can probably filter your /api requests here + let (parts, _body) = request.into_parts(); + let response = responder.respond_flatten(parts); + Ok::<_, Infallible>(response) + }); + + // use service_fn like in hyper example Ok(()) } ``` + +License: MIT diff --git a/loader/examples/docs/.gitignore b/loader/examples/docs/.gitignore deleted file mode 100644 index bf1d193..0000000 --- a/loader/examples/docs/.gitignore +++ /dev/null @@ -1 +0,0 @@ -docs.pack diff --git a/loader/examples/docs/main.rs b/loader/examples/docs/main.rs deleted file mode 100644 index e8443eb..0000000 --- a/loader/examples/docs/main.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Serves this package docs using tokio + hyper + web_static_pack. -//! -//! 1. Run local or install packer executable: `cargo run` or `cargo install web-static-pack-packer` -//! 2. Create docs in `target/doc` directory: `cargo doc --no-deps`. -//! 3. Run packer `web-static-pack-packer ./target/doc/ ./loader/examples/docs/docs.pack`. -//! 4. Build & run this example `cargo run --example docs`. -//! 5. Open http://localhost:8080/ in your browser. - -use anyhow::{Context, Error}; -use hyper::{ - service::{make_service_fn, service_fn}, - Body, Request, Response, Server, -}; -use lazy_static::lazy_static; -use log::LevelFilter; -use simple_logger::SimpleLogger; -use std::{convert::Infallible, include_bytes, net::SocketAddr}; -use web_static_pack::{hyper_loader::Responder, loader::Loader}; - -#[tokio::main] -async fn main() { - SimpleLogger::new() - .env() - .with_level(LevelFilter::Info) - .init() - .unwrap(); - main_result().await.unwrap() -} - -async fn service(request: Request) -> Result, Infallible> { - lazy_static! { - static ref PACK: &'static [u8] = include_bytes!("docs.pack"); - static ref LOADER: Loader = Loader::new(&PACK).unwrap(); - static ref RESPONDER: Responder<'static> = Responder::new(&LOADER); - } - - Ok(RESPONDER.request_respond(&request)) -} - -async fn main_result() -> Result<(), Error> { - let address = SocketAddr::from(([0, 0, 0, 0], 8080)); - let server = Server::bind(&address).serve(make_service_fn(|_connection| async { - Ok::<_, Infallible>(service_fn(service)) - })); - - log::info!("Server listening on {:?}", address); - server.await.context("server")?; - - Ok(()) -} diff --git a/loader/src/body.rs b/loader/src/body.rs new file mode 100644 index 0000000..a0cd1ab --- /dev/null +++ b/loader/src/body.rs @@ -0,0 +1,85 @@ +//! [http] / [http_body] crate abstractions. Provides [Body], implementing +//! [HttpBody] for raw bytes slice. + +use http_body::{Body as HttpBody, Frame, SizeHint}; +use std::{ + convert::Infallible, + pin::Pin, + task::{Context, Poll}, +}; + +/// [HttpBody] implementation for body consisting of a single in-memory slice. +/// Implementation is based on `http_body_util::Full`. +/// +/// Please note that once body is used, eg. polled with [Self::poll_frame], it +/// will become empty, eg. [Self::data] will return empty slice. +#[derive(Debug)] +pub struct Body<'a> { + // None(empty slice) is not allowed. + data: Option<&'a [u8]>, +} +impl<'a> Body<'a> { + /// Creates [self] from data. + pub fn new(data: &'a [u8]) -> Self { + let data = if !data.is_empty() { Some(data) } else { None }; + Self { data } + } + + /// Creates empty [self]. + pub fn empty() -> Self { + let data = None; + Self { data } + } + + /// Returns remaining data. + /// + /// This will return original content until polled with [Self::poll_frame], + /// then it will return empty slice. + pub fn data(&self) -> &'a [u8] { + self.data.unwrap_or(b"") + } +} +impl<'a> HttpBody for Body<'a> { + type Data = &'a [u8]; + type Error = Infallible; + + fn poll_frame( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + let self_ = unsafe { self.get_unchecked_mut() }; + let data = self_.data.take(); + + match data { + Some(data) => Poll::Ready(Some(Ok(Frame::data(data)))), + None => Poll::Ready(None), + } + } + + fn is_end_stream(&self) -> bool { + self.data.is_none() + } + + fn size_hint(&self) -> SizeHint { + match self.data { + Some(data) => SizeHint::with_exact(data.len() as u64), + None => SizeHint::with_exact(0), + } + } +} + +#[cfg(test)] +mod test_body { + use super::Body as Body_; + use http_body::Body; + use http_body_util::combinators::BoxBody; + + /// we want to keep our body to be compatible with [BoxBody] + #[test] + fn body_converts_to_box_body() { + let body = Body_::new(b"foo"); + let box_body = BoxBody::new(body); + + assert_eq!(box_body.size_hint().lower(), 3); + } +} diff --git a/loader/src/cache_control.rs b/loader/src/cache_control.rs new file mode 100644 index 0000000..9b941db --- /dev/null +++ b/loader/src/cache_control.rs @@ -0,0 +1,40 @@ +//! Cache control related types. Provides [CacheControl]. + +use crate::common::cache_control::{CacheControl as CacheControl_, CacheControlArchived}; +use http::HeaderValue; + +/// Cache control enumeration, used to generate `cache-control` header content. +/// This will be usually built from [CacheControlArchived] or [CacheControl_] +/// (via [From]). +#[derive(Debug)] +pub enum CacheControl { + /// Prevents caching file, by setting `no-cache` in `cache-control`. + NoCache, + /// Sets value to make resource cached for as long as possible. + MaxCache, +} +impl CacheControl { + /// Creates http [HeaderValue] from [self]. + pub fn cache_control(&self) -> HeaderValue { + match self { + CacheControl::NoCache => HeaderValue::from_static("no-cache"), + CacheControl::MaxCache => HeaderValue::from_static("max-age=31536000, immutable"), + } + } +} +impl From for CacheControl { + fn from(value: CacheControl_) -> Self { + match value { + CacheControl_::NoCache => Self::NoCache, + CacheControl_::MaxCache => Self::MaxCache, + } + } +} +impl From for CacheControl { + fn from(value: CacheControlArchived) -> Self { + match value { + CacheControlArchived::NoCache => Self::NoCache, + CacheControlArchived::MaxCache => Self::MaxCache, + } + } +} diff --git a/loader/src/content_encoding.rs b/loader/src/content_encoding.rs new file mode 100644 index 0000000..4ef66ff --- /dev/null +++ b/loader/src/content_encoding.rs @@ -0,0 +1,274 @@ +//! Content encoding negotiation and content resolver types. + +use crate::file::File; +use anyhow::{bail, Error}; +use http::{header, HeaderMap, HeaderValue}; +use std::cell::Cell; + +/// Describes accepted content encodings. +/// +/// Should be created by parsing `accept-encoding` header, through one of +/// `from_` methods. +/// +/// `identity` is always considered to be accepted. +#[derive(PartialEq, Eq, Debug)] +pub struct EncodingAccepted { + /// Whether `gzip` encoding is accepted. + pub gzip: bool, + /// Whether `brotli` encoding is accepted. + pub brotli: bool, +} +impl EncodingAccepted { + /// Constructs [self] with none encoding (except for always available + /// identity) enabled. + pub fn none() -> Self { + Self { + gzip: false, + brotli: false, + } + } + + /// Constructs [self] from [HeaderMap]. Inside it looks only for + /// `accept-encoding` header. May return error if header contains + /// invalid string. + pub fn from_headers(headers: &HeaderMap) -> Result { + let accept_encoding = match headers.get(header::ACCEPT_ENCODING) { + Some(accept_encoding) => accept_encoding, + None => return Ok(Self::none()), + }; + + let self_ = Self::from_accept_encoding_header_raw(accept_encoding)?; + + Ok(self_) + } + /// Constructs [self] from [HeaderValue] for `accept-encoding` header. May + /// return error if header contains invalid string. + pub fn from_accept_encoding_header_raw(accept_encoding: &HeaderValue) -> Result { + let accept_encoding = match accept_encoding.to_str() { + Ok(accept_encoding) => accept_encoding, + Err(_) => bail!("unable to parse accept encoding as string"), + }; + + let self_ = Self::from_accept_encoding_header_str(accept_encoding); + + Ok(self_) + } + /// Constructs [self] from `accept-encoding` header value. + pub fn from_accept_encoding_header_str(accept_encoding: &str) -> Self { + let mut gzip = false; + let mut brotli = false; + + for accept_encoding in accept_encoding.split(", ") { + let accept_encoding = Self::extract_algorithm_from_value(accept_encoding); + + match accept_encoding { + "gzip" => { + gzip = true; + } + "br" => { + brotli = true; + } + _ => {} + } + } + + Self { gzip, brotli } + } + + /// Removes `quality` or `preference` from header value. + /// eg. changes `gzip;q=0.5` to `gzip` + pub fn extract_algorithm_from_value(mut value: &str) -> &str { + if let Some((algorithm, _)) = value.split_once(";q=") { + value = algorithm; + } + value + } +} + +#[cfg(test)] +mod test_encoding_accepted { + use super::EncodingAccepted; + use http::{HeaderMap, HeaderName, HeaderValue}; + use test_case::test_case; + + #[test_case(&[], Some(EncodingAccepted::none()))] + #[test_case(&[("accept-encoding", "gzip")], Some(EncodingAccepted { gzip: true, brotli: false }))] + fn from_headers_returns_expected( + headers: &[(&'static str, &'static str)], + expected: Option, + ) { + let headers_map = headers + .iter() + .copied() + .map(|(key, value)| { + ( + HeaderName::from_static(key), + HeaderValue::from_static(value), + ) + }) + .collect::(); + + assert_eq!(EncodingAccepted::from_headers(&headers_map).ok(), expected); + } + + #[test_case(HeaderValue::from_bytes(b"\xff").unwrap(), None)] + #[test_case(HeaderValue::from_static(""), Some(EncodingAccepted { gzip: false, brotli: false }))] + #[test_case(HeaderValue::from_static("gzip, compress, br"), Some(EncodingAccepted { gzip: true, brotli: true }))] + fn from_accept_encoding_header_raw_returns_expected( + header_value: HeaderValue, + expected: Option, + ) { + assert_eq!( + EncodingAccepted::from_accept_encoding_header_raw(&header_value).ok(), + expected + ); + } + + #[test_case("", EncodingAccepted { gzip: false, brotli: false })] + #[test_case("gzip", EncodingAccepted { gzip: true, brotli: false })] + #[test_case("br", EncodingAccepted { gzip: false, brotli: true })] + #[test_case("deflate, gzip;q=1.0", EncodingAccepted { gzip: true, brotli: false })] + fn from_accept_encoding_header_str_returns_expected( + accept_encoding: &str, + expected: EncodingAccepted, + ) { + assert_eq!( + EncodingAccepted::from_accept_encoding_header_str(accept_encoding), + expected + ); + } + + #[test_case("", "")] + #[test_case("gzip", "gzip")] + #[test_case("gzip;q=1.0", "gzip")] + fn extract_algorithm_from_value_returns_expected( + value: &str, + expected: &str, + ) { + assert_eq!( + EncodingAccepted::extract_algorithm_from_value(value), + expected + ); + } +} + +/// Represents content in resolved content encoding. This should be created by +/// calling [Self::resolve], providing [EncodingAccepted] from request header +/// and [File]. +#[derive(PartialEq, Eq, Debug)] +pub struct ContentContentEncoding<'c> { + /// content (body) that should be sent in response + pub content: &'c [u8], + /// `content-encoding` header value that should be sent in response + pub content_encoding: HeaderValue, +} +impl<'c> ContentContentEncoding<'c> { + /// Based on accepted encodings from [EncodingAccepted] and available from + /// [File] resolves best (currently *smallest*) content. + pub fn resolve( + encoding_accepted: &EncodingAccepted, + file: &'c impl File, + ) -> Self { + let mut best = Cell::new(ContentContentEncoding { + content: file.content(), + content_encoding: HeaderValue::from_static("identity"), + }); + + // gzip + if encoding_accepted.gzip + && let Some(content_gzip) = file.content_gzip() + && content_gzip.len() <= best.get_mut().content.len() + { + best.set(ContentContentEncoding { + content: content_gzip, + content_encoding: HeaderValue::from_static("gzip"), + }); + } + + // brotli + if encoding_accepted.brotli + && let Some(content_brotli) = file.content_brotli() + && content_brotli.len() <= best.get_mut().content.len() + { + best.set(ContentContentEncoding { + content: content_brotli, + content_encoding: HeaderValue::from_static("br"), + }); + } + + best.into_inner() + } +} + +#[cfg(test)] +mod test_content_content_encoding { + use super::{ContentContentEncoding, EncodingAccepted}; + use crate::{cache_control::CacheControl, file::File}; + use http::HeaderValue; + use test_case::test_case; + + #[derive(Debug)] + pub struct FileMock { + pub content: &'static [u8], + pub content_gzip: Option<&'static [u8]>, + pub content_brotli: Option<&'static [u8]>, + } + impl File for FileMock { + fn content(&self) -> &[u8] { + self.content + } + fn content_gzip(&self) -> Option<&[u8]> { + self.content_gzip + } + fn content_brotli(&self) -> Option<&[u8]> { + self.content_brotli + } + + fn content_type(&self) -> HeaderValue { + unimplemented!() + } + + fn etag(&self) -> HeaderValue { + unimplemented!() + } + + fn cache_control(&self) -> CacheControl { + unimplemented!() + } + } + + #[test_case( + EncodingAccepted { gzip: false, brotli: false }, + FileMock { content: b"content-identity", content_gzip: None, content_brotli: None }, + ContentContentEncoding {content: b"content-identity", content_encoding: HeaderValue::from_static("identity") } ; + "nothing provided, nothing accepted" + )] + #[test_case( + EncodingAccepted { gzip: false, brotli: false }, + FileMock { content: b"content-identity", content_gzip: Some(b"content-gzip"), content_brotli: Some(b"content-brotli") }, + ContentContentEncoding {content: b"content-identity", content_encoding: HeaderValue::from_static("identity") } ; + "all provided, nothing accepted" + )] + #[test_case( + EncodingAccepted { gzip: true, brotli: true }, + FileMock { content: b"content-identity", content_gzip: None, content_brotli: None }, + ContentContentEncoding {content: b"content-identity", content_encoding: HeaderValue::from_static("identity") } ; + "all accepted, nothing provided" + )] + #[test_case( + EncodingAccepted { gzip: true, brotli: true }, + FileMock { content: b"content-aaa", content_gzip: Some(b"content-bb"), content_brotli: Some(b"content-c") }, + ContentContentEncoding {content: b"content-c", content_encoding: HeaderValue::from_static("br") } ; + "brotli should win as the shortest" + )] + fn resolve_returns_expected( + encoding_accepted: EncodingAccepted, + content: FileMock, + expected: ContentContentEncoding, + ) { + assert_eq!( + ContentContentEncoding::resolve(&encoding_accepted, &content), + expected + ); + } +} diff --git a/loader/src/file.rs b/loader/src/file.rs new file mode 100644 index 0000000..3a249c2 --- /dev/null +++ b/loader/src/file.rs @@ -0,0 +1,76 @@ +//! Single file related types. Provides [File] trait. + +use crate::{ + cache_control::CacheControl, + common::file::{File as File_, FileArchived}, +}; +use http::HeaderValue; + +/// Trait for single file inside a `pack`. Consists of body in different encodings +/// (`identity` aka `normal`, `gzip`, `brotli`), some precomputed header values +/// etc. +/// +/// Most users will indirectly use [FileArchived] implementation, obtained from +/// [crate::pack::Pack::get_file_by_path] (implemented by +/// [crate::common::pack::PackArchived]). +/// This trait is also implemented for non-archived [File_], mostly for testing +/// purposes. +pub trait File { + // content with different types + /// Accesses file content in original (`identity`) encoding. + fn content(&self) -> &[u8]; + /// Accesses file content in `gzip` encoding if available. + fn content_gzip(&self) -> Option<&[u8]>; + /// Accesses file content in `brotli` encoding if available. + fn content_brotli(&self) -> Option<&[u8]>; + + // headers + /// Accesses `content-type` header contents for this file. + fn content_type(&self) -> HeaderValue; + /// Accesses `ETag` header contents for this file. + fn etag(&self) -> HeaderValue; + /// Accesses [CacheControl] for this file. + fn cache_control(&self) -> CacheControl; +} +impl File for File_ { + fn content(&self) -> &[u8] { + &self.content + } + fn content_gzip(&self) -> Option<&[u8]> { + self.content_gzip.as_deref() + } + fn content_brotli(&self) -> Option<&[u8]> { + self.content_brotli.as_deref() + } + + fn content_type(&self) -> HeaderValue { + HeaderValue::from_str(&self.content_type).unwrap() + } + fn etag(&self) -> HeaderValue { + HeaderValue::from_str(&self.etag).unwrap() + } + fn cache_control(&self) -> CacheControl { + CacheControl::from(self.cache_control) + } +} +impl File for FileArchived { + fn content(&self) -> &[u8] { + &self.content + } + fn content_gzip(&self) -> Option<&[u8]> { + self.content_gzip.as_deref() + } + fn content_brotli(&self) -> Option<&[u8]> { + self.content_brotli.as_deref() + } + + fn content_type(&self) -> HeaderValue { + HeaderValue::from_str(&self.content_type).unwrap() + } + fn etag(&self) -> HeaderValue { + HeaderValue::from_str(&self.etag).unwrap() + } + fn cache_control(&self) -> CacheControl { + CacheControl::from(self.cache_control) + } +} diff --git a/loader/src/hyper_loader.rs b/loader/src/hyper_loader.rs deleted file mode 100644 index e26329b..0000000 --- a/loader/src/hyper_loader.rs +++ /dev/null @@ -1,165 +0,0 @@ -//! Hyper integration. -//! See examples/docs/main.rs for usage sample. -//! -//! Entry point for this module is `Responder`. -//! Create `Responder` providing reference to `Loader`. -//! Use `request_respond()` method to serve file in response to request. - -use super::loader::Loader; -use http::{header, HeaderMap, Method, StatusCode, Uri}; -use hyper::{Body, Request, Response}; - -/// Possible errors during `Responder` handling -pub enum ResponderError { - /// Not supported HTTP Method, this maps to HTTP `METHOD_NOT_ALLOWED`. - HttpMethodNotSupported, - - /// Request URI was not found in `Loader`. This maps to HTTP `NOT_FOUND`. - LoaderPathNotFound, - - /// Error while parsing HTTP `Accept-Encoding`. This maps to HTTP `BAD_REQUEST`. - UnparsableAcceptEncoding, -} -impl ResponderError { - /// Converts error into best matching HTTP error code - pub fn as_http_status_code(&self) -> StatusCode { - match self { - ResponderError::HttpMethodNotSupported => StatusCode::METHOD_NOT_ALLOWED, - ResponderError::LoaderPathNotFound => StatusCode::NOT_FOUND, - ResponderError::UnparsableAcceptEncoding => StatusCode::BAD_REQUEST, - } - } - - /// Creates default response (status code + empty body) for this error. - pub fn as_default_response(&self) -> Response { - Response::builder() - .status(self.as_http_status_code()) - .body(Body::default()) - .unwrap() - } -} - -/// Main class for hyper integration. -/// Given `Loader`, responds to incoming requests serving files from `Loader`. -pub struct Responder<'l> { - loader: &'l Loader, -} -impl<'l> Responder<'l> { - /// Creates instance, using provided `Loader`. - pub fn new(loader: &'l Loader) -> Self { - Self { loader } - } - - /// Given basic hyper request, responds to it, or returns `ResponderError`. - /// To automatically cast `ResponderError` to response, use `request_respond` instead. - pub fn request_respond_or_error( - &self, - request: &Request, - ) -> Result, ResponderError> { - self.parts_respond_or_error(request.method(), request.uri(), request.headers()) - } - - /// Given set of parts (`method`, `uri` and `headers`), responds to it, or returns `ResponderError`. - /// To automatically cast `ResponderError` to response, use `parts_respond` instead. - pub fn parts_respond_or_error( - &self, - method: &Method, - uri: &Uri, - headers: &HeaderMap, - ) -> Result, ResponderError> { - // Only GET requests are allowed. - // TODO: Handle HEAD requests. - match *method { - Method::GET => (), - _ => { - return Err(ResponderError::HttpMethodNotSupported); - } - }; - - // Find file for given request. - let file_descriptor = match self.loader.get(uri.path()) { - Some(file_descriptor) => file_descriptor, - None => { - return Err(ResponderError::LoaderPathNotFound); - } - }; - - // Check for possible ETag. - // If ETag exists and matches current file, return 304. - if let Some(etag_request) = headers.get(header::IF_NONE_MATCH) { - if etag_request.as_bytes() == file_descriptor.etag().as_bytes() { - return Ok(Response::builder() - .status(StatusCode::NOT_MODIFIED) - .body(Body::default()) - .unwrap()); - } - }; - - // Check accepted encodings - let mut accepted_encoding_gzip = false; - if let Some(accept_encoding) = headers.get(header::ACCEPT_ENCODING) { - let accept_encoding = match accept_encoding.to_str() { - Ok(accept_encoding) => accept_encoding, - Err(_) => { - return Err(ResponderError::UnparsableAcceptEncoding); - } - }; - - #[allow(clippy::single_match)] - accept_encoding - .split(", ") - .for_each(|accept_encoding| match accept_encoding { - "gzip" => { - accepted_encoding_gzip = true; - } - _ => {} - }); - } - - // Select data based on accepted encoding - // (chunk, content_encoding) - let (content, content_encoding) = - if accepted_encoding_gzip && file_descriptor.content_gzip().is_some() { - (file_descriptor.content_gzip().unwrap(), "gzip") - } else { - (file_descriptor.content(), "identity") - }; - - // Provide response. - let response = Response::builder() - .header(header::CONTENT_TYPE, file_descriptor.content_type()) - .header(header::CONTENT_LENGTH, content.len()) - .header(header::CONTENT_ENCODING, content_encoding) - .header(header::ETAG, file_descriptor.etag()) - .body(Body::from(content)) - .unwrap(); - - Ok(response) - } - - /// Given basic hyper request, responds to it. - /// In case of error creates default http response. If specific error control is needed, use `request_respond_or_error` instead. - pub fn request_respond( - &self, - request: &Request, - ) -> Response { - match self.request_respond_or_error(request) { - Ok(response) => response, - Err(error) => error.as_default_response(), - } - } - - /// Given set of parts (`method`, `uri` and `headers`), responds to it. - /// In case of error creates default http response. If specific error control is needed, use `parts_respond_or_error` instead. - pub fn parts_respond( - &self, - method: &Method, - uri: &Uri, - headers: &HeaderMap, - ) -> Response { - match self.parts_respond_or_error(method, uri, headers) { - Ok(response) => response, - Err(error) => error.as_default_response(), - } - } -} diff --git a/loader/src/lib.rs b/loader/src/lib.rs index 6c98029..20dd9c3 100644 --- a/loader/src/lib.rs +++ b/loader/src/lib.rs @@ -1,88 +1,106 @@ -//! # web-static-pack -//! Embed static resources (GUI, assets, images, styles, html) within executable. -//! Serve with hyper or any server of your choice. -//! -//! [![docs.rs](https://docs.rs/web-static-pack/badge.svg)](https://docs.rs/web-static-pack) -//! -//! ## Usage scenario: -//! - Combines given directory tree into single, fast, binary-based single-file representation, called `pack`. Use simple CLI tool `web-static-pack-packer` to create a pack. -//! - Pack could be embedded into your application using `include_bytes!` single macro. -//! - Super-fast, zero-copy `loader` provides by-name access to files. -//! - Easy-to-use `hyper_loader` allows super-quick integration with hyper-based server. -//! -//! ## Features: -//! - Super fast, low overhead -//! - 100% 'static access, zero data copy -//! - 100% pack-time calculated `Content-Type`, `ETag` (using sha3) -//! - 100% pack-time calculated gzip-compressed files -//! - Almost no external dependencies -//! -//! ## Limitations: -//! - By default all files with guesses text/ content type are treated as utf-8 -//! - Packs are not guaranteed to be portable across versions / architectures -//! -//! ## Future goals: -//! - You tell me -//! -//! ## Non-Goals: -//! - Directory listings -//! - automatic index.html resolving -//! - Uploads -//! -//! ## Example: -//! 1. Create a pack from `cargo doc`: -//! ``` -//! $ cargo doc --no-deps -//! $ cargo run ./target/doc/ docs.pack -//! ``` -//! -//! 2. Serve docs.pack from your web-application (see `examples/docs`) -//! ``` -//! use anyhow::{Context, Error}; -//! use hyper::{ -//! service::{make_service_fn, service_fn}, -//! Body, Request, Response, Server, -//! }; -//! use lazy_static::lazy_static; -//! use log::LevelFilter; -//! use simple_logger::SimpleLogger; -//! use std::{convert::Infallible, include_bytes, net::SocketAddr}; -//! use web_static_pack::{hyper_loader::Responder, loader::Loader}; -//! -//! #[tokio::main] -//! async fn main() { -//! SimpleLogger::new() -//! .env() -//! .with_level(LevelFilter::Info) -//! .init() -//! .unwrap(); -//! main_result().await.unwrap() -//! } +//! web-static-pack is the "loader" (2nd stage) part of the +//! [web-static-pack](https://github.com/peku33/web-static-pack) +//! project. See project page for a general idea how two parts cooperate. +//! +//! Once a `pack` is created with build script / CI / build.rs using +//! [web-static-pack-packer](https://crates.io/crates/web-static-pack-packer) +//! it will usually be included in your target application with +//! . +//! Then it will be loaded with a [loader::load], utilizing zero-copy +//! deserialization (so file contents will be sent from executable contents +//! directly). The pack is then possibly wrapped with [responder::Responder] +//! http service and used with a web server like hyper. +//! +//! The main part of this crate is [responder::Responder]. Its +//! [responder::Responder::respond_flatten] method makes a [http] service - a +//! function taking [http::Request] (actually the [http::request::Parts], as we +//! don't need the body) and returning [http::Response]. +//! +//! To make a [responder::Responder], a [common::pack::Pack] is needed. It can +//! be obtained by [loader::load] function by passing (possibly included in +//! binary) contents of a `pack` created with the packer. +//! +//! # Examples +//! +//! ## Creating and calling responder +//! ```ignore +//! use anyhow::Error; +//! use include_bytes_aligned::include_bytes_aligned; +//! use http::StatusCode; +//! use web_static_pack::{loader::load, responder::Responder}; +//! +//! // assume we have a vcard-personal-portfolio.pack available from packer examples +//! static PACK_ARCHIVED_SERIALIZED: &[u8] = +//! include_bytes_aligned!(16, "vcard-personal-portfolio.pack"); +//! +//! fn main() -> Result<(), Error> { +//! // load (map / cast) [common::pack::PackArchived] from included bytes +//! let pack_archived = unsafe { load(PACK_ARCHIVED_SERIALIZED).unwrap() }; //! -//! async fn service(request: Request) -> Result, Infallible> { -//! lazy_static! { -//! static ref PACK: &'static [u8] = include_bytes!("docs.pack"); -//! static ref LOADER: Loader = Loader::new(&PACK).unwrap(); -//! static ref RESPONDER: Responder<'static> = Responder::new(&LOADER); -//! } +//! // create a responder from `pack` +//! let responder = Responder::new(pack_archived); //! -//! Ok(RESPONDER.request_respond(&request)) +//! // do some checks on the responder +//! assert_eq!( +//! responder.respond_flatten().status(), +//! StatusCode::OK +//! ); +//! +//! Ok(()) //! } +//! ``` //! -//! async fn main_result() -> Result<(), Error> { -//! let address = SocketAddr::from(([0, 0, 0, 0], 8080)); -//! let server = Server::bind(&address).serve(make_service_fn(|_connection| async { -//! Ok::<_, Infallible>(service_fn(service)) -//! })); +//! ## Adapting to hyper service +//! This example is based on +//! +//! which is a bit complicated. +//! +//! You can run full working example from +//! `tests/examples/vcard_personal_portfolio_server.rs` +//! +//! ```ignore +//! use anyhow::Error; +//! use web_static_pack::responder::Responder; +//! use std::{ +//! convert::Infallible, +//! mem::transmute, +//! }; //! -//! log::info!("Server listening on {:?}", address); -//! server.await.context("server")?; -//! +//! #[tokio::main(flavor = "current_thread")] +//! async fn main() -> Result<(), Error> { +//! // lets assume we have a `responder: Responder` object available from previous example +//! // hyper requires service to be static +//! // we use graceful, no connections will outlive server function +//! let responder = unsafe { +//! transmute::< +//! &Responder<'_, _>, +//! &Responder<'static, _>, +//! >(&responder) +//! }; +//! +//! // make hyper service +//! let service_fn = service_fn(|request: Request| async { +//! // you can probably filter your /api requests here +//! let (parts, _body) = request.into_parts(); +//! let response = responder.respond_flatten(parts); +//! Ok::<_, Infallible>(response) +//! }); +//! +//! // use service_fn like in hyper example //! Ok(()) //! } //! ``` -pub mod loader; +#![feature(let_chains)] +#![allow(clippy::new_without_default)] +#![warn(missing_docs)] -#[cfg(feature = "hyper_loader")] -pub mod hyper_loader; +pub use web_static_pack_common as common; + +pub mod body; +pub mod cache_control; +pub mod content_encoding; +pub mod file; +pub mod loader; +pub mod pack; +pub mod responder; diff --git a/loader/src/loader.rs b/loader/src/loader.rs index 5be2b36..00d9ddb 100644 --- a/loader/src/loader.rs +++ b/loader/src/loader.rs @@ -1,148 +1,88 @@ -//! Main loader module. -//! This is the part you should include in you target project if you want to read packs directly. -//! After creating a pack with cli packer tool, include this into your program with `include_bytes!` macro. Then pass it to `Loader::new()`. -//! Files may be retrieved using `get()` method. - -use anyhow::{bail, Context, Error}; -use std::{collections::HashMap, str}; - -/// File descriptor, retrieved from loader. -pub struct FileDescriptor { - content_type: &'static str, - etag: &'static str, - content: &'static [u8], - content_gzip: Option<&'static [u8]>, -} -impl FileDescriptor { - /// Returns HTTP Content-Type. - pub fn content_type(&self) -> &'static str { - self.content_type - } - - /// Returns quoted http ETag precalculated for this file. - pub fn etag(&self) -> &'static str { - self.etag - } - - /// Returns original file content. - pub fn content(&self) -> &'static [u8] { - self.content - } - - /// Returns gzipped file content (if available) - pub fn content_gzip(&self) -> Option<&'static [u8]> { - self.content_gzip - } -} - -/// Main loader. Create using `::new()` providing reference to result of `include_bytes!`. -/// Call `get()` to access files. -pub struct Loader { - files: HashMap<&'static str, FileDescriptor>, -} -impl Loader { - fn read_u8(rest: &mut &'static [u8]) -> Result<&'static [u8], Error> { - #[allow(clippy::len_zero)] - if rest.len() < 1 { - bail!("Premature length termination"); - } - let length = u8::from_ne_bytes(unsafe { [*rest.get_unchecked(0)] }) as usize; - - if rest.len() - 1 < length { - bail!("Premature data termination"); - } - let data = &rest[1..(1 + length)]; - - *rest = &rest[1 + length..]; - - Ok(data) - } - fn read_u16(rest: &mut &'static [u8]) -> Result<&'static [u8], Error> { - if rest.len() < 2 { - bail!("Premature length termination"); - } - let length = u16::from_ne_bytes(unsafe { [*rest.get_unchecked(0), *rest.get_unchecked(1)] }) - as usize; - - if rest.len() - 2 < length { - bail!("Premature data termination"); - } - let data = &rest[2..(2 + length)]; - - *rest = &rest[2 + length..]; - - Ok(data) - } - fn read_u32(rest: &mut &'static [u8]) -> Result<&'static [u8], Error> { - if rest.len() < 4 { - bail!("Premature length termination"); - } - let length = u32::from_ne_bytes(unsafe { - [ - *rest.get_unchecked(0), - *rest.get_unchecked(1), - *rest.get_unchecked(2), - *rest.get_unchecked(3), - ] - }) as usize; - - if rest.len() - 4 < length { - bail!("Premature data termination"); - } - let data = &rest[4..(4 + length)]; - - *rest = &rest[4 + length..]; - - Ok(data) - } - - /// Creates a loader. - /// Pass result of `include_bytes!` macro here. - /// Create pack (for inclusion) with `web-static-pack-packer`. - pub fn new(included_bytes: &'static [u8]) -> Result { - let mut rest = included_bytes; - let mut files = HashMap::<&'static str, FileDescriptor>::new(); - while !rest.is_empty() { - // Extract. - let path = - unsafe { str::from_utf8_unchecked(Self::read_u16(&mut rest).context("path")?) }; - let content_type = unsafe { - str::from_utf8_unchecked(Self::read_u8(&mut rest).context("content_type")?) - }; - let etag = - unsafe { str::from_utf8_unchecked(Self::read_u8(&mut rest).context("etag")?) }; - let content = Self::read_u32(&mut rest).context("content")?; - let content_gzip = Self::read_u32(&mut rest).context("content_gzip")?; - let content_gzip = if !content_gzip.is_empty() { - Some(content_gzip) - } else { - None - }; - - // Build FileDescriptor. - let file_descriptor = FileDescriptor { - content_type, - etag, - content, - content_gzip, - }; - - // Push to collection. - if files.insert(path, file_descriptor).is_some() { - bail!("File corrupted, duplicated path: {}", path); - } - } - log::info!("Loaded total {} files", files.len()); - Ok(Self { files }) - } - - /// Retrieves file from pack. - /// The path should usually start with `/`, exactly as in URL. - /// Returns `Some(&FileDescriptor)` if file is found, `None` otherwise. - pub fn get( - &self, - path: &str, - ) -> Option<&FileDescriptor> { - self.files.get(path) - } +//! Module containing [load] function used to convert (map) serialized `pack` +//! into [PackArchived] object. + +use crate::common::{ + pack::{Pack, PackArchived}, + PACK_FILE_MAGIC, PACK_FILE_VERSION, +}; +use anyhow::{ensure, Error}; +use rkyv::archived_root; + +/// Alignment value for `serialized` in [load]. +pub const ALIGN_BYTES: usize = 16; + +/// Loads [PackArchived] from serialized bytes created by +/// [web-static-pack-packer](https://crates.io/crates/web-static-pack-packer). +/// +/// This method is lightweight, as it does some pre-checks and then casts +/// (zero-copy deserialization) [rkyv] archived [PackArchived] to the output. +/// +/// In a typical scenario the [Pack] will be created with packer in build +/// pipeline (or build.rs), then serialized to a file and stored in the working +/// / build directory. +/// +/// Target application will have serialized `pack` embedded with +/// +/// (or alternatively read from fs) and then passed to this function, that will +/// "map" it to [PackArchived]. +/// +/// Loaded [PackArchived] will be typically used to create +/// [crate::responder::Responder]. +/// +/// Please note that `serialized` must be aligned to [ALIGN_BYTES], either by +/// `include_bytes_aligned` crate (if embedding into executable) or +/// [rkyv::util::AlignedVec] (if loading from fs in runtime). +/// +/// # Examples +/// +/// ```ignore +/// // from workspace root: +/// // $ cargo run -- directory-single ./tests/data/vcard-personal-portfolio/ vcard-personal-portfolio.pack +/// +/// // then in your application +/// static PACK_ARCHIVED_SERIALIZED: &[u8] = include_bytes_aligned!( +/// 16, // == web_static_pack::loader::ALIGN_BYTES, +/// "vcard-personal-portfolio.pack" +/// ); +/// +/// fn main() { +/// let pack = unsafe { web_static_pack::loader::load(PACK_ARCHIVED_SERIALIZED).unwrap() }; +/// // create responder from pack +/// // pass http requests to responder +/// // see crate documentation for full example +/// } +/// ``` +/// +/// # Safety +/// `serialized` must point to valid `pack` created with matching version of +/// packer. Underlying loader (rkyv) relies on correct file content. If invalid +/// content is provided it is going to cause undefined behavior. +pub unsafe fn load(serialized: &[u8]) -> Result<&PackArchived, Error> { + ensure!( + serialized.as_ptr() as usize % ALIGN_BYTES == 0, + "invalid alignment, serialized must be aligned to {ALIGN_BYTES} bytes" + ); + ensure!(serialized.len() > 16, "premature file end"); + + // check file magic + let file_magic_bytes: [u8; 8] = serialized[0..8].try_into()?; + let file_magic = u64::from_ne_bytes(file_magic_bytes); + ensure!( + file_magic == PACK_FILE_MAGIC, + "file magic mismatch, probably not a pack file" + ); + + // check file version + let file_version_bytes: [u8; 8] = serialized[8..16].try_into()?; + let file_version = u64::from_ne_bytes(file_version_bytes); + ensure!( + file_version == PACK_FILE_VERSION, + "file version mismatch (got {file_version}, expected: {PACK_FILE_VERSION}) (probably pack created with different version)" + ); + + // deserialize content + // NOTE: value passed to [archived_root] must be 16-aligned + let pack = unsafe { archived_root::(&serialized[16..]) }; + + Ok(pack) } diff --git a/loader/src/pack.rs b/loader/src/pack.rs new file mode 100644 index 0000000..d421218 --- /dev/null +++ b/loader/src/pack.rs @@ -0,0 +1,50 @@ +//! Pack related types. Provides [Pack] trait. + +use crate::{ + common::{ + file::{File as File_, FileArchived}, + pack::{Pack as Pack_, PackArchived}, + }, + file::File, +}; + +/// Trait representing Pack, a container for files identified by path. +/// +/// Most users will use [PackArchived] implementation, returned by +/// [crate::loader::load]. +/// This trait is also implemented for non-archived [Pack_], mostly for testing +/// purposes. +pub trait Pack { + /// File type returned by this `pack`. + type File: File; + + /// Given `pack` relative path, eg. `/dir1/dir2/file.html` returns file + /// associated with this path. Returns [None] if file for given path + /// does not exist. + fn get_file_by_path( + &self, + path: &str, + ) -> Option<&Self::File>; +} +impl Pack for Pack_ { + type File = File_; + + fn get_file_by_path( + &self, + path: &str, + ) -> Option<&Self::File> { + let file = self.files_by_path.get(path)?; + Some(file) + } +} +impl Pack for PackArchived { + type File = FileArchived; + + fn get_file_by_path( + &self, + path: &str, + ) -> Option<&Self::File> { + let file = self.files_by_path.get(path)?; + Some(file) + } +} diff --git a/loader/src/responder.rs b/loader/src/responder.rs new file mode 100644 index 0000000..4014c89 --- /dev/null +++ b/loader/src/responder.rs @@ -0,0 +1,394 @@ +//! Module containing [Responder] - service taking http requests and returning +//! http responses. + +use crate::{ + body::Body, + content_encoding::{ContentContentEncoding, EncodingAccepted}, + file::File, + pack::Pack, +}; +use http::{ + header, + request::Parts as RequestParts, + response::{Builder as ResponseBuilder, Response as HttpResponse}, + Method, StatusCode, +}; + +/// Http response type specialization. +pub type Response<'a> = HttpResponse>; + +/// Responder service, providing http response for requests, looking for +/// [File] in [Pack]. +/// +/// There are two main methods for this type: +/// - [Self::respond] - generates http response for successful requests and lets +/// user handle errors manually. +/// - [Self::respond_flatten] - like above, but generates default responses also +/// for errors. +/// +/// # Examples +/// +/// ```ignore +/// # use http::StatusCode; +/// +/// let pack_archived = web_static_pack::loader::load(...).unwrap(); +/// let responder = web_static_pack::responder::Responder::new(pack_archived); +/// +/// assert_eq!( +/// responder.respond_flatten().status(), +/// StatusCode::OK +/// ); +/// assert_eq!( +/// responder.respond_flatten().status(), +/// StatusCode::NOT_FOUND +/// ); +/// +/// assert_eq!( +/// responder.respond(), +/// Err(ResponderRespondError::PackPathNotFound) +/// ); +/// ``` +/// +/// For full example, including making a hyper server, see crate level +/// documentation. +#[derive(Debug)] +pub struct Responder<'p, P> +where + P: Pack, +{ + pack: &'p P, +} +impl<'p, P> Responder<'p, P> +where + P: Pack, +{ + /// Creates new instance, based on [Pack]. + pub const fn new(pack: &'p P) -> Self { + Self { pack } + } + + /// Returns http response for given request or rust error to be handled by + /// user. + /// + /// Inside this method: + /// - Checks http method (accepts GET or HEAD). + /// - Looks for file inside `pack` passed in constructor. + /// - Checks for `ETag` match (and returns 304). + /// - Negotiates content encoding. + /// - Builds final http response containing header and body (if method is + /// not HEAD). + /// + /// For alternative handling errors with default http responses see + /// [Self::respond_flatten]. + pub fn respond( + &self, + request: RequestParts, + ) -> Result, ResponderRespondError> { + // only GET and HEAD are supported + let body_in_response = match request.method { + Method::GET => true, + Method::HEAD => false, + _ => { + return Err(ResponderRespondError::HttpMethodNotSupported); + } + }; + + // find file for given request + let file = match self.pack.get_file_by_path(request.uri.path()) { + Some(file_descriptor) => file_descriptor, + None => { + return Err(ResponderRespondError::PackPathNotFound); + } + }; + + // check for possible `ETag` + // if `ETag` exists and matches current file, return 304 + if let Some(etag_request) = request.headers.get(header::IF_NONE_MATCH) + && etag_request.as_bytes() == file.etag().as_bytes() + { + let response = ResponseBuilder::new() + .status(StatusCode::NOT_MODIFIED) + .header(header::ETAG, file.etag()) // https://stackoverflow.com/a/4226409/1658328 + .body(Body::empty()) + .unwrap(); + return Ok(response); + }; + + // resolve content and content-encoding header + let content_content_encoding = ContentContentEncoding::resolve( + &match EncodingAccepted::from_headers(&request.headers) { + Ok(content_encoding_encoding_accepted) => content_encoding_encoding_accepted, + Err(_) => return Err(ResponderRespondError::UnparsableAcceptEncoding), + }, + file, + ); + + // build final response + let response = ResponseBuilder::new() + .header(header::CONTENT_TYPE, file.content_type()) + .header(header::ETAG, file.etag()) + .header(header::CACHE_CONTROL, file.cache_control().cache_control()) + .header( + header::CONTENT_LENGTH, + content_content_encoding.content.len(), + ) + .header( + header::CONTENT_ENCODING, + content_content_encoding.content_encoding, + ) + .body(if body_in_response { + Body::new(content_content_encoding.content) + } else { + Body::empty() + }) + .unwrap(); + + Ok(response) + } + + /// Like [Self::respond], but generates "default" (proper http + /// status code and empty body) responses also for errors. This will for + /// example generate HTTP 404 response for request uri not found in path. + /// + /// For manual error handling, see [Self::respond]. + pub fn respond_flatten( + &self, + request: RequestParts, + ) -> Response<'p> { + match self.respond(request) { + Ok(response) => response, + Err(responder_error) => responder_error.into_response(), + } + } +} + +/// Possible errors during [Responder::respond] handling. +#[derive(PartialEq, Eq, Debug)] +pub enum ResponderRespondError { + /// Not supported HTTP Method, this maps to HTTP `METHOD_NOT_ALLOWED`. + HttpMethodNotSupported, + + /// Request URI was not found in [Pack]. This maps to HTTP `NOT_FOUND`. + PackPathNotFound, + + /// Error while parsing HTTP `Accept-Encoding`. This maps to HTTP + /// `BAD_REQUEST`. + UnparsableAcceptEncoding, +} +impl ResponderRespondError { + /// Converts error into best matching HTTP error code. + pub fn status_code(&self) -> StatusCode { + match self { + ResponderRespondError::HttpMethodNotSupported => StatusCode::METHOD_NOT_ALLOWED, + ResponderRespondError::PackPathNotFound => StatusCode::NOT_FOUND, + ResponderRespondError::UnparsableAcceptEncoding => StatusCode::BAD_REQUEST, + } + } + + /// Creates default response (status code + empty body) for this error. + pub fn into_response(&self) -> Response<'static> { + let response = ResponseBuilder::new() + .status(self.status_code()) + .body(Body::empty()) + .unwrap(); + response + } +} + +#[cfg(test)] +mod test_responder { + use super::{Responder, ResponderRespondError}; + use crate::{cache_control::CacheControl, file::File, pack::Pack}; + use anyhow::anyhow; + use http::{ + header, method::Method, request::Builder as RequestBuilder, status::StatusCode, HeaderMap, + HeaderName, HeaderValue, + }; + + struct FileMock; + impl File for FileMock { + fn content(&self) -> &[u8] { + b"content-identity" + } + fn content_gzip(&self) -> Option<&[u8]> { + None + } + fn content_brotli(&self) -> Option<&[u8]> { + Some(b"content-br") + } + + fn content_type(&self) -> HeaderValue { + HeaderValue::from_static("text/plain; charset=utf-8") + } + fn etag(&self) -> HeaderValue { + HeaderValue::from_static("\"etagvalue\"") + } + fn cache_control(&self) -> CacheControl { + CacheControl::MaxCache + } + } + + struct PackMock; + impl Pack for PackMock { + type File = FileMock; + + fn get_file_by_path( + &self, + path: &str, + ) -> Option<&Self::File> { + match path { + "/present" => Some(&FileMock), + _ => None, + } + } + } + + static RESPONDER: Responder<'static, PackMock> = Responder::new(&PackMock); + + fn header_as_string( + headers: &HeaderMap, + name: HeaderName, + ) -> &str { + headers + .get(&name) + .ok_or_else(|| anyhow!("missing header {name}")) + .unwrap() + .to_str() + .unwrap() + } + + #[test] + fn resolves_typical_request() { + let (request, _) = RequestBuilder::new() + .method(Method::GET) + .uri("/present") + .header(header::ACCEPT_ENCODING, "br, gzip") + .header(header::IF_NONE_MATCH, "\"invalidetag\"") + .body(()) + .unwrap() + .into_parts(); + + let response = RESPONDER.respond(request).unwrap(); + let headers = response.headers(); + + assert_eq!(response.status(), StatusCode::OK); + + assert_eq!( + header_as_string(headers, header::CONTENT_TYPE), + "text/plain; charset=utf-8" + ); + assert_eq!( + header_as_string(headers, header::ETAG), // line break + "\"etagvalue\"" + ); + assert_eq!( + header_as_string(headers, header::CACHE_CONTROL), // line break + "max-age=31536000, immutable" + ); + assert_eq!( + header_as_string(headers, header::CONTENT_LENGTH), // line break + "10" + ); + assert_eq!( + header_as_string(headers, header::CONTENT_ENCODING), // line break + "br" + ); + + assert_eq!(response.body().data(), b"content-br"); + } + + #[test] + fn resolves_no_body_for_head_request() { + let (request, _) = RequestBuilder::new() + .method(Method::HEAD) + .uri("/present") + .body(()) + .unwrap() + .into_parts(); + + let response = RESPONDER.respond(request).unwrap(); + let headers = response.headers(); + + assert_eq!(response.status(), StatusCode::OK); + + assert_eq!( + header_as_string(headers, header::CONTENT_TYPE), + "text/plain; charset=utf-8" + ); + assert_eq!( + header_as_string(headers, header::ETAG), // line break + "\"etagvalue\"" + ); + assert_eq!( + header_as_string(headers, header::CONTENT_LENGTH), // line break + "16" + ); + assert_eq!( + header_as_string(headers, header::CONTENT_ENCODING), + "identity" + ); + + assert_eq!(response.body().data(), b""); + } + + #[test] + fn resolves_not_modified_for_matching_etag() { + let (request, _) = RequestBuilder::new() + .method(Method::GET) + .uri("/present") + .header(header::IF_NONE_MATCH, "\"etagvalue\"") + .body(()) + .unwrap() + .into_parts(); + + let response = RESPONDER.respond(request).unwrap(); + let headers = response.headers(); + + assert_eq!(response.status(), StatusCode::NOT_MODIFIED); + + // `ETag` should be resent, others should be missing + assert_eq!( + header_as_string(headers, header::ETAG), // line break + "\"etagvalue\"" + ); + assert!(headers.get(header::CONTENT_TYPE).is_none()); + + // of course no body + assert_eq!(response.body().data(), b""); + } + + #[test] + fn resolves_error_for_invalid_method() { + let (request, _) = RequestBuilder::new() + .method(Method::POST) + .uri("/present") + .body(()) + .unwrap() + .into_parts(); + + let response_error = RESPONDER.respond(request).unwrap_err(); + assert_eq!( + response_error, + ResponderRespondError::HttpMethodNotSupported + ); + + let response_flatten = response_error.into_response(); + assert_eq!(response_flatten.status(), StatusCode::METHOD_NOT_ALLOWED); + } + + #[test] + fn resolves_error_for_file_not_found() { + let (request, _) = RequestBuilder::new() + .method(Method::GET) + .uri("/missing") + .body(()) + .unwrap() + .into_parts(); + + let response_error = RESPONDER.respond(request).unwrap_err(); + assert_eq!(response_error, ResponderRespondError::PackPathNotFound); + + let response_flatten = response_error.into_response(); + assert_eq!(response_flatten.status(), StatusCode::NOT_FOUND); + } +} diff --git a/packer/Cargo.toml b/packer/Cargo.toml index b3384e0..7d2fd2d 100644 --- a/packer/Cargo.toml +++ b/packer/Cargo.toml @@ -1,23 +1,28 @@ [package] name = "web-static-pack-packer" -version = "0.1.6" +version = "0.5.0-beta.1" authors = ["Paweł Kubrak "] edition = "2021" -description = "Installable web-static-pack-packer tool for web-static-pack crate" license = "MIT" +description = "Installable web-static-pack-packer tool for web-static-pack crate" homepage = "https://github.com/peku33/web-static-pack" repository = "https://github.com/peku33/web-static-pack" -documentation = "https://docs.rs/web-static-pack" readme = "README.md" +keywords = ["web", "http", "static", "resources", "hyper"] +categories = ["web-programming"] [dependencies] -bytes = "1.5.0" -clap = "4.4.7" -anyhow = "1.0.75" -itertools = "0.11.0" -libflate = "2.0.0" -log = "0.4.20" -mime_guess = "2.0.4" +web-static-pack-common = { version = "0.5.0-beta.1", path = "../common" } + +anyhow = "1.0.86" +brotli = "6.0.0" +clap = { version = "4.5.8", features = ["derive"] } +flate2 = "1.0" +itertools = "0.13.0" +mime_guess = "2.0.5" +rkyv = { version = "0.7.44" } sha3 = "0.10.8" -simple_logger = "4.2.0" -walkdir = "2.4.0" +walkdir = "2.5.0" + +[dev-dependencies] +test-case = "3.3.1" diff --git a/packer/README.md b/packer/README.md index 5b9aa15..4e1b8d4 100644 --- a/packer/README.md +++ b/packer/README.md @@ -1,13 +1,129 @@ # web-static-pack-packer -Executable to build packs for web-static-pack crate -See main crate for details - -[![docs.rs](https://docs.rs/web-static-pack-packer/badge.svg)](https://docs.rs/web-static-pack-packer) - -## Usage -1. Install (`cargo install web-static-pack-packer`) or run locally (`cargo run`) -2. Provide positional arguments: - - `` - the directory to pack - - `` - name of the build pack - - `[root_pach]` - relative path to build pack paths with. use the same as `path` to have all paths in pack root -3. Use `` file with `web-static-pack` (loader) + +web-static-pack-packer is the "builder" (1st stage) part of the +[web-static-pack](https://github.com/peku33/web-static-pack) +project. See project page for a general idea how two parts cooperate. + +The goal of the packer part is to collect your directories / files / memory +slices, precalculate things like `ETag`, compressed versions (`gzip`, +`brotli`) and store them as a single file (called `pack`). Your target +application will include (ex. with + +) and "load" / "parse" `pack` during runtime using +[web-static-pack](https://crates.io/crates/web-static-pack) +(the loader part) and (possibly) serve it with a web server of your choice. + +This crate is usually used in build script / CI / build.rs stage, not in +your target application. It's used to create a `pack` from list of files +(like your GUI app / images / other assets) to be later loaded by your app +and served with a web server. + +This crate can be used in two ways: +- As a standalone application, installed with `cargo install`, this is the + preferred way if you are using build scripts, CI pipeline etc. +- As a library, imported to your project, this is a way to go if you want to + use it in build.rs of your target application or go with some really + custom approach + +## Using as a standalone application + +### Install (or update to matching version) +- Either install it with `$ cargo install web-static-pack-packer` and use + shell command `$ web-static-pack-packer [PARAMS]...` +- Or clone repo, go into `packer` directory, `$ cargo run --release -- + [PARAMS]...`. (please note `--` which marks end of arguments for cargo run + and beginning of arguments for the application). + +For the purpose of this example, the first option is assumed, with +`web-static-pack-packer` command available. + +### Create a `pack` +`web-static-pack-packer` provides up to date documentation with `$ +web-static-pack-packer --help`. Application is built around subcommands to +cover basic scenarios: +- `directory-single [OPTIONS] ` + will create a `pack` from a single directory. This is the most common + scenario, for example when you have a web application built into + `./gui/build` directory and you want to have it served with your app. +- `files-cmd [OPTIONS] + [INPUT_FILE_PATHS]...` lets you specify all files from command line in + `xargs` style. base directory path is used as a root for building relative + paths inside a `pack`. +- `files-stdin [OPTIONS] ` + lets you provide list of files from stdin. + +#### Examples +Let's say you have a `vcard-personal-portfolio` directory containing your +web project (available in tests/data/ in repository). Directory structure +looks like: +``` +vcard-personal-portfolio +| index.html +| index.txt ++---assets +| +---css +| | style.css +| +---images +| | .png +| \---js +| script.js +\---website-demo-image + desktop.png + mobile.png +``` +By running: +``` +$ web-static-pack-packer \ + directory-single \ + ./vcard-personal-portfolio \ + ./vcard-personal-portfolio.pack +``` +a new file `vcard-personal-portfolio.pack` will be created, containing all +files, so that `GET /index.html` or `GET /assets/css/tyle.css` or `GET +/website-demo-image/mobile.png` will be correctly resolved. + +In the next step, the `vcard-personal-portfolio.pack` should be used by +[web-static-pack](https://crates.io/crates/web-static-pack) +(the loader part) to serve it from your app. + +## Using as a library +When using as a library, you are most likely willing to create a loadable +(by the loader) `pack`, by using [pack::Builder]. + +You will need to add [file_pack_path::FilePackPath] (file + path) objects to +the builder, which you can obtain by: +- Manually constructing the object from [common::pack_path::PackPath] and + [common::file::File] (obtained from fs [file::build_from_path] or memory + slice [file::build_from_content]). +- Reading single file with [file_pack_path::FilePackPath::build_from_path]. +- Automatic search through fs with [directory::search]. + +When all files are added to the builder, you will need to finalize it and +either write to fs (to have it included in your target application) with +[pack::store_file] or (mostly for test purposes) serialize to memory with +[pack::store_memory]. + +#### Examples +This example will do exactly the same as one for application scenario: +```rust + +// start with empty pack builder +let mut pack = Builder::new(); + +// add files with directory search and default options +pack.file_pack_paths_add(search( + &PathBuf::from("vcard-personal-portfolio"), + &SearchOptions::default(), + &BuildFromPathOptions::default(), +)?)?; + +// finalize the builder, obtain pack +let pack = pack.finalize(); + +// store (serialize `pack` to the fs) to be included in the target app +store_file(&pack, &PathBuf::from("vcard-personal-portfolio.pack"))?; +``` + +For more examples browse through modules of this crate. + +License: MIT diff --git a/packer/src/directory.rs b/packer/src/directory.rs new file mode 100644 index 0000000..820a45e --- /dev/null +++ b/packer/src/directory.rs @@ -0,0 +1,104 @@ +//! Directory helpers. Contains [search] function, used to gather files from +//! directory recursively. + +use crate::{file, file_pack_path}; +use anyhow::{Context, Error}; +use std::path::Path; +use walkdir::WalkDir; + +/// Settings for [search] function. +/// +/// If not sure what to set here, use [Default]. +#[derive(Debug)] +pub struct SearchOptions { + /// Whether to follow links while traversing directories. + pub follow_links: bool, +} +impl Default for SearchOptions { + fn default() -> Self { + Self { follow_links: true } + } +} + +/// Searches fs recursively and builds [file_pack_path::FilePackPath] for each +/// file. +/// +/// Traverses directory specified in `path` using [SearchOptions]. Builds all +/// found files as [file_pack_path::FilePackPath] using +/// [file::BuildFromPathOptions]. Paths are created by stripping `path` from +/// full file path. +/// +/// # Examples +/// +/// ``` +/// # use anyhow::Error; +/// # use std::{collections::HashMap, path::PathBuf}; +/// # use web_static_pack_packer::{ +/// # directory::{search, SearchOptions}, +/// # file::BuildFromPathOptions, +/// # }; +/// # +/// # fn main() -> Result<(), Error> { +/// # +/// // traverse directory from tests +/// let files = search( +/// &PathBuf::from(env!("CARGO_MANIFEST_DIR")) +/// .parent() +/// .unwrap() +/// .join("tests") +/// .join("data") +/// .join("vcard-personal-portfolio") +/// .join("assets"), +/// &SearchOptions::default(), +/// &BuildFromPathOptions::default(), +/// )?; +/// +/// // group files into hashmap {pack_path: file} +/// let files_by_path = files +/// .into_vec() +/// .into_iter() +/// .map(|file_pack_path| (file_pack_path.pack_path, file_pack_path.file)) +/// .collect::>(); +/// +/// // verify necessary files were added +/// assert!(files_by_path.contains_key("/css/style.css")); +/// assert!(files_by_path.contains_key("/js/script.js")); +/// assert!(!files_by_path.contains_key("/index.html")); +/// # +/// # Ok(()) +/// # } +/// ``` +pub fn search( + path: &Path, + options: &SearchOptions, + file_build_options: &file::BuildFromPathOptions, +) -> Result, Error> { + let file_paths = WalkDir::new(path) + .follow_links(options.follow_links) + .into_iter() + .map(|file_entry| { + // detect search errors + let file_entry = file_entry?; + + // we are interested in files only + // if follow_links is true, this will be resolved as link target + if !file_entry.file_type().is_file() { + return Ok(None); + } + + // build file + let file_pack_path = file_pack_path::FilePackPath::build_from_path( + file_entry.path(), + path, + file_build_options, + ) + .with_context(|| file_entry.path().to_string_lossy().into_owned())?; + + // yield for processing + Ok(Some(file_pack_path)) + }) + .filter_map(|entry_result| entry_result.transpose()) // strips Ok(None) + .collect::, Error>>()?; + + Ok(file_paths) +} diff --git a/packer/src/file.rs b/packer/src/file.rs new file mode 100644 index 0000000..26c699e --- /dev/null +++ b/packer/src/file.rs @@ -0,0 +1,379 @@ +//! File helpers. Contains [build_from_path] and [build_from_content] functions +//! to create a [File] from fs / memory content. + +use crate::common::{cache_control::CacheControl, file::File}; +use anyhow::Error; +use brotli::enc::BrotliEncoderParams; +use flate2::{write::GzEncoder, Compression}; +use sha3::{Digest, Sha3_256}; +use std::{ + fs, + io::{Cursor, Write}, + path::Path, +}; + +/// Options when preparing file in [build_from_path]. +/// +/// If not sure what to set here, use [Default]. +#[derive(Debug)] +pub struct BuildFromPathOptions { + /// Try adding gzipped version of file. If set to true, it may still not be + /// added (ex. in case gzipped version is larger than raw). + pub use_gzip: bool, + /// Try adding brotli version of file. If set to true, it may still not be + /// added (ex. in case gzipped version is larger than raw). + pub use_brotli: bool, + + /// Override `content-type` header for this file. + pub content_type_override: Option, + /// Override [CacheControl] for this file. + pub cache_control_override: Option, +} +impl Default for BuildFromPathOptions { + fn default() -> Self { + Self { + use_gzip: true, + use_brotli: true, + content_type_override: None, + cache_control_override: None, + } + } +} + +/// Creates a [File] by reading file from fs, specified by `path`. +/// +/// Inside file will be read, `content-type` determined +/// from extension and then passed to [build_from_content]. +/// +/// # Examples +/// +/// ``` +/// # use anyhow::{anyhow, Error}; +/// # use std::path::PathBuf; +/// # use web_static_pack_packer::file::{build_from_path, BuildFromPathOptions}; +/// # +/// # fn main() -> Result<(), Error> { +/// # +/// let file = build_from_path( +/// &PathBuf::from(env!("CARGO_MANIFEST_DIR")) +/// .parent() +/// .ok_or_else(|| anyhow!("missing parent"))? +/// .join("tests") +/// .join("data") +/// .join("vcard-personal-portfolio") +/// .join("index.html"), +/// &BuildFromPathOptions::default(), +/// )?; +/// assert_eq!(file.content_type, "text/html; charset=utf-8"); +/// # +/// # Ok(()) +/// # } +/// ``` +pub fn build_from_path( + path: &Path, + options: &BuildFromPathOptions, +) -> Result { + // read content + let content = content_from_path(path)?; + + // use user provided content type if set, otherwise guess from path + let content_type = if let Some(content_type) = &options.content_type_override { + content_type.clone() + } else { + content_type_from_path(path) + }; + + // pass to inner builder + let file = build_from_content( + content, + content_type, + &BuildFromContentOptions { + use_gzip: options.use_gzip, + use_brotli: options.use_brotli, + cache_control_override: options.cache_control_override, + }, + ); + + Ok(file) +} + +/// Options when preparing file in [build_from_content]. +/// +/// If not sure what to set here, use [Default]. +#[derive(Debug)] +pub struct BuildFromContentOptions { + /// Try adding gzipped version of content. If set to true, it may still not + /// be added (ex. in case gzipped version is larger than raw). + pub use_gzip: bool, + /// Try adding brotli version of content. If set to true, it may still not + /// be added (ex. in case gzipped version is larger than raw). + pub use_brotli: bool, + + /// Override [CacheControl] for this file. + pub cache_control_override: Option, +} +impl Default for BuildFromContentOptions { + fn default() -> Self { + Self { + use_gzip: true, + use_brotli: true, + cache_control_override: None, + } + } +} + +/// Creates a [File] from provided raw content and `content-type`. +/// +/// Inside compressed versions will be created (according to options), `ETag` +/// calculated and [CacheControl] set. +/// +/// When setting `content_type` remember to set charset for text files, eg. +/// `text/plain; charset=utf-8`. +/// +/// # Examples +/// +/// ``` +/// # use anyhow::Error; +/// # use std::path::PathBuf; +/// # use web_static_pack_packer::file::{build_from_content, BuildFromContentOptions}; +/// # +/// # fn main() -> Result<(), Error> { +/// # +/// let file = build_from_content( +/// Box::new(*b"Hello World!"), +/// "text/html; charset=utf-8".to_owned(), +/// &BuildFromContentOptions::default(), +/// ); +/// assert!(file.content_gzip.is_none()); // too short for gzip +/// assert!(file.content_brotli.is_none()); // too short for gzip +/// assert_eq!(&*file.content, b"Hello World!"); +/// assert_eq!(file.content_type, "text/html; charset=utf-8"); +/// # +/// # Ok(()) +/// # } +/// ``` +pub fn build_from_content( + content: Box<[u8]>, + content_type: String, + options: &BuildFromContentOptions, +) -> File { + let content_gzip = if options.use_gzip { + content_gzip_from_content(&content) + } else { + None + }; + let content_brotli = if options.use_brotli { + content_brotli_from_content(&content) + } else { + None + }; + + let etag = etag_from_content(&content); + let cache_control = if let Some(cache_control) = &options.cache_control_override { + *cache_control + } else { + // we assume, that content is "static" and provide max caching opportunity + CacheControl::MaxCache + }; + + File { + content, + content_gzip, + content_brotli, + content_type, + etag, + cache_control, + } +} + +/// Builds content by reading given file. +fn content_from_path(path: &Path) -> Result, Error> { + let content = fs::read(path)?.into_boxed_slice(); + + Ok(content) +} +/// Builds gzip compressed version of `content`. +/// +/// Returns [None] if there is no sense in having compressed version in `pack` +/// (eg. compressed is larger than raw). +fn content_gzip_from_content(content: &[u8]) -> Option> { + // no sense in compressing empty files + if content.is_empty() { + return None; + } + + let mut content_gzip = GzEncoder::new(Vec::new(), Compression::best()); + content_gzip.write_all(content).unwrap(); + let content_gzip = content_gzip.finish().unwrap().into_boxed_slice(); + + // if gzip is longer then original value - it makes no sense to store it + if content_gzip.len() >= content.len() { + return None; + } + + Some(content_gzip) +} +/// Builds brotli compressed version of `content`. +/// +/// Returns [None] if there is no sense in having compressed version in `pack` +/// (eg. compressed is larger than raw). +fn content_brotli_from_content(content: &[u8]) -> Option> { + // no sense in compressing empty files + if content.is_empty() { + return None; + } + + let mut content_cursor = Cursor::new(content); + let mut content_brotli = Vec::new(); + let content_brotli_length = brotli::BrotliCompress( + &mut content_cursor, + &mut content_brotli, + &BrotliEncoderParams::default(), + ) + .unwrap(); + let content_brotli = content_brotli.into_boxed_slice(); + assert!(content_brotli.len() == content_brotli_length); + + // if brotli is longer then original value - it makes no sense to store it + if content_brotli.len() >= content.len() { + return None; + } + + Some(content_brotli) +} + +/// Guesses `content-type` from file path. +/// +/// Only path is used, file content is not read. If file type cannot be guessed, +/// returns "application/octet-stream". For text files (eg. plain, html, css, +/// js, etc) it assumes utf-8 encoding. +fn content_type_from_path(path: &Path) -> String { + let mut content_type = mime_guess::from_path(path) + .first_or_octet_stream() + .as_ref() + .to_owned(); + + // NOTE: temporary workaround for https://github.com/abonander/mime_guess/issues/90 + if content_type == "application/javascript" { + content_type = "text/javascript".to_owned(); + } + + if content_type.starts_with("text/") { + content_type.push_str("; charset=utf-8"); + } + content_type +} +/// Calculates `ETag` header from file contents. +fn etag_from_content(content: &[u8]) -> String { + let mut etag = Sha3_256::new(); + etag.update(content); + let etag = etag.finalize(); + let etag = format!("\"{:x}\"", &etag); // `ETag` as "quoted" hex sha3. Quote is required by standard + etag +} + +#[cfg(test)] +mod test { + use super::{ + build_from_content, content_brotli_from_content, content_gzip_from_content, + content_type_from_path, etag_from_content, BuildFromContentOptions, + }; + use crate::common::file::File; + use std::path::{Path, PathBuf}; + use test_case::test_case; + + #[test] + fn build_from_content_returns_expected() { + let content_original = b"lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum"; + let content_type_original = "text/plain; charset=utf-8"; + + let file = build_from_content( + Box::new(*content_original), + content_type_original.to_owned(), + &BuildFromContentOptions::default(), + ); + + let File { + content, + content_gzip, + content_brotli, + content_type, + // implementation dependant + // etag, + // cache_control, + .. + } = file; + assert_eq!(&*content, content_original); + assert_eq!(&*content_gzip.unwrap(), b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x02\xff\x95\xc6\x41\x09\x00\x00\x08\x03\xc0\x2a\x2b\xe7\x43\xd8\x50\x14\xfb\x9b\x61\xbf\x63\x4d\x08\xd9\x7b\x02\x3d\x3f\x1e\x08\x7c\xb8\x3b\x00\x00\x00"); + assert_eq!(&*content_brotli.unwrap(), b"\x1b\x3a\x00\xf8\x1d\xa9\x53\x9f\xbb\x70\x9d\xc6\xf6\x06\xa7\xda\xe4\x1a\xa4\x6c\xae\x4e\x18\x15\x0b\x98\x56\x70\x03"); + assert_eq!(content_type, content_type_original); + + // implementation dependant + // assert_eq!(etag, ""); + // assert_eq!(cache_control, CacheControl::MaxCache); + } + + #[test] + fn empty_should_not_be_compressed() { + assert!(content_gzip_from_content(&[]).is_none()); + assert!(content_brotli_from_content(&[]).is_none()); + } + + #[test] + fn content_gzip_from_content_returns_expected() { + assert_eq!( + content_gzip_from_content(b"lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum").as_deref(), + Some(b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x02\xff\x95\xc6\x41\x09\x00\x00\x08\x03\xc0\x2a\x2b\xe7\x43\xd8\x50\x14\xfb\x9b\x61\xbf\x63\x4d\x08\xd9\x7b\x02\x3d\x3f\x1e\x08\x7c\xb8\x3b\x00\x00\x00".as_slice()) + ); + } + + #[test] + fn content_brotli_from_content_returns_expected() { + assert_eq!( + content_brotli_from_content(b"lorem ipsum lorem ipsum lorem ipsum lorem ipsum lorem ipsum").as_deref(), + Some(b"\x1b\x3a\x00\xf8\x1d\xa9\x53\x9f\xbb\x70\x9d\xc6\xf6\x06\xa7\xda\xe4\x1a\xa4\x6c\xae\x4e\x18\x15\x0b\x98\x56\x70\x03".as_slice()) + ); + } + + #[test] + fn etag_from_content_returns_expected() { + // two identical payloads should produce identical `ETag` + // two different payloads should produce different `ETag` + + assert_eq!( + etag_from_content(b"lorem ipsum"), + etag_from_content(b"lorem ipsum") + ); + assert_ne!( + etag_from_content(b"lorem ipsum"), + etag_from_content(b"ipsum lorem") + ); + } + + #[test_case( + &PathBuf::from("a.html"), + "text/html; charset=utf-8"; + "html file" + )] + #[test_case( + &PathBuf::from("directory/styles.css"), + "text/css; charset=utf-8"; + "css file in directory" + )] + #[test_case( + &PathBuf::from("/root/dir/script.00ff00.js"), + "text/javascript; charset=utf-8"; + "js file, full path, with some hex in stem" + )] + #[test_case( + &PathBuf::from("C:\\Users\\example\\Images\\SomeImage.webp"), + "image/webp"; + "webp image in windows style path format" + )] + fn content_type_from_path_returns_expected( + path: &Path, + expected: &str, + ) { + assert_eq!(content_type_from_path(path), expected); + } +} diff --git a/packer/src/file_pack_path.rs b/packer/src/file_pack_path.rs new file mode 100644 index 0000000..eac9b96 --- /dev/null +++ b/packer/src/file_pack_path.rs @@ -0,0 +1,98 @@ +//! Pack path (file and pack path combined) helpers. Contains [FilePackPath], a +//! combination of [File] and [PackPath]. + +use crate::{ + common::{file::File, pack_path::PackPath}, + file, pack_path, +}; +use anyhow::{Context, Error}; +use std::path::Path; + +/// [File] (describing the content) + its [PackPath] (describing uri that the +/// file will be accessible at). +/// +/// This is the main item added to the `pack`. +/// +/// It can be manually created by passing `file` and `pack_path` fields or using +/// associated helpers methods. +#[derive(Debug)] +pub struct FilePackPath { + /// The file. + pub file: File, + + /// The path inside the `pack`, corresponding to http path parameter. + pub pack_path: PackPath, +} +impl FilePackPath { + /// Creates [self] by reading file relative to given base directory. + /// + /// Given file path (`path`) to read and base directory path creates a + /// [self] by preparing: + /// - [File] with [file::build_from_path] using + /// [file::BuildFromPathOptions]. + /// - [PackPath] with [pack_path::from_file_base_relative_path] (as relative + /// path between `path` and `base_directory_path`). + /// + /// # Examples + /// + /// ``` + /// # use anyhow::{anyhow, Error}; + /// # use std::path::PathBuf; + /// # use web_static_pack_packer::{file::BuildFromPathOptions, file_pack_path::FilePackPath}; + /// # + /// # fn main() -> Result<(), Error> { + /// # + /// // base directory + /// let base_directory = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + /// .parent() + /// .ok_or_else(|| anyhow!("missing parent"))? + /// .join("tests") + /// .join("data") + /// .join("vcard-personal-portfolio"); + /// + /// // file in base/directory/path/index.html should end up as /index.html + /// let file_pack_path_1 = FilePackPath::build_from_path( + /// &base_directory.join("index.html"), + /// &base_directory, + /// &BuildFromPathOptions::default(), + /// )?; + /// assert_eq!( + /// file_pack_path_1.file.content_type, + /// "text/html; charset=utf-8" + /// ); + /// assert_eq!(&*file_pack_path_1.pack_path, "/index.html"); + /// + /// // file in base/directory/path/website-demo-image/desktop.png should end up as /website-demo-image/desktop.png + /// let file_pack_path_2 = FilePackPath::build_from_path( + /// &base_directory.join("website-demo-image").join("desktop.png"), + /// &base_directory, + /// &BuildFromPathOptions::default(), + /// )?; + /// assert_eq!( + /// file_pack_path_2.file.content_type, + /// "image/png" + /// ); + /// assert_eq!(&*file_pack_path_2.pack_path, "/website-demo-image/desktop.png"); + /// # + /// # Ok(()) + /// # } + /// ``` + pub fn build_from_path( + path: &Path, + base_directory_path: &Path, + file_options: &file::BuildFromPathOptions, + ) -> Result { + // strip prefix, so entry_path is relative to search root + let file_base_relative_path = path + .strip_prefix(base_directory_path) + .context("resolve file_base_relative_path")?; + + // create path prefix + let pack_path = pack_path::from_file_base_relative_path(file_base_relative_path)?; + + // read and build file + let file = file::build_from_path(path, file_options)?; + + Ok(Self { file, pack_path }) + } +} diff --git a/packer/src/lib.rs b/packer/src/lib.rs new file mode 100644 index 0000000..203e567 --- /dev/null +++ b/packer/src/lib.rs @@ -0,0 +1,146 @@ +//! web-static-pack-packer is the "builder" (1st stage) part of the +//! [web-static-pack](https://github.com/peku33/web-static-pack) +//! project. See project page for a general idea how two parts cooperate. +//! +//! The goal of the packer part is to collect your directories / files / memory +//! slices, precalculate things like `ETag`, compressed versions (`gzip`, +//! `brotli`) and store them as a single file (called `pack`). Your target +//! application will include (ex. with +//! +//! ) and "load" / "parse" `pack` during runtime using +//! [web-static-pack](https://crates.io/crates/web-static-pack) +//! (the loader part) and (possibly) serve it with a web server of your choice. +//! +//! This crate is usually used in build script / CI / build.rs stage, not in +//! your target application. It's used to create a `pack` from list of files +//! (like your GUI app / images / other assets) to be later loaded by your app +//! and served with a web server. +//! +//! This crate can be used in two ways: +//! - As a standalone application, installed with `cargo install`, this is the +//! preferred way if you are using build scripts, CI pipeline etc. +//! - As a library, imported to your project, this is a way to go if you want to +//! use it in build.rs of your target application or go with some really +//! custom approach +//! +//! # Using as a standalone application +//! +//! ## Install (or update to matching version) +//! - Either install it with `$ cargo install web-static-pack-packer` and use +//! shell command `$ web-static-pack-packer [PARAMS]...` +//! - Or clone repo, go into `packer` directory, `$ cargo run --release -- +//! [PARAMS]...`. (please note `--` which marks end of arguments for cargo run +//! and beginning of arguments for the application). +//! +//! For the purpose of this example, the first option is assumed, with +//! `web-static-pack-packer` command available. +//! +//! ## Create a `pack` +//! `web-static-pack-packer` provides up to date documentation with `$ +//! web-static-pack-packer --help`. Application is built around subcommands to +//! cover basic scenarios: +//! - `directory-single [OPTIONS] ` +//! will create a `pack` from a single directory. This is the most common +//! scenario, for example when you have a web application built into +//! `./gui/build` directory and you want to have it served with your app. +//! - `files-cmd [OPTIONS] +//! [INPUT_FILE_PATHS]...` lets you specify all files from command line in +//! `xargs` style. base directory path is used as a root for building relative +//! paths inside a `pack`. +//! - `files-stdin [OPTIONS] ` +//! lets you provide list of files from stdin. +//! +//! ### Examples +//! Let's say you have a `vcard-personal-portfolio` directory containing your +//! web project (available in tests/data/ in repository). Directory structure +//! looks like: +//! ```text +//! vcard-personal-portfolio +//! | index.html +//! | index.txt +//! +---assets +//! | +---css +//! | | style.css +//! | +---images +//! | | .png +//! | \---js +//! | script.js +//! \---website-demo-image +//! desktop.png +//! mobile.png +//! ``` +//! By running: +//! ```text +//! $ web-static-pack-packer \ +//! directory-single \ +//! ./vcard-personal-portfolio \ +//! ./vcard-personal-portfolio.pack +//! ``` +//! a new file `vcard-personal-portfolio.pack` will be created, containing all +//! files, so that `GET /index.html` or `GET /assets/css/tyle.css` or `GET +//! /website-demo-image/mobile.png` will be correctly resolved. +//! +//! In the next step, the `vcard-personal-portfolio.pack` should be used by +//! [web-static-pack](https://crates.io/crates/web-static-pack) +//! (the loader part) to serve it from your app. +//! +//! # Using as a library +//! When using as a library, you are most likely willing to create a loadable +//! (by the loader) `pack`, by using [pack::Builder]. +//! +//! You will need to add [file_pack_path::FilePackPath] (file + path) objects to +//! the builder, which you can obtain by: +//! - Manually constructing the object from [common::pack_path::PackPath] and +//! [common::file::File] (obtained from fs [file::build_from_path] or memory +//! slice [file::build_from_content]). +//! - Reading single file with [file_pack_path::FilePackPath::build_from_path]. +//! - Automatic search through fs with [directory::search]. +//! +//! When all files are added to the builder, you will need to finalize it and +//! either write to fs (to have it included in your target application) with +//! [pack::store_file] or (mostly for test purposes) serialize to memory with +//! [pack::store_memory]. +//! +//! ### Examples +//! This example will do exactly the same as one for application scenario: +//! ```no_run +//! # use anyhow::Error; +//! # use std::path::PathBuf; +//! # use web_static_pack_packer::{ +//! # directory::{search, SearchOptions}, +//! # file::BuildFromPathOptions, +//! # pack::{store_file, Builder}, +//! # }; +//! +//! # fn main() -> Result<(), Error> { +//! // start with empty pack builder +//! let mut pack = Builder::new(); +//! +//! // add files with directory search and default options +//! pack.file_pack_paths_add(search( +//! &PathBuf::from("vcard-personal-portfolio"), +//! &SearchOptions::default(), +//! &BuildFromPathOptions::default(), +//! )?)?; +//! +//! // finalize the builder, obtain pack +//! let pack = pack.finalize(); +//! +//! // store (serialize `pack` to the fs) to be included in the target app +//! store_file(&pack, &PathBuf::from("vcard-personal-portfolio.pack"))?; +//! # Ok(()) +//! # } +//! ``` +//! +//! For more examples browse through modules of this crate. + +#![allow(clippy::new_without_default)] +#![warn(missing_docs)] + +pub use web_static_pack_common as common; + +pub mod directory; +pub mod file; +pub mod file_pack_path; +pub mod pack; +pub mod pack_path; diff --git a/packer/src/main.rs b/packer/src/main.rs index 83675e3..e94df8b 100644 --- a/packer/src/main.rs +++ b/packer/src/main.rs @@ -1,64 +1,191 @@ -//! # web-static-pack-packer -//! Executable to build packs for web-static-pack crate -//! See main crate for details -//! -//! [![docs.rs](https://docs.rs/web-static-pack-packer/badge.svg)](https://docs.rs/web-static-pack-packer) -//! -//! ## Usage -//! 1. Install (`cargo install web-static-pack-packer`) or run locally (`cargo run`) -//! 2. Provide positional arguments: -//! - `` - the directory to pack -//! - `` - name of the build pack -//! - `[root_pach]` - relative path to build pack paths with. use the same as `path` to have all paths in pack root -//! 3. Use `` file with `web-static-pack` (loader) - -mod packer; +//! Main packer executable, to be used as cli tool. For help run this command +//! with `-h`. + +#![warn(missing_docs)] use anyhow::{Context, Error}; -use log::LevelFilter; -use packer::Pack; -use simple_logger::SimpleLogger; -use std::path::PathBuf; +use clap::{Args, Parser, Subcommand}; +use std::{io::stdin, path::PathBuf}; +use web_static_pack_packer::{directory, file, file_pack_path, pack}; + +#[derive(Parser, Debug)] +#[command(version, about)] +struct Arguments { + #[command(subcommand)] + pub command: Command, +} + +#[derive(Args, Debug)] +struct FileGlobalOptions { + /// Add gzipped version of file to the `pack`. If not set, uses sane + /// defaults. + #[arg(long)] + pub use_gzip: Option, + /// Add brotli compressed version of file to the `pack`. If not set, uses + /// sane defaults. + #[arg(long)] + pub use_brotli: Option, +} +impl FileGlobalOptions { + pub fn into_file_build_from_path_options(self) -> file::BuildFromPathOptions { + let mut file_build_from_path_options = file::BuildFromPathOptions::default(); + + if let Some(use_gzip) = self.use_gzip { + file_build_from_path_options.use_gzip = use_gzip; + } + + if let Some(use_brotli) = self.use_brotli { + file_build_from_path_options.use_brotli = use_brotli; + } + + file_build_from_path_options + } +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Creates a single `pack` from recursively searching through single + /// directory. + /// + /// Please note that all found files are added, including hidden files + /// (starting with `.` on unix and with certain flags on windows). + DirectorySingle { + #[command(flatten)] + file_global_options: FileGlobalOptions, + + /// Whether to follow links while traversing directories. If not set, + /// uses sane defaults. + #[arg(long)] + follow_links: Option, + + /// The directory to be added to the `pack`. + input_directory_path: PathBuf, + + /// Output `pack` path. + output_file_path: PathBuf, + }, + /// Creates `pack` from options and list of files supplied through command + /// line. + FilesCmd { + #[command(flatten)] + file_global_options: FileGlobalOptions, + + /// Output `pack` path. + output_file_path: PathBuf, + + /// Base directory path, used to resolve relative for file inside + /// `pack`. All added files must be inside this directory. + input_base_directory_path: PathBuf, + + /// List of files to be added to the `pack`. + input_file_paths: Vec, + }, + /// Creates `pack` from options supplied through command line and list of + /// files from stdin. + FilesStdin { + #[command(flatten)] + file_global_options: FileGlobalOptions, + + /// Base directory path, used to resolve relative for file inside + /// `pack`. All added files must be inside this directory. + input_base_directory_path: PathBuf, + + /// Output `pack` path. + output_file_path: PathBuf, + }, +} fn main() -> Result<(), Error> { - SimpleLogger::new() - .env() - .with_level(LevelFilter::Info) - .init() - .unwrap(); - - let matches = clap::builder::Command::new("web-static-pack packer") - .arg( - clap::builder::Arg::new("path") - .help("the directory to pack") - .required(true) - .value_parser(clap::builder::PathBufValueParser::new()), - ) - .arg( - clap::builder::Arg::new("output_file") - .help("name of the build pack") - .required(true) - .value_parser(clap::builder::PathBufValueParser::new()) - , - ) - .arg( - clap::builder::Arg::new("root_path") - .help("relative path to build pack paths with. use the same as `path` to have all paths in pack root") - .required(false) - .value_parser(clap::builder::PathBufValueParser::new()) - ) - .get_matches(); - - let path = matches.get_one::("path").cloned().unwrap(); - let output_file = matches.get_one::("output_file").cloned().unwrap(); - let root_path = matches - .get_one::("root_path") - .cloned() - .unwrap_or_else(PathBuf::new); - - let mut pack = Pack::new(); - pack.directory_add(&path, &root_path) - .context("directory_add")?; - pack.store(&output_file).context("store")?; + let arguments = Arguments::parse(); + + match arguments.command { + Command::DirectorySingle { + file_global_options, + follow_links, + input_directory_path, + output_file_path, + } => { + let mut directory_search_options = directory::SearchOptions::default(); + if let Some(follow_links) = follow_links { + directory_search_options.follow_links = follow_links; + } + + let file_build_from_path_options = + file_global_options.into_file_build_from_path_options(); + + let mut pack_builder = pack::Builder::new(); + for file_pack_path in directory::search( + &input_directory_path, + &directory_search_options, + &file_build_from_path_options, + )? { + // TODO: provide information which file produced error + pack_builder.file_pack_path_add(file_pack_path)? + } + + let pack = pack_builder.finalize(); + pack::store_file(&pack, &output_file_path)?; + } + Command::FilesCmd { + file_global_options, + output_file_path, + input_base_directory_path, + input_file_paths, + } => { + let file_build_from_path_options = + file_global_options.into_file_build_from_path_options(); + + let mut pack_builder = pack::Builder::new(); + for input_file_path in input_file_paths { + // TODO: move this into try block with shared context + let input_file_error_context = || input_file_path.to_string_lossy().into_owned(); + + let file_pack_path = file_pack_path::FilePackPath::build_from_path( + &input_file_path, + &input_base_directory_path, + &file_build_from_path_options, + ) + .with_context(input_file_error_context)?; + + pack_builder + .file_pack_path_add(file_pack_path) + .with_context(input_file_error_context)?; + } + + let pack = pack_builder.finalize(); + pack::store_file(&pack, &output_file_path)?; + } + Command::FilesStdin { + file_global_options, + input_base_directory_path, + output_file_path, + } => { + let file_build_from_path_options = + file_global_options.into_file_build_from_path_options(); + + let mut pack_builder = pack::Builder::new(); + for input_file_path in stdin().lines() { + let input_file_path = PathBuf::from(input_file_path?); + + // TODO: move this into try block with shared context + let input_file_error_context = || input_file_path.to_string_lossy().into_owned(); + + let file_pack_path = file_pack_path::FilePackPath::build_from_path( + &input_file_path, + &input_base_directory_path, + &file_build_from_path_options, + ) + .with_context(input_file_error_context)?; + + pack_builder + .file_pack_path_add(file_pack_path) + .with_context(|| input_file_path.to_string_lossy().into_owned())?; + } + + let pack = pack_builder.finalize(); + pack::store_file(&pack, &output_file_path)?; + } + } + Ok(()) } diff --git a/packer/src/pack.rs b/packer/src/pack.rs new file mode 100644 index 0000000..301e865 --- /dev/null +++ b/packer/src/pack.rs @@ -0,0 +1,113 @@ +//! Pack helpers. Contains [Builder], builder for [Pack]. + +use crate::{ + common::{file::File, pack::Pack, pack_path::PackPath, PACK_FILE_MAGIC, PACK_FILE_VERSION}, + file_pack_path::FilePackPath, +}; +use anyhow::{bail, Error}; +use rkyv::{ + ser::{ + serializers::{AllocScratch, CompositeSerializer, WriteSerializer}, + Serializer, + }, + AlignedVec, Infallible, +}; +use std::{ + collections::{hash_map, HashMap}, + fs, io, + path::Path, +}; + +/// Main builder for `pack`. Inside it keeps list of [File] under respective +/// [PackPath]. +#[derive(Debug)] +pub struct Builder { + files_by_pack_path: HashMap, +} +impl Builder { + /// Creates empty [self] to be filled with files. + pub fn new() -> Self { + let files_by_pack_path = HashMap::::new(); + + Self { files_by_pack_path } + } + + /// Adds file to the `pack`. + pub fn file_pack_path_add( + &mut self, + file_pack_path: FilePackPath, + ) -> Result<(), Error> { + let entry = match self.files_by_pack_path.entry(file_pack_path.pack_path) { + hash_map::Entry::Occupied(_entry) => { + bail!("file on specified path already exist"); + } + hash_map::Entry::Vacant(entry) => entry, + }; + + entry.insert(file_pack_path.file); + + Ok(()) + } + + /// Adds collection of files to the `pack`. + pub fn file_pack_paths_add( + &mut self, + file_pack_paths: impl IntoIterator, + ) -> Result<(), Error> { + file_pack_paths + .into_iter() + .try_for_each(|file_pack_path| self.file_pack_path_add(file_pack_path))?; + + Ok(()) + } + + /// Finalizes to builder, returning built [Pack]. + pub fn finalize(self) -> Pack { + Pack { + files_by_path: self.files_by_pack_path, + } + } +} + +fn store( + pack: &Pack, + mut writer: impl io::Write, +) -> Result<(), Error> { + // NOTE: we rely on `pack` being 16-aligned two u64 at the beginning keeps + // alignment unchanged, but adding anything here will break it. + writer.write_all(&PACK_FILE_MAGIC.to_ne_bytes())?; + writer.write_all(&PACK_FILE_VERSION.to_ne_bytes())?; + + let mut inner_serializer = CompositeSerializer::new( + WriteSerializer::new(&mut writer), + AllocScratch::new(), + Infallible, + ); + inner_serializer.serialize_value(pack)?; + + Ok(()) +} + +/// Serializes `pack` to [AlignedVec]. Serialized data can be used with `load` +/// method of loader. +pub fn store_memory(pack: &Pack) -> Result { + let mut buffer = AlignedVec::new(); + store(pack, &mut buffer)?; + Ok(buffer) +} + +/// Serializes `pack` to given file path Serialized data can be used with `load` +/// method of loader. +pub fn store_file( + pack: &Pack, + path: &Path, +) -> Result<(), Error> { + let mut file = fs::File::create(path)?; + + store(pack, &mut file)?; + + file.sync_all()?; + drop(file); + + Ok(()) +} diff --git a/packer/src/pack_path.rs b/packer/src/pack_path.rs new file mode 100644 index 0000000..9e7ad3a --- /dev/null +++ b/packer/src/pack_path.rs @@ -0,0 +1,94 @@ +//! Pack path helpers. Contains [from_file_base_relative_path] that creates pack +//! paths from fs paths. + +use crate::common::pack_path::PackPath; +use anyhow::{anyhow, ensure, Error}; +use std::{ + iter, + path::{Component, Path}, +}; + +/// Creates pack path (eg. "/dir1/dir2/file.html") from relative fs path (eg. +/// "workdir\\dir1\\dir2\\file.html"). +/// +/// # Examples +/// +/// ``` +/// # use anyhow::Error; +/// # use std::path::PathBuf; +/// # use web_static_pack_packer::{ +/// # common::pack_path::PackPath, pack_path::from_file_base_relative_path, +/// # }; +/// # +/// # fn main() -> Result<(), Error> { +/// # +/// assert_eq!( +/// from_file_base_relative_path(&PathBuf::from("path\\to\\file.txt"))?, +/// PackPath::from_string("/path/to/file.txt".to_owned()), +/// ); +/// # +/// # Ok(()) +/// # } +/// ``` +pub fn from_file_base_relative_path(file_base_relative_path: &Path) -> Result { + assert!(file_base_relative_path.is_relative()); + + // list of path components, eg. ["dir1", "dir2", "file.bin"] + let file_base_relative_path_components = file_base_relative_path + .components() + .map(|component| { + // we cannot handle things like '/' or '.' or '..' here + ensure!( + matches!(component, Component::Normal(_)), + "relative path must not contain only standard path items, got {:?}", + component + ); + + component + .as_os_str() + .to_str() + .ok_or_else(|| anyhow!("cannot convert path component to string")) + }) + .collect::, Error>>()?; + + // we add empty element at the beginning to have path starting with / + let pack_path_string = itertools::join( + iter::once("").chain(file_base_relative_path_components), + "/", + ); + + // convert into pack path + let pack_path = PackPath::from_string(pack_path_string); + + Ok(pack_path) +} + +#[cfg(test)] +mod test { + use super::from_file_base_relative_path; + use crate::common::pack_path::PackPath; + use std::path::{Path, PathBuf}; + use test_case::test_case; + + #[test_case( + &PathBuf::from("somefile"), + &PackPath::from_string("/somefile".to_owned()); + "base file path without prefix" + )] + #[test_case( + &PathBuf::from("linux/like/relative/path.html"), + &PackPath::from_string("/linux/like/relative/path.html".to_owned()); + "linux like relative path" + )] + #[test_case( + &PathBuf::from("Project\\MyApp\\Application.js"), + &PackPath::from_string("/Project/MyApp/Application.js".to_owned()); + "windows relative path" + )] + fn from_file_base_relative_path_returns_expected( + path: &Path, + expected: &PackPath, + ) { + assert_eq!(&from_file_base_relative_path(path).unwrap(), expected); + } +} diff --git a/packer/src/packer.rs b/packer/src/packer.rs deleted file mode 100644 index 9873e16..0000000 --- a/packer/src/packer.rs +++ /dev/null @@ -1,226 +0,0 @@ -use anyhow::{anyhow, Context, Error}; -use bytes::Bytes; -use libflate::gzip; -use sha3::{Digest, Sha3_256}; -use std::{ - collections::LinkedList, - convert::TryInto, - fs, - fs::File, - io::Write, - path::{Component, Path, PathBuf}, -}; -use walkdir::WalkDir; - -struct FileDescriptor { - pack_path: String, - content_type: String, - etag: String, - content: Bytes, - content_gzip: Option, -} -impl FileDescriptor { - fn serialize_into( - &mut self, - write: &mut W, - ) -> Result<(), Error> { - // pack_path - let pack_path_bytes = self.pack_path.as_bytes(); - // pack_path length, u16, 2 bytes - let pack_path_bytes_length: u16 = pack_path_bytes - .len() - .try_into() - .context("pack_path_bytes_length")?; - write - .write_all(&pack_path_bytes_length.to_ne_bytes()) - .context("pack_path_bytes_length")?; - // pack_path, length as above - write - .write_all(pack_path_bytes) - .context("pack_path_bytes")?; - - // content_type - let content_type_bytes = self.content_type.as_bytes(); - // content_type length, u8, 1 byte - let content_type_bytes_length: u8 = content_type_bytes - .len() - .try_into() - .context("content_type_bytes_length")?; - write - .write_all(&content_type_bytes_length.to_ne_bytes()) - .context("content_type_bytes_length")?; - // content_type, length as above - write - .write_all(content_type_bytes) - .context("content_type_bytes")?; - - // etag - let etag_bytes = self.etag.as_bytes(); - // etag length, u8, 1 byte - let etag_bytes_length: u8 = etag_bytes.len().try_into().context("etag_bytes_length")?; - write - .write_all(&etag_bytes_length.to_ne_bytes()) - .context("etag_bytes_length")?; - // etag, length as above - write.write_all(etag_bytes).context("etag_bytes")?; - - // content - // size as u32, should be enough - let content_bytes_length: u32 = self - .content - .len() - .try_into() - .context("content_bytes_length")?; - write - .write_all(&content_bytes_length.to_ne_bytes()) - .context("content_bytes_length")?; - // content, length as above - write.write_all(&self.content).context("content_bytes")?; - - // content_gzip, optional - // size as u32, should be enough - let content_bytes_gzip_length: u32 = match self.content_gzip.as_ref() { - Some(content_gzip) => content_gzip.len().try_into().context("content_gzip")?, - None => 0, - }; - write - .write_all(&content_bytes_gzip_length.to_ne_bytes()) - .context("content_bytes_gzip_length")?; - // content_gzip, if available - if let Some(content_gzip) = self.content_gzip.as_ref() { - write.write_all(content_gzip).context("content_gzip")?; - } - - // All done - Ok(()) - } -} - -pub struct Pack { - file_descriptors: LinkedList, -} -impl Pack { - pub fn new() -> Self { - Pack { - file_descriptors: LinkedList::new(), - } - } - pub fn file_add( - &mut self, - fs_path: PathBuf, - pack_path: String, - ) -> Result<(), Error> { - log::info!( - "Packing file {} -> {}", - fs_path.as_path().to_string_lossy(), - pack_path - ); - - // content - let content = Bytes::from(fs::read(&fs_path).context("content")?); - - // content_gzip - let mut content_gzip = gzip::Encoder::new(Vec::new()).context("content_gzip")?; - content_gzip.write_all(&content).context("content_gzip")?; - let content_gzip = Bytes::from( - content_gzip - .finish() - .into_result() - .context("content_gzip")?, - ); - let content_gzip = if content_gzip.len() < content.len() { - Some(content_gzip) - } else { - None - }; - - // content_type - let mut content_type = mime_guess::from_path(&fs_path) - .first_or_octet_stream() - .as_ref() - .to_owned(); - if content_type.starts_with("text/") { - content_type.push_str("; charset=UTF-8"); - } - - // etag - let mut etag = Sha3_256::new(); - etag.update(&content); - let etag = etag.finalize(); - let etag = format!("\"{:x}\"", &etag); // ETag as "quoted" hex sha3 - - // Info - log::info!( - "Packed {}: content_type={}, etag={}, content.len={}, content_gzip.len={:?}", - pack_path, - content_type, - etag, - content.len(), - content_gzip.as_ref().map(|content_gzip| content_gzip.len()) - ); - - // FileDescriptor - self.file_descriptors.push_back(FileDescriptor { - pack_path, - content_type, - etag, - content, - content_gzip, - }); - - Ok(()) - } - pub fn directory_add( - &mut self, - fs_path: &Path, - pack_path_prefix: &Path, - ) -> Result<(), Error> { - let walk_dir = WalkDir::new(fs_path).follow_links(true); - for entry in walk_dir { - let entry = entry.context("entry")?; - - // Strip directories - if entry.file_type().is_dir() { - continue; - } - - // Extract path from record - let entry_path = entry.into_path(); - - // Strip fs_path from entry path (make it relative to fs_path) - // Add pack_path_prefix - let relative_path = - pack_path_prefix.join(entry_path.strip_prefix(fs_path).context("relative_path")?); - - // Convert directory notation to linux-like - let pack_path_components = relative_path - .components() - .filter(|component| !matches!(component, Component::RootDir)) - .map(|component| { - component - .as_os_str() - .to_str() - .ok_or_else(|| anyhow!("Cannot convert path component to string")) - }) - .collect::, _>>() - .context("pack_path_components")?; - let pack_path = itertools::join([""].iter().chain(pack_path_components.iter()), "/"); - - // Add file to pack - self.file_add(entry_path, pack_path).context("file_add")?; - } - Ok(()) - } - pub fn store( - &mut self, - path: &Path, - ) -> Result<(), Error> { - let mut file = File::create(path).context("file")?; - for file_descriptor in self.file_descriptors.iter_mut() { - file_descriptor - .serialize_into(&mut file) - .context("file_descriptor")?; - } - Ok(()) - } -} diff --git a/rustfmt.toml b/rustfmt.toml index 8e1f965..f9a835b 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -1,3 +1,5 @@ edition = "2021" -fn_args_layout = "Vertical" +fn_params_layout = "Vertical" imports_granularity = "Crate" +wrap_comments = true +format_code_in_doc_comments = true diff --git a/tests/Cargo.toml b/tests/Cargo.toml new file mode 100644 index 0000000..7b8c5b3 --- /dev/null +++ b/tests/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "web-static-pack-tests" +version = "0.5.0-beta.1" +authors = ["Paweł Kubrak "] +edition = "2021" +license = "MIT" +description = "Integration tests & data for web-static-pack crate." +homepage = "https://github.com/peku33/web-static-pack" +repository = "https://github.com/peku33/web-static-pack" +publish = false + +[dependencies] +web-static-pack-common = { version = "0.5.0-beta.1", path = "../common" } +web-static-pack = { version = "0.5.0-beta.1", path = "../loader" } +web-static-pack-packer = { version = "0.5.0-beta.1", path = "../packer" } + +anyhow = "1.0.86" +futures = "0.3.30" +http = "1.1.0" +hyper = { version = "1.4.0", features = ["full"] } +hyper-util = { version = "0.1.6", features = ["full"] } +include_bytes_aligned = "0.1.3" +log = "0.4.22" +memmap2 = "0.9.4" +ouroboros = "0.18.4" +reqwest = { version = "0.12.5", features = ["gzip", "brotli"] } +simple_logger = "5.0.0" +test-case = "3.3.1" +tokio = { version = "1.38.0", features = ["full"] } diff --git a/tests/data/vcard-personal-portfolio.pack b/tests/data/vcard-personal-portfolio.pack new file mode 100644 index 0000000..cfc229b Binary files /dev/null and b/tests/data/vcard-personal-portfolio.pack differ diff --git a/tests/examples/vcard_personal_portfolio_builder.rs b/tests/examples/vcard_personal_portfolio_builder.rs new file mode 100644 index 0000000..f36642e --- /dev/null +++ b/tests/examples/vcard_personal_portfolio_builder.rs @@ -0,0 +1,26 @@ +//! Builds data/vcard-personal-portfolio.pack from data/vcard-personal-portfolio + +#![feature(async_closure)] + +use anyhow::Error; +use simple_logger::SimpleLogger; +use std::path::PathBuf; +use web_static_pack_tests::build_vcard_personal_portfolio_cached; + +fn main() -> Result<(), Error> { + SimpleLogger::new().init().unwrap(); + + let directory = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("data"); + assert!(directory.is_dir()); + + log::trace!("building pack"); + let pack = build_vcard_personal_portfolio_cached(); + + log::trace!("saving pack"); + web_static_pack_packer::pack::store_file( + pack, + &directory.join("vcard-personal-portfolio.pack"), + )?; + + Ok(()) +} diff --git a/tests/examples/vcard_personal_portfolio_server.rs b/tests/examples/vcard_personal_portfolio_server.rs new file mode 100644 index 0000000..57cc55a --- /dev/null +++ b/tests/examples/vcard_personal_portfolio_server.rs @@ -0,0 +1,44 @@ +//! Spins up a server listening on [BIND] serving contents of +//! `data/vcard-personal-portfolio.pack`. Pack should be created upfront with +//! builder example. + +#![feature(async_closure)] + +use anyhow::Error; +use futures::{channel::oneshot, try_join}; +use include_bytes_aligned::include_bytes_aligned; +use simple_logger::SimpleLogger; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use tokio::signal::ctrl_c; +use web_static_pack_tests::serve_pack; + +static PACK_ARCHIVED_SERIALIZED: &[u8] = + include_bytes_aligned!(16, "../data/vcard-personal-portfolio.pack"); + +const BIND: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3000); + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Error> { + SimpleLogger::new().init().unwrap(); + + // load `pack` from prebuild version + log::trace!("loading pack"); + let pack_archived = unsafe { web_static_pack::loader::load(PACK_ARCHIVED_SERIALIZED).unwrap() }; + + // ctrl_c handler + let (shutdown_sender, shutdown_receiver) = oneshot::channel::<()>(); + let ctrl_c_runner = async move { + ctrl_c().await?; + shutdown_sender.send(()).unwrap(); + Ok(()) + }; + + // server + log::trace!("running server, go to http://{BIND}/index.html"); + let server_runner = serve_pack(pack_archived, Some(BIND), None, shutdown_receiver); + + // combine and run + try_join!(ctrl_c_runner, server_runner)?; + + Ok(()) +} diff --git a/tests/src/lib.rs b/tests/src/lib.rs new file mode 100644 index 0000000..5178b9a --- /dev/null +++ b/tests/src/lib.rs @@ -0,0 +1,220 @@ +#![doc(hidden)] + +use anyhow::{anyhow, ensure, Context, Error}; +use futures::{ + channel::oneshot, + future::{select, Either}, + pin_mut, +}; +use hyper::{body::Incoming, header, server::conn::http1, service::service_fn, Request}; +use hyper_util::{rt::TokioIo, server::graceful::GracefulShutdown}; +use memmap2::Mmap; +use ouroboros::self_referencing; +use reqwest::Client; +use std::{ + convert::Infallible, + fs::File, + mem::transmute, + net::{Ipv4Addr, SocketAddr, SocketAddrV4}, + path::PathBuf, + sync::LazyLock, +}; +use tokio::net::TcpListener; + +// builds [web_static_pack_common::pack::Pack] from +// data/vcard-personal-portfolio +fn build_vcard_personal_portfolio() -> Result { + let mut pack = web_static_pack_packer::pack::Builder::new(); + pack.file_pack_paths_add(web_static_pack_packer::directory::search( + &PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("data") + .join("vcard-personal-portfolio"), + &web_static_pack_packer::directory::SearchOptions::default(), + &web_static_pack_packer::file::BuildFromPathOptions::default(), + )?)?; + let pack = pack.finalize(); + + Ok(pack) +} +pub fn build_vcard_personal_portfolio_cached() -> &'static web_static_pack_common::pack::Pack { + static CACHE: LazyLock = + LazyLock::new(|| build_vcard_personal_portfolio().unwrap()); + + &CACHE +} + +// loads (mmaps) data/vcard-personal-portfolio.pack +#[self_referencing] +struct VCardPersonalPortfolioMMap { + mmap: Mmap, + + #[borrows(mmap)] + pack_archived: &'this web_static_pack_common::pack::PackArchived, +} +fn load_vcard_personal_portfolio() -> Result { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("data") + .join("vcard-personal-portfolio.pack"); + + let file = + File::open(path).context("you probably need to run builder example from this crate")?; + let mmap = unsafe { Mmap::map(&file)? }; + // let pack_archived = unsafe { web_static_pack::loader::load(&*mmap) }?; + + let inner = VCardPersonalPortfolioMMap::try_new(mmap, |mmap| unsafe { + web_static_pack::loader::load(mmap) + })?; + + Ok(inner) +} +pub fn load_vcard_personal_portfolio_cached() -> &'static web_static_pack_common::pack::PackArchived +{ + static CACHE: LazyLock = + LazyLock::new(|| load_vcard_personal_portfolio().unwrap()); + CACHE.borrow_pack_archived() +} + +// runs a http server serving pack, listening on bind or local emphemeric port +// if not set, notifying bind_ready_sender when server is ready and where is +// listening and shuts down when shutdown_receiver yields +pub async fn serve_pack

( + pack: &P, + bind: Option, + bind_ready_sender: Option>, + shutdown_receiver: oneshot::Receiver<()>, +) -> Result<(), Error> +where + P: web_static_pack::pack::Pack + Sync + 'static, +{ + log::trace!("staring server"); + + // pin shutdown future + pin_mut!(shutdown_receiver); + + // make responder from `pack` + let responder = web_static_pack::responder::Responder::new(pack); + + // hyper requires service to be static + // we use graceful, no connections will outlive server function + let responder = unsafe { + transmute::< + &web_static_pack::responder::Responder<'_, P>, + &web_static_pack::responder::Responder<'static, P>, + >(&responder) + }; + + // make hyper service + let service_fn = service_fn(|request: Request| async { + let (parts, _body) = request.into_parts(); + + log::info!("serving {}", parts.uri); + let response = responder.respond_flatten(parts); + + Ok::<_, Infallible>(response) + }); + + // graceful shutdown watcher + let graceful = GracefulShutdown::new(); + + // use ephemeric port + let bind = bind.unwrap_or(SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0))); + + // server listener + let listener = TcpListener::bind(bind).await?; + + // get final listening port + let bind = listener.local_addr()?; + log::trace!("listening on {bind}"); + + // notify that server is ready + if let Some(bind_ready_sender) = bind_ready_sender { + bind_ready_sender.send(bind).unwrap(); + } + + // main loop + log::trace!("entering main server loop"); + loop { + let listener_accept = listener.accept(); + pin_mut!(listener_accept); + + match select(listener_accept, &mut shutdown_receiver).await { + Either::Left((result, _)) => { + let (stream, _remote_address) = result?; + log::trace!("new connection"); + + let io = TokioIo::new(stream); + + let connection = http1::Builder::new().serve_connection(io, service_fn); + let graceful_connection = graceful.watch(connection); + + tokio::spawn(async move { + graceful_connection.await.unwrap(); + }); + } + Either::Right((result, _)) => { + result.unwrap(); + log::trace!("got exit signal"); + break; + } + } + } + + log::trace!("waiting for active connections to shutdown"); + graceful.shutdown().await; + + log::trace!("exiting server"); + Ok(()) +} + +// gets each of paths from pack_paths from pack, performs GET request on +// http://address/pack_path +pub async fn download_verify_pack

( + pack: &P, + pack_paths: &[&str], + address: SocketAddr, +) -> Result<(), Error> +where + P: web_static_pack::pack::Pack + Sync + 'static, +{ + let client = Client::new(); + + for pack_path in pack_paths { + log::trace!("downloading {pack_path}"); + + let file = pack + .get_file_by_path(pack_path) + .ok_or_else(|| anyhow!("request file missing in pack"))?; + + let response = client + .get(format!("http://{address}{pack_path}")) + .send() + .await? + .error_for_status()?; + + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .ok_or_else(|| anyhow!("content type missing in response"))? + .clone(); + let etag = response + .headers() + .get(header::ETAG) + .ok_or_else(|| anyhow!("etag missing in response"))? + .clone(); + let cache_control = response + .headers() + .get(header::CACHE_CONTROL) + .ok_or_else(|| anyhow!("cache control missing in response"))? + .clone(); + let content = response.bytes().await?; + + ensure!(content == web_static_pack::file::File::content(file)); + ensure!(content_type == web_static_pack::file::File::content_type(file)); + ensure!(etag == web_static_pack::file::File::etag(file)); + ensure!(cache_control == web_static_pack::file::File::cache_control(file).cache_control()); + } + + drop(client); + + Ok(()) +} diff --git a/tests/tests/build_and_load.rs b/tests/tests/build_and_load.rs new file mode 100644 index 0000000..2ad4145 --- /dev/null +++ b/tests/tests/build_and_load.rs @@ -0,0 +1,71 @@ +use std::collections::HashSet; +use web_static_pack_tests::{ + build_vcard_personal_portfolio_cached, load_vcard_personal_portfolio_cached, +}; + +#[test] +fn builder_builds_pack_with_same_contents() { + // prebuilt `pack` loaded from data + let pack_archived = load_vcard_personal_portfolio_cached(); + + // `pack` built from source files + let pack = build_vcard_personal_portfolio_cached(); + + // check if they contain equal keys + assert_eq!( + pack.files_by_path + .keys() + .map(|pack_path| &**pack_path) + .collect::>(), + pack_archived + .files_by_path + .keys() + .map(|pack_path_archived| &**pack_path_archived) + .collect::>() + ); + + // zip values and check if they are equal + pack.files_by_path + .iter() + .map(|(pack_path, file)| { + ( + pack_path, + file, + pack_archived.files_by_path.get(&**pack_path).unwrap(), + ) + }) + .for_each(|(_pack_path, file, file_archived)| { + assert_eq!(&*file.content, &*file_archived.content); + assert_eq!( + file.content_gzip.as_deref(), + file_archived.content_gzip.as_deref() + ); + assert_eq!( + file.content_brotli.as_deref(), + file_archived.content_brotli.as_deref() + ); + + assert_eq!(file.content_type, file_archived.content_type); + assert_eq!(file.etag, file_archived.etag); + // assert_eq!(file.cache_control, file_archived.cache_control); + // TODO: fix this comparision + }); +} + +#[test] +fn loader_loads_correctly_prebuilt_pack() { + let pack_archived = load_vcard_personal_portfolio_cached(); + + // index.html should have content-type: text/html; charset=utf-8 + assert_eq!( + pack_archived + .files_by_path + .get("/index.html") + .unwrap() + .content_type, + "text/html; charset=utf-8" + ); + + // index.php should not exists + assert!(pack_archived.files_by_path.get("/index.php").is_none()); +} diff --git a/tests/tests/load_and_serve.rs b/tests/tests/load_and_serve.rs new file mode 100644 index 0000000..e68837a --- /dev/null +++ b/tests/tests/load_and_serve.rs @@ -0,0 +1,45 @@ +#![feature(async_closure)] + +use futures::{channel::oneshot, try_join}; +use std::net::SocketAddr; +use web_static_pack_tests::{ + download_verify_pack, load_vcard_personal_portfolio_cached, serve_pack, +}; + +#[tokio::test(flavor = "current_thread")] +async fn client_downloads_verifies_served_by_server() { + // `pack` to run both server and client on + let pack_archived = load_vcard_personal_portfolio_cached(); + + // all paths from `pack` used for test + let pack_paths = pack_archived + .files_by_path + .keys() + .map(|pack_path_archived| &**pack_path_archived) + .collect::>(); + + // makes client wait for server to become ready and turns it off when completed + let (bind_ready_sender, bind_ready_receiver) = oneshot::channel::(); + let (shutdown_sender, shutdown_receiver) = oneshot::channel::<()>(); + + // server + let server = serve_pack( + pack_archived, + None, + Some(bind_ready_sender), + shutdown_receiver, + ); + + // client + let download_verifier = async move { + let bind = bind_ready_receiver.await?; + + download_verify_pack(pack_archived, &pack_paths, bind).await?; + + shutdown_sender.send(()).unwrap(); + Ok(()) + }; + + // wait for all + try_join!(server, download_verifier).unwrap(); +} diff --git a/tests/tests/serve_custom.rs b/tests/tests/serve_custom.rs new file mode 100644 index 0000000..3ba6799 --- /dev/null +++ b/tests/tests/serve_custom.rs @@ -0,0 +1,241 @@ +#![feature(async_closure)] + +use anyhow::{anyhow, Error}; +use futures::{channel::oneshot, try_join, Future}; +use http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode}; +use reqwest::{get, Client, ClientBuilder, Url}; +use std::net::SocketAddr; +use test_case::test_case; +use web_static_pack_tests::serve_pack; + +struct FileMock; +impl web_static_pack::file::File for FileMock { + fn content(&self) -> &[u8] { + b"content-identity-is-the-longest-and-least-preferred-option" + } + fn content_gzip(&self) -> Option<&[u8]> { + // "content-gzip" + Some(b"\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03\x4b\xce\xcf\x2b\x49\xcd\x2b\xd1\x4d\xaf\xca\x2c\x00\x00\x98\x02\x99\x74\x0c\x00\x00\x00") + } + fn content_brotli(&self) -> Option<&[u8]> { + // "content-brotli" + Some(b"\x8b\x06\x80\x63\x6f\x6e\x74\x65\x6e\x74\x2d\x62\x72\x6f\x74\x6c\x69\x03") + } + + fn content_type(&self) -> HeaderValue { + HeaderValue::from_static("text/plain; charset=utf-8") + } + fn etag(&self) -> HeaderValue { + HeaderValue::from_static("\"etagvalue\"") + } + fn cache_control(&self) -> web_static_pack::cache_control::CacheControl { + web_static_pack::cache_control::CacheControl::MaxCache + } +} +struct PackMock; +impl web_static_pack::pack::Pack for PackMock { + type File = FileMock; + + fn get_file_by_path( + &self, + path: &str, + ) -> Option<&Self::File> { + match path { + "/present" => Some(&FileMock), + _ => None, + } + } +} + +async fn run_with_server>, E: FnOnce(Url) -> F>( + executor: E, // async fn executor(base_url: Url) -> Result<(), Error> { ... } +) -> Result<(), Error> { + let (bind_ready_sender, bind_ready_receiver) = oneshot::channel::(); + let (shutdown_sender, shutdown_receiver) = oneshot::channel::<()>(); + + let server = serve_pack(&PackMock, None, Some(bind_ready_sender), shutdown_receiver); + + let verifier = async move { + let bind = bind_ready_receiver.await?; + let base_url = Url::parse(&format!("http://{bind}/"))?; + + executor(base_url).await?; + + shutdown_sender.send(()).unwrap(); + Ok(()) + }; + + try_join!(server, verifier)?; + + Ok(()) +} + +fn header_as_string( + headers: &HeaderMap, + name: HeaderName, +) -> &str { + headers + .get(&name) + .ok_or_else(|| anyhow!("missing header {name}")) + .unwrap() + .to_str() + .unwrap() +} + +#[tokio::test(flavor = "current_thread")] +async fn responds_to_typical_request() { + run_with_server(async move |base_url: Url| { + let response = get(base_url.join("/present")?).await?.error_for_status()?; + let headers = response.headers(); + + assert_eq!( + header_as_string(headers, header::CONTENT_TYPE), + "text/plain; charset=utf-8" + ); + assert_eq!( + header_as_string(headers, header::ETAG), // line break + "\"etagvalue\"" + ); + assert_eq!( + header_as_string(headers, header::CACHE_CONTROL), // line break + "max-age=31536000, immutable" + ); + + // reqwest strips content-length and content-encoding when using encoding + let body = response.bytes().await?; + assert_eq!(&*body, b"content-brotli"); + + Ok(()) + }) + .await + .unwrap(); +} + +#[test_case(true, true, b"content-brotli"; "all enabled, brotli is the shortest")] +#[test_case(false, true, b"content-gzip"; "no brotli, but gzip")] +#[test_case(false, false, b"content-identity-is-the-longest-and-least-preferred-option"; "nothing, should receive identity")] +#[tokio::test(flavor = "current_thread")] +async fn responds_with_other_encodings( + brotli: bool, + gzip: bool, + expected: &[u8], +) { + run_with_server(async move |base_url: Url| { + let response = ClientBuilder::new() + .brotli(brotli) + .gzip(gzip) + .build()? + .get(base_url.join("/present")?) + .send() + .await? + .error_for_status()?; + + // reqwest strips content-length and content-encoding when using encoding + let body = response.bytes().await?; + assert_eq!(&*body, expected); + + Ok(()) + }) + .await + .unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn resolves_no_body_for_head_request() { + run_with_server(async move |base_url: Url| { + let response = Client::new() + .head(base_url.join("/present")?) + .send() + .await? + .error_for_status()?; + let headers = response.headers(); + + // all headers should be there + assert_eq!( + header_as_string(headers, header::CONTENT_TYPE), + "text/plain; charset=utf-8" + ); + assert_eq!( + header_as_string(headers, header::ETAG), // line break + "\"etagvalue\"" + ); + assert_eq!( + header_as_string(headers, header::CACHE_CONTROL), // line break + "max-age=31536000, immutable" + ); + + // reqwest strips content-length and content-encoding when using encoding + let body = response.bytes().await?; + assert_eq!(&*body, b""); + + Ok(()) + }) + .await + .unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn resolves_not_modified_for_matching_etag() { + run_with_server(async move |base_url: Url| { + let response = Client::new() + .get(base_url.join("/present")?) + .header(header::IF_NONE_MATCH, "\"etagvalue\"") + .send() + .await?; + let headers = response.headers(); + + assert_eq!(response.status(), StatusCode::NOT_MODIFIED); + + // `ETag` should be resent, others should be missing. + assert_eq!( + header_as_string(headers, header::ETAG), // line break + "\"etagvalue\"" + ); + assert!(headers.get(header::CONTENT_TYPE).is_none()); + + // of course no body + let body = response.bytes().await?; + assert_eq!(&*body, b""); + + Ok(()) + }) + .await + .unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn resolves_error_for_invalid_method() { + run_with_server(async move |base_url: Url| { + let response = Client::new() + .post(base_url.join("/present")?) + .send() + .await?; + + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); + + // no body for flattening responder + let body = response.bytes().await?; + assert_eq!(&*body, b""); + + Ok(()) + }) + .await + .unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn resolves_error_for_file_not_found() { + run_with_server(async move |base_url: Url| { + let response = Client::new().get(base_url.join("/missing")?).send().await?; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + // no body for flattening responder + let body = response.bytes().await?; + assert_eq!(&*body, b""); + + Ok(()) + }) + .await + .unwrap(); +}