feat: create initial version of proc_macro and lib

This commit is contained in:
Angel Hudgins 2025-03-04 19:50:43 +01:00
parent 7dc755dde4
commit c459800fca
11 changed files with 1357 additions and 9 deletions

View File

@ -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

610
Cargo.lock generated
View File

@ -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",
]

View File

@ -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"

View File

@ -6,6 +6,8 @@ ignore = [
allow = [
"MIT",
"Apache-2.0",
"Unicode-3.0",
"BSD-3-Clause",
]
[licenses.private]

View File

@ -1 +0,0 @@

View File

@ -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

View File

@ -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<OsString>),
#[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"
);
}
}

View File

@ -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<Self> {
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::<Token![,]>()?;
let key: Ident = input.parse()?;
input.parse::<Token![=]>()?;
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<Self> {
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<Self> {
if let Ok(inner) = input.parse::<LitStr>() {
Ok(ValidAssetsDirTypes::LiteralStr(inner))
} else {
let inner = input.parse::<Ident>().map_err(|_| {
syn::Error::new(
input.span(),
"Assets directory must be a literal string or valid identifier",
)
})?;
Ok(ValidAssetsDirTypes::Ident(inner))
}
}
}
struct IgnoreDirs(Vec<PathBuf>);
struct IgnoreDirsWithSpan(Vec<(PathBuf, Span)>);
impl Parse for IgnoreDirsWithSpan {
fn parse(input: ParseStream) -> syn::Result<Self> {
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::<LitStr>()?;
if !inner_content.is_empty() {
inner_content.parse::<Token![,]>()?;
}
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<IgnoreDirs> {
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<Self> {
let lit = input.parse()?;
Ok(ShouldCompress(lit))
}
}
fn generate_static_routes(
assets_dir: &ValidAssetsDirTypes,
ignore_dirs: &IgnoreDirs,
should_compress: &LitBool,
) -> Result<TokenStream, error::Error> {
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::<Result<Vec<_>, _>>()?;
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<S>() -> ::axum::Router<S>
where S: ::std::clone::Clone + ::std::marker::Send + ::std::marker::Sync + 'static {
let mut router = ::axum::Router::<S>::new();
#(#routes)*
router
}
})
}
fn gzip_compress(contents: &[u8]) -> Result<Option<LitByteStr>, 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<Option<LitByteStr>, 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<u8>>,
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<T: ToTokens>(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<LitByteStr> {
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}\"")
}

18
static-serve/Cargo.toml Normal file
View File

@ -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

192
static-serve/src/lib.rs Normal file
View File

@ -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<S> FromRequestParts<S> for AcceptEncoding
where
S: Send + Sync,
{
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
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<HeaderValue>);
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<S> FromRequestParts<S> for IfNoneMatch
where
S: Send + Sync,
{
type Rejection = Infallible;
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
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<S>(
router: Router<S>,
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<S>
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());
}
}