From b6f2e498efbd55688913c28eec8caf1843dba814 Mon Sep 17 00:00:00 2001 From: "bart.leboeuf" Date: Mon, 2 Mar 2026 19:59:46 +0100 Subject: [PATCH 1/4] Fix: Normalize macro route path to ensure compatibility with Windows, so that Axum no longer panics. --- static-serve-macro/src/error.rs | 2 - static-serve-macro/src/lib.rs | 71 +++++++++++++++++++++++---------- 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/static-serve-macro/src/error.rs b/static-serve-macro/src/error.rs index 8997794..08d4a56 100644 --- a/static-serve-macro/src/error.rs +++ b/static-serve-macro/src/error.rs @@ -23,8 +23,6 @@ pub(crate) enum Error { InvalidUnicodeInDirectoryName, #[error("Cannot canonicalize ignore path")] CannotCanonicalizeIgnorePath(#[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")] diff --git a/static-serve-macro/src/lib.rs b/static-serve-macro/src/lib.rs index ce911d7..f19897d 100644 --- a/static-serve-macro/src/lib.rs +++ b/static-serve-macro/src/lib.rs @@ -658,11 +658,11 @@ impl ToTokens for OptionBytesSlice { } } -struct EmbeddedFileInfo<'a> { +struct EmbeddedFileInfo { /// When creating a `Router`, we need the API path/route to the /// target file. If creating a `Handler`, this is not needed since /// the router is responsible for the file's path on the server. - entry_path: Option<&'a str>, + entry_path: Option, content_type: String, etag_str: String, lit_byte_str_contents: LitByteStr, @@ -671,9 +671,9 @@ struct EmbeddedFileInfo<'a> { cache_busted: bool, } -impl<'a> EmbeddedFileInfo<'a> { +impl EmbeddedFileInfo { fn from_path( - pathbuf: &'a PathBuf, + pathbuf: &PathBuf, assets_dir_abs_str: Option<&str>, should_compress: &LitBool, should_strip_html_ext: &LitBool, @@ -695,18 +695,18 @@ impl<'a> EmbeddedFileInfo<'a> { // entry_path is only needed for the router (embed_assets!) let entry_path = if let Some(dir) = assets_dir_abs_str { - if should_strip_html_ext.value && content_type == "text/html" { - Some( - strip_html_ext(pathbuf)? - .strip_prefix(dir) - .unwrap_or_default(), - ) + let relative_entry = pathbuf + .strip_prefix(dir) + .ok() + .and_then(|p| p.to_str()) + .unwrap_or_default(); + let relative_path = if should_strip_html_ext.value && content_type == "text/html" { + strip_html_ext_relative(relative_entry) } else { - pathbuf - .to_str() - .ok_or(Error::InvalidUnicodeInEntryName)? - .strip_prefix(dir) - } + relative_entry.to_owned() + }; + + Some(normalize_web_path(&relative_path)) } else { None }; @@ -820,21 +820,48 @@ fn etag(contents: &[u8]) -> String { format!("\"{hash:016x}\"") } -fn strip_html_ext(entry: &Path) -> Result<&str, Error> { - let entry_str = entry.to_str().ok_or(Error::InvalidUnicodeInEntryName)?; - let mut output = entry_str; +/// Strip `.html`/`.htm` from a relative path for "clean URL" routing. +/// +/// - Normalizes Windows `\` separators to `/`. +/// - Removes a trailing `.html` or `.htm` extension if present. +/// - If the resulting path ends with `/index` (or is exactly `index`), removes the `index` part. +/// +/// Examples: +/// - `index.html` -> `""` (served at `/`) +/// - `docs/index.htm` -> `docs/` +/// - `about.html` -> `about` +fn strip_html_ext_relative(entry: &str) -> String { + let mut output = entry.replace('\\', "/"); // Strip the extension if let Some(prefix) = output.strip_suffix(".html") { - output = prefix; + output = prefix.to_owned(); } else if let Some(prefix) = output.strip_suffix(".htm") { - output = prefix; + output = prefix.to_owned(); } // If it was `/index.html` or `/index.htm`, also remove `index` if output.ends_with("/index") { - output = output.strip_suffix("index").unwrap_or("/"); + output = output.strip_suffix("index").unwrap_or("/").to_owned(); + } else if output == "index" { + output.clear(); } - Ok(output) + output +} + +/// Normalize an embedded asset's relative file path into a web route path. +/// +/// - Converts Windows `\` path separators to `/`. +/// - Ensures the returned path starts with `/`. +/// - Returns `/` for the empty path (used for `index.html` when `strip_html_ext = true`). +fn normalize_web_path(relative_path: &str) -> String { + let normalized = relative_path.replace('\\', "/"); + if normalized.is_empty() { + "/".to_owned() + } else if normalized.starts_with('/') { + normalized + } else { + format!("/{normalized}") + } } From 4c05c2a8e93485372df0a19dcbc8f8d0f8782d55 Mon Sep 17 00:00:00 2001 From: "bart.leboeuf" Date: Tue, 3 Mar 2026 20:07:37 +0100 Subject: [PATCH 2/4] Feat: change simple normalize by std::path::Component --- static-serve-macro/src/lib.rs | 46 ++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/static-serve-macro/src/lib.rs b/static-serve-macro/src/lib.rs index f19897d..95d0bc0 100644 --- a/static-serve-macro/src/lib.rs +++ b/static-serve-macro/src/lib.rs @@ -820,18 +820,23 @@ fn etag(contents: &[u8]) -> String { format!("\"{hash:016x}\"") } -/// Strip `.html`/`.htm` from a relative path for "clean URL" routing. +/// Normalize a relative asset path, strip `.html`/`.htm`, and map +/// `/index(.html|.htm)` to its directory route. /// -/// - Normalizes Windows `\` separators to `/`. -/// - Removes a trailing `.html` or `.htm` extension if present. -/// - If the resulting path ends with `/index` (or is exactly `index`), removes the `index` part. -/// -/// Examples: -/// - `index.html` -> `""` (served at `/`) -/// - `docs/index.htm` -> `docs/` -/// - `about.html` -> `about` +/// The input is normalized via `Path::components()` so separator style +/// differences across platforms do not affect route generation. fn strip_html_ext_relative(entry: &str) -> String { - let mut output = entry.replace('\\', "/"); + let mut output = Path::new(entry) + .components() + .filter_map(|component| match component { + std::path::Component::Normal(segment) => segment.to_str(), + std::path::Component::CurDir + | std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) => None, + }) + .collect::>() + .join("/"); // Strip the extension if let Some(prefix) = output.strip_suffix(".html") { @@ -850,17 +855,24 @@ fn strip_html_ext_relative(entry: &str) -> String { output } -/// Normalize an embedded asset's relative file path into a web route path. +/// Convert a relative filesystem-style path into a rooted web route. /// -/// - Converts Windows `\` path separators to `/`. -/// - Ensures the returned path starts with `/`. -/// - Returns `/` for the empty path (used for `index.html` when `strip_html_ext = true`). +/// Path segments are normalized via `Path::components()`. The returned +/// route is always absolute (starts with `/`) and defaults to `/` for +/// empty input. fn normalize_web_path(relative_path: &str) -> String { - let normalized = relative_path.replace('\\', "/"); + let normalized = Path::new(relative_path) + .components() + .filter_map(|component| match component { + std::path::Component::Normal(segment) => segment.to_str(), + std::path::Component::CurDir => Some("."), + std::path::Component::ParentDir => Some(".."), + std::path::Component::RootDir | std::path::Component::Prefix(_) => None, + }) + .collect::>() + .join("/"); if normalized.is_empty() { "/".to_owned() - } else if normalized.starts_with('/') { - normalized } else { format!("/{normalized}") } From 65b3ef9e82e694cabf50a99d4362b7d73ec68462 Mon Sep 17 00:00:00 2001 From: Bart LEBOEUF Date: Wed, 4 Mar 2026 11:27:52 +0100 Subject: [PATCH 3/4] Update README.md Force CI to restart properly --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index d57289e..37e1ad8 100644 --- a/README.md +++ b/README.md @@ -135,3 +135,4 @@ at your option. ### Contribution Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. + From 6e1f1487ecb7b71d2a05e30dbb483a5e35154612 Mon Sep 17 00:00:00 2001 From: "bart.leboeuf" Date: Wed, 4 Mar 2026 22:18:41 +0100 Subject: [PATCH 4/4] refactor: call directly format in normalize_web_path --- static-serve-macro/src/lib.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/static-serve-macro/src/lib.rs b/static-serve-macro/src/lib.rs index 95d0bc0..5658ccb 100644 --- a/static-serve-macro/src/lib.rs +++ b/static-serve-macro/src/lib.rs @@ -865,15 +865,12 @@ fn normalize_web_path(relative_path: &str) -> String { .components() .filter_map(|component| match component { std::path::Component::Normal(segment) => segment.to_str(), - std::path::Component::CurDir => Some("."), - std::path::Component::ParentDir => Some(".."), - std::path::Component::RootDir | std::path::Component::Prefix(_) => None, + std::path::Component::CurDir + | std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) => None, }) .collect::>() .join("/"); - if normalized.is_empty() { - "/".to_owned() - } else { - format!("/{normalized}") - } + format!("/{normalized}") }