From c459800fcac34cec6aa0b99aa8ea618a74be7ae3 Mon Sep 17 00:00:00 2001 From: Angel Hudgins Date: Tue, 4 Mar 2025 19:50:43 +0100 Subject: [PATCH] feat: create initial version of proc_macro and lib --- .gitignore | 2 +- .gitlab-ci.yml | 1 + Cargo.lock | 610 ++++++++++++++++++++++++++++++++ Cargo.toml | 24 +- deny.toml | 2 + src/lib.rs | 1 - static-serve-macro/Cargo.toml | 27 ++ static-serve-macro/src/error.rs | 82 +++++ static-serve-macro/src/lib.rs | 407 +++++++++++++++++++++ static-serve/Cargo.toml | 18 + static-serve/src/lib.rs | 192 ++++++++++ 11 files changed, 1357 insertions(+), 9 deletions(-) delete mode 100644 src/lib.rs create mode 100644 static-serve-macro/Cargo.toml create mode 100644 static-serve-macro/src/error.rs create mode 100644 static-serve-macro/src/lib.rs create mode 100644 static-serve/Cargo.toml create mode 100644 static-serve/src/lib.rs diff --git a/.gitignore b/.gitignore index ea8c4bf..c41cc9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -/target +/target \ No newline at end of file diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8abad5c..c19f1f6 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -21,6 +21,7 @@ rust:clippy: stage: test image: rust:1.84-alpine3.21 before_script: + - apk add musl-dev - rustup component add clippy script: - cargo clippy --all-features -- -D warnings diff --git a/Cargo.lock b/Cargo.lock index d52086e..fd45a40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,616 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" + +[[package]] +name = "axum" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be714c154be609ec7f5dad223a33bf1482fff90472de28f7362806e6d4832b8c" +dependencies = [ + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "display_full_error" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7c47e9a2fd28a8edd1446f10dabe8ac5d26bb77ed2b1077bfcd8308904e8c6" + +[[package]] +name = "flate2" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11faaf5a5236997af9848be0bef4db95824b1d534ebc64d0f0c6cf3e67bd38dc" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jobserver" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0" +dependencies = [ + "libc", +] + +[[package]] +name = "libc" +version = "0.2.171" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c19937216e9d3aa9956d9bb8dfc0b0c8beb6058fc4f7a4dc4d850edf86a237d6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" +dependencies = [ + "adler2", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "proc-macro2" +version = "1.0.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31971752e70b8b2686d7e46ec17fb38dad4051d94024c88df49b667caea9c84" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" + +[[package]] +name = "rustversion" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eded382c5f5f786b989652c49544c4877d9f015cc22e145a5ea8ea66c2921cd2" + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "static-serve" version = "0.1.0" +dependencies = [ + "axum", + "bytes", + "static-serve-macro", +] + +[[package]] +name = "static-serve-macro" +version = "0.1.0" +dependencies = [ + "display_full_error", + "flate2", + "glob", + "proc-macro2", + "quote", + "sha1", + "syn", + "thiserror", + "zstd", +] + +[[package]] +name = "syn" +version = "2.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "thiserror" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.44.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f382da615b842244d4b8738c82ed1275e6c5dd90c459a30941cd07080b06c91a" +dependencies = [ + "backtrace", + "pin-project-lite", + "tokio-macros", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.15+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb81183ddd97d0c74cedf1d50d85c8d08c1b8b68ee863bdee9e706eedba1a237" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index ac2bd27..81c3d7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,26 @@ -[package] -name = "static-serve" +[workspace] +members = [ + "static-serve", + "static-serve-macro", +] +resolver = "2" + +[workspace.package] version = "0.1.0" edition = "2021" +rust-version = "1.83" +description = "A helper for compressing and embedding static assets in an Axum webserver" +repository = "https://github.com/M4SS-Code/static-serve" license = "MIT OR Apache-2.0" +keywords = ["static", "axum", "embed", "web", "conditional"] +categories = ["web-programming", "web-programming::http-server", "filesystem"] -[dependencies] - -[lints.rust] +[workspace.lints.rust] +missing_docs = "warn" unsafe_code = "deny" unreachable_pub = "deny" -[lints.clippy] +[workspace.lints.clippy] pedantic = { level = "warn", priority = -1 } module_name_repetitions = "allow" await_holding_refcell_ref = "deny" @@ -20,4 +30,4 @@ needless_raw_string_hashes = "warn" redundant_closure_for_method_calls = "warn" semicolon_if_nothing_returned = "warn" str_to_string = "warn" -clone_on_ref_ptr = "warn" +clone_on_ref_ptr = "warn" \ No newline at end of file diff --git a/deny.toml b/deny.toml index d8963b3..f5c3c5d 100644 --- a/deny.toml +++ b/deny.toml @@ -6,6 +6,8 @@ ignore = [ allow = [ "MIT", "Apache-2.0", + "Unicode-3.0", + "BSD-3-Clause", ] [licenses.private] diff --git a/src/lib.rs b/src/lib.rs deleted file mode 100644 index 8b13789..0000000 --- a/src/lib.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/static-serve-macro/Cargo.toml b/static-serve-macro/Cargo.toml new file mode 100644 index 0000000..29e0c64 --- /dev/null +++ b/static-serve-macro/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "static-serve-macro" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +keywords.workspace = true +description.workspace = true +categories.workspace = true +repository.workspace = true + +[lib] +proc-macro = true + +[dependencies] +display_full_error = "1.1" +flate2 = "1.1" +glob = "0.3" +proc-macro2 = "1.0" +quote = "1.0" +sha1 = "0.10" +syn = { version = "2.0", default-features = false } +thiserror = "2.0.12" +zstd = "0.13" + +[lints] +workspace = true diff --git a/static-serve-macro/src/error.rs b/static-serve-macro/src/error.rs new file mode 100644 index 0000000..7230f8a --- /dev/null +++ b/static-serve-macro/src/error.rs @@ -0,0 +1,82 @@ +use std::{ + ffi::{OsStr, OsString}, + fmt::{Display, Formatter}, + io, +}; + +use glob::{GlobError, PatternError}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub(crate) enum Error { + #[error("{}", UnknownFileExtension(.0.as_deref()))] + UnknownFileExtension(Option), + #[error("Cannot canonicalize assets directory")] + CannotCanonicalizeDirectory(#[source] io::Error), + #[error("Invalid unicode in directory name")] + InvalidUnicodeInDirectoryName, + #[error("Cannot canonicalize ignore directory")] + CannotCanonicalizeIgnoreDir(#[source] io::Error), + #[error("Invalid unicode in directory name")] + InvalidUnicodeInEntryName, + #[error("Error while compressing with gzip")] + Gzip(#[from] GzipType), + #[error("Error while compressing with zstd")] + Zstd(#[from] ZstdType), + #[error("Error while reading entry contents")] + CannotReadEntryContents(#[source] io::Error), + #[error("Error while parsing glob pattern")] + Pattern(#[source] PatternError), + #[error("Error reading path for glob")] + Glob(#[source] GlobError), + #[error("Cannot get entry metadata")] + CannotGetMetadata(#[source] io::Error), +} + +struct UnknownFileExtension<'a>(Option<&'a OsStr>); +impl Display for UnknownFileExtension<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self.0 { + Some(ext) => write!( + f, + "Unknown file extension in directory of static assets: {}", + ext.to_string_lossy() + ), + None => write!(f, "Missing file extension"), + } + } +} + +#[derive(Debug, Error)] +pub(crate) enum GzipType { + #[error("The compressor could not write")] + CompressorWrite(#[source] io::Error), + #[error("The encoder could not complete the `finish` procedure")] + EncoderFinish(#[source] io::Error), +} + +#[derive(Debug, Error)] +pub(crate) enum ZstdType { + #[error("The encoder could not write")] + EncoderWrite(#[source] io::Error), + #[error("The encoder could not complete the `finish` procedure")] + EncoderFinish(#[source] io::Error), +} + +#[cfg(test)] +mod test { + use std::ffi::OsStr; + + use super::UnknownFileExtension; + + #[test] + fn unknown_file_extension() { + let missing_extension = UnknownFileExtension(None); + assert_eq!(missing_extension.to_string(), "Missing file extension"); + let unknown_extension = UnknownFileExtension(Some(OsStr::new("pippo"))); + assert_eq!( + unknown_extension.to_string(), + "Unknown file extension in directory of static assets: pippo" + ); + } +} diff --git a/static-serve-macro/src/lib.rs b/static-serve-macro/src/lib.rs new file mode 100644 index 0000000..dd7841d --- /dev/null +++ b/static-serve-macro/src/lib.rs @@ -0,0 +1,407 @@ +//! Proc macro crate for compressing and embedding static assets +//! in a web server +//! Macro invocation: `embed_assets!('path/to/assets', compress = true);` + +use std::{ + convert::Into, + fmt::Display, + fs, + io::{self, Write}, + path::{Path, PathBuf}, +}; + +use display_full_error::DisplayFullError; +use flate2::write::GzEncoder; +use glob::glob; +use proc_macro2::{Span, TokenStream}; +use quote::{quote, ToTokens}; +use sha1::{Digest as _, Sha1}; +use syn::{ + bracketed, + parse::{Parse, ParseStream}, + parse_macro_input, Ident, LitBool, LitByteStr, LitStr, Token, +}; + +mod error; +use error::{Error, GzipType, ZstdType}; + +#[proc_macro] +/// Embed and optionally compress static assets for a web server +pub fn embed_assets(input: proc_macro::TokenStream) -> proc_macro::TokenStream { + let parsed = parse_macro_input!(input as EmbedAssets); + quote! { #parsed }.into() +} + +struct EmbedAssets { + assets_dir: AssetsDir, + validated_ignore_dirs: IgnoreDirs, + should_compress: ShouldCompress, +} + +impl Parse for EmbedAssets { + fn parse(input: ParseStream) -> syn::Result { + let assets_dir: AssetsDir = input.parse()?; + + // Default to no compression + let mut maybe_should_compress = None; + let mut maybe_ignore_dirs = None; + + while !input.is_empty() { + input.parse::()?; + let key: Ident = input.parse()?; + input.parse::()?; + + match key.to_string().as_str() { + "compress" => { + let value = input.parse()?; + maybe_should_compress = Some(value); + } + "ignore_dirs" => { + let value = input.parse()?; + maybe_ignore_dirs = Some(value); + } + _ => { + return Err(syn::Error::new( + key.span(), + "Unknown key in embed_assets! macro. Expected `compress` or `ignore_dirs`", + )); + } + } + } + + let should_compress = maybe_should_compress.unwrap_or_else(|| { + ShouldCompress(LitBool { + value: false, + span: Span::call_site(), + }) + }); + + let ignore_dirs_with_span = maybe_ignore_dirs.unwrap_or(IgnoreDirsWithSpan(vec![])); + let validated_ignore_dirs = validate_ignore_dirs(ignore_dirs_with_span, &assets_dir.0)?; + + Ok(Self { + assets_dir, + validated_ignore_dirs, + should_compress, + }) + } +} + +impl ToTokens for EmbedAssets { + fn to_tokens(&self, tokens: &mut TokenStream) { + let AssetsDir(assets_dir) = &self.assets_dir; + let ignore_dirs = &self.validated_ignore_dirs; + let ShouldCompress(should_compress) = &self.should_compress; + + let result = generate_static_routes(assets_dir, ignore_dirs, should_compress); + + match result { + Ok(value) => { + tokens.extend(quote! { + #value + }); + } + Err(err_message) => { + let error = syn::Error::new(Span::call_site(), err_message); + tokens.extend(error.to_compile_error()); + } + } + } +} + +struct AssetsDir(ValidAssetsDirTypes); + +impl Parse for AssetsDir { + fn parse(input: ParseStream) -> syn::Result { + let input_span = input.span(); + let assets_dir: ValidAssetsDirTypes = input.parse()?; + let literal = assets_dir.to_string(); + let path = Path::new(&literal); + let metadata = match fs::metadata(path) { + Ok(meta) => meta, + Err(e) if matches!(e.kind(), std::io::ErrorKind::NotFound) => { + return Err(syn::Error::new( + input_span, + "The specified assets directory does not exist", + )); + } + Err(e) => { + return Err(syn::Error::new( + input_span, + format!( + "Error reading directory {literal}: {}", + DisplayFullError(&e) + ), + )); + } + }; + + if !metadata.is_dir() { + return Err(syn::Error::new( + input_span, + "The specified assets directory is not a directory", + )); + } + + Ok(AssetsDir(assets_dir)) + } +} + +enum ValidAssetsDirTypes { + LiteralStr(LitStr), + Ident(Ident), +} + +impl Display for ValidAssetsDirTypes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::LiteralStr(inner) => write!(f, "{}", inner.value()), + Self::Ident(inner) => write!(f, "{inner}"), + } + } +} + +impl Parse for ValidAssetsDirTypes { + fn parse(input: ParseStream) -> syn::Result { + if let Ok(inner) = input.parse::() { + Ok(ValidAssetsDirTypes::LiteralStr(inner)) + } else { + let inner = input.parse::().map_err(|_| { + syn::Error::new( + input.span(), + "Assets directory must be a literal string or valid identifier", + ) + })?; + Ok(ValidAssetsDirTypes::Ident(inner)) + } + } +} + +struct IgnoreDirs(Vec); + +struct IgnoreDirsWithSpan(Vec<(PathBuf, Span)>); + +impl Parse for IgnoreDirsWithSpan { + fn parse(input: ParseStream) -> syn::Result { + let inner_content; + bracketed!(inner_content in input); + + let mut dirs = Vec::new(); + while !inner_content.is_empty() { + let directory_span = inner_content.span(); + let directory_str = inner_content.parse::()?; + if !inner_content.is_empty() { + inner_content.parse::()?; + } + let path = PathBuf::from(directory_str.value()); + dirs.push((path, directory_span)); + } + + Ok(IgnoreDirsWithSpan(dirs)) + } +} + +fn validate_ignore_dirs( + ignore_dirs: IgnoreDirsWithSpan, + assets_dir: &ValidAssetsDirTypes, +) -> syn::Result { + let mut valid_ignore_dirs = Vec::new(); + for (dir, span) in ignore_dirs.0 { + let full_path = PathBuf::from(assets_dir.to_string()).join(&dir); + match fs::metadata(&full_path) { + Ok(meta) if !meta.is_dir() => { + return Err(syn::Error::new( + span, + "The specified ignored directory is not a directory", + )); + } + Ok(_) => valid_ignore_dirs.push(full_path), + Err(e) if matches!(e.kind(), std::io::ErrorKind::NotFound) => { + return Err(syn::Error::new( + span, + "The specified ignored directory does not exist", + )) + } + Err(e) => { + return Err(syn::Error::new( + span, + format!( + "Error reading ignored directory {}: {}", + dir.to_string_lossy(), + DisplayFullError(&e) + ), + )) + } + }; + } + Ok(IgnoreDirs(valid_ignore_dirs)) +} + +struct ShouldCompress(LitBool); + +impl Parse for ShouldCompress { + fn parse(input: ParseStream) -> syn::Result { + let lit = input.parse()?; + Ok(ShouldCompress(lit)) + } +} + +fn generate_static_routes( + assets_dir: &ValidAssetsDirTypes, + ignore_dirs: &IgnoreDirs, + should_compress: &LitBool, +) -> Result { + let assets_dir_abs = Path::new(&assets_dir.to_string()) + .canonicalize() + .map_err(Error::CannotCanonicalizeDirectory)?; + let assets_dir_abs_str = assets_dir_abs + .to_str() + .ok_or(Error::InvalidUnicodeInDirectoryName)?; + let canon_ignore_dirs = ignore_dirs + .0 + .iter() + .map(|d| d.canonicalize().map_err(Error::CannotCanonicalizeIgnoreDir)) + .collect::, _>>()?; + + let mut routes = Vec::new(); + for entry in glob(&format!("{assets_dir_abs_str}/**/*")).map_err(Error::Pattern)? { + let entry = entry.map_err(Error::Glob)?; + let metadata = entry.metadata().map_err(Error::CannotGetMetadata)?; + if metadata.is_dir() { + continue; + } + + // Skip `entry`s which are located in ignored subdirectories + if canon_ignore_dirs + .iter() + .any(|ignore_dir| entry.starts_with(ignore_dir)) + { + continue; + } + + let contents = fs::read(&entry).map_err(Error::CannotReadEntryContents)?; + + // Optionally compress files + let (maybe_gzip, maybe_zstd) = if should_compress.value { + let gzip = gzip_compress(&contents)?; + let zstd = zstd_compress(&contents)?; + (gzip, zstd) + } else { + (None, None) + }; + + // Create parameters for `::static_serve::static_route()` + let entry_path = entry + .to_str() + .ok_or(Error::InvalidUnicodeInEntryName)? + .strip_prefix(assets_dir_abs_str) + .unwrap_or_default(); + let content_type = file_content_type(&entry)?; + let etag_str = etag(&contents); + let lit_byte_str_contents = LitByteStr::new(&contents, Span::call_site()); + let maybe_gzip = option_to_token_stream_option(maybe_gzip.as_ref()); + let maybe_zstd = option_to_token_stream_option(maybe_zstd.as_ref()); + + routes.push(quote! { + router = ::static_serve::static_route( + router, + #entry_path, + #content_type, + #etag_str, + #lit_byte_str_contents, + #maybe_gzip, + #maybe_zstd, + ); + }); + } + + Ok(quote! { + pub fn static_router() -> ::axum::Router + where S: ::std::clone::Clone + ::std::marker::Send + ::std::marker::Sync + 'static { + let mut router = ::axum::Router::::new(); + #(#routes)* + router + } + }) +} + +fn gzip_compress(contents: &[u8]) -> Result, Error> { + let mut compressor = GzEncoder::new(Vec::new(), flate2::Compression::best()); + compressor + .write_all(contents) + .map_err(|e| Error::Gzip(GzipType::CompressorWrite(e)))?; + let compressed = compressor + .finish() + .map_err(|e| Error::Gzip(GzipType::EncoderFinish(e)))?; + + Ok(maybe_get_compressed(&compressed, contents)) +} + +fn zstd_compress(contents: &[u8]) -> Result, Error> { + let level = *zstd::compression_level_range().end(); + let mut encoder = zstd::Encoder::new(Vec::new(), level).unwrap(); + write_to_zstd_encoder(&mut encoder, contents) + .map_err(|e| Error::Zstd(ZstdType::EncoderWrite(e)))?; + + let compressed = encoder + .finish() + .map_err(|e| Error::Zstd(ZstdType::EncoderFinish(e)))?; + + Ok(maybe_get_compressed(&compressed, contents)) +} + +fn write_to_zstd_encoder( + encoder: &mut zstd::Encoder<'static, Vec>, + contents: &[u8], +) -> io::Result<()> { + encoder.set_pledged_src_size(Some( + contents + .len() + .try_into() + .expect("contents size should fit into u64"), + ))?; + encoder.window_log(23)?; + encoder.include_checksum(false)?; + encoder.include_contentsize(false)?; + encoder.long_distance_matching(false)?; + encoder.write_all(contents)?; + + Ok(()) +} + +fn option_to_token_stream_option(opt: Option<&T>) -> TokenStream { + if let Some(inner) = opt { + quote! { ::std::option::Option::Some(#inner) } + } else { + quote! { ::std::option::Option::None } + } +} + +fn is_compression_significant(compressed_len: usize, contents_len: usize) -> bool { + let ninety_pct_original = contents_len / 10 * 9; + compressed_len < ninety_pct_original +} + +fn maybe_get_compressed(compressed: &[u8], contents: &[u8]) -> Option { + is_compression_significant(compressed.len(), contents.len()) + .then(|| LitByteStr::new(compressed, Span::call_site())) +} + +fn file_content_type(path: &Path) -> Result<&'static str, error::Error> { + match path.extension() { + Some(ext) if ext.eq_ignore_ascii_case("css") => Ok("text/css"), + Some(ext) if ext.eq_ignore_ascii_case("js") => Ok("text/javascript"), + Some(ext) if ext.eq_ignore_ascii_case("txt") => Ok("text/plain"), + Some(ext) if ext.eq_ignore_ascii_case("woff") => Ok("font/woff"), + Some(ext) if ext.eq_ignore_ascii_case("woff2") => Ok("font/woff2"), + Some(ext) if ext.eq_ignore_ascii_case("svg") => Ok("image/svg+xml"), + ext => Err(error::Error::UnknownFileExtension(ext.map(Into::into))), + } +} + +fn etag(contents: &[u8]) -> String { + let sha256 = Sha1::digest(contents); + let hash = u64::from_le_bytes(sha256[..8].try_into().unwrap()) + ^ u64::from_le_bytes(sha256[8..16].try_into().unwrap()); + format!("\"{hash:016x}\"") +} diff --git a/static-serve/Cargo.toml b/static-serve/Cargo.toml new file mode 100644 index 0000000..7f85e04 --- /dev/null +++ b/static-serve/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "static-serve" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +keywords.workspace = true +description.workspace = true +categories.workspace = true +repository.workspace = true + +[dependencies] +static-serve-macro = { path = "../static-serve-macro", version = "0.1.0" } +axum = { version = "0.8", default-features = false } +bytes = "1.10" + +[lints] +workspace = true diff --git a/static-serve/src/lib.rs b/static-serve/src/lib.rs new file mode 100644 index 0000000..faae84e --- /dev/null +++ b/static-serve/src/lib.rs @@ -0,0 +1,192 @@ +//! Crate for compressing and embedding static assets +//! in a web server +use std::convert::Infallible; + +use axum::{ + extract::FromRequestParts, + http::{ + header::{ + HeaderValue, ACCEPT_ENCODING, CACHE_CONTROL, CONTENT_ENCODING, CONTENT_TYPE, ETAG, + IF_NONE_MATCH, VARY, + }, + request::Parts, + StatusCode, + }, + response::IntoResponse, + routing::get, + Router, +}; +use bytes::Bytes; + +pub use static_serve_macro::embed_assets; + +/// The accept/reject status for gzip and zstd encoding +#[derive(Debug, Copy, Clone)] +struct AcceptEncoding { + /// Is gzip accepted? + pub gzip: bool, + /// Is zstd accepted? + pub zstd: bool, +} + +impl FromRequestParts for AcceptEncoding +where + S: Send + Sync, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let accept_encoding = parts.headers.get(ACCEPT_ENCODING); + let accept_encoding = accept_encoding + .and_then(|accept_encoding| accept_encoding.to_str().ok()) + .unwrap_or_default(); + + Ok(Self { + gzip: accept_encoding.contains("gzip"), + zstd: accept_encoding.contains("zstd"), + }) + } +} + +/// Check if the `IfNoneMatch` header is present +#[derive(Debug)] +struct IfNoneMatch(Option); + +impl IfNoneMatch { + /// required function for checking if `IfNoneMatch` is present + fn matches(&self, etag: &str) -> bool { + self.0 + .as_ref() + .is_some_and(|if_none_match| if_none_match.as_bytes() == etag.as_bytes()) + } +} + +impl FromRequestParts for IfNoneMatch +where + S: Send + Sync, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let if_none_match = parts.headers.get(IF_NONE_MATCH).cloned(); + Ok(Self(if_none_match)) + } +} + +#[doc(hidden)] +/// The router for adding routes for static assets +pub fn static_route( + router: Router, + web_path: &'static str, + content_type: &'static str, + etag: &'static str, + body: &'static [u8], + body_gz: Option<&'static [u8]>, + body_zst: Option<&'static [u8]>, +) -> Router +where + S: Clone + Send + Sync + 'static, +{ + router.route( + web_path, + get( + move |accept_encoding: AcceptEncoding, if_none_match: IfNoneMatch| async move { + let headers_base = [ + (CONTENT_TYPE, HeaderValue::from_static(content_type)), + (ETAG, HeaderValue::from_static(etag)), + ( + CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + ), + (VARY, HeaderValue::from_static("Accept-Encoding")), + ]; + + match ( + if_none_match.matches(etag), + accept_encoding.gzip, + accept_encoding.zstd, + body_gz, + body_zst, + ) { + (true, _, _, _, _) => (headers_base, StatusCode::NOT_MODIFIED).into_response(), + (false, _, true, _, Some(body_zst)) => ( + headers_base, + [(CONTENT_ENCODING, HeaderValue::from_static("zstd"))], + Bytes::from_static(body_zst), + ) + .into_response(), + (false, true, _, Some(body_gz), _) => ( + headers_base, + [(CONTENT_ENCODING, HeaderValue::from_static("gzip"))], + Bytes::from_static(body_gz), + ) + .into_response(), + _ => (headers_base, Bytes::from_static(body)).into_response(), + } + }, + ), + ) +} + +#[cfg(test)] +mod tests { + + #[test] + fn router_created_with_test_routes_lit_str() { + embed_assets!("test_assets/small", compress = false); + let router: Router<()> = static_router(); + assert!(router.has_routes()); + } + + #[test] + fn router_created_not_compressed_because_not_worthwhile() { + embed_assets!("test_assets/small", compress = true); + let router: Router<()> = static_router(); + assert!(router.has_routes()); + } + + #[test] + fn router_created_compressed() { + embed_assets!("test_assets/big", compress = true); + let router: Router<()> = static_router(); + assert!(router.has_routes()); + } + + #[test] + fn router_created_with_test_routes_ident() { + embed_assets!(test_assets, compress = true); + let router: Router<()> = static_router(); + assert!(router.has_routes()); + } + + #[test] + fn router_created_ignore_dirs_one() { + embed_assets!(test_assets, ignore_dirs = ["test_assets/big"]); + let router: Router<()> = static_router(); + assert!(router.has_routes()); + } + + #[test] + fn router_created_ignore_dirs_two() { + embed_assets!( + test_assets, + ignore_dirs = ["test_assets/big", "test_assets/small"] + ); + let router: Router<()> = static_router(); + // all directories ignored, so router has no routes + assert!(!router.has_routes()); + } + + #[test] + fn router_created_ignore_dirs_with_defaults() { + // TODO: actually create one of the default ignore directories + // in `test_assets` to make sure this works + embed_assets!( + test_assets, + ignore_dirs = ["test_assets/big"], + use_default_ignore_dirs = true + ); + let router: Router<()> = static_router(); + assert!(router.has_routes()); + } +}