Skip to content

Commit dc6b0a1

Browse files
authored
feat: normalize macro route path to ensure compatibility with Windows
The macro was generating route paths from filesystem paths without normalizing separators/leading slash on Windows, so Axum received paths like \foo and panicked.
1 parent 3990d6b commit dc6b0a1

3 files changed

Lines changed: 59 additions & 24 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,4 @@ at your option.
135135
### Contribution
136136

137137
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.
138+

static-serve-macro/src/error.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,6 @@ pub(crate) enum Error {
2323
InvalidUnicodeInDirectoryName,
2424
#[error("Cannot canonicalize ignore path")]
2525
CannotCanonicalizeIgnorePath(#[source] io::Error),
26-
#[error("Invalid unicode in directory name")]
27-
InvalidUnicodeInEntryName,
2826
#[error("Error while compressing with gzip")]
2927
Gzip(#[from] GzipType),
3028
#[error("Error while compressing with zstd")]

static-serve-macro/src/lib.rs

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -658,11 +658,11 @@ impl ToTokens for OptionBytesSlice {
658658
}
659659
}
660660

661-
struct EmbeddedFileInfo<'a> {
661+
struct EmbeddedFileInfo {
662662
/// When creating a `Router`, we need the API path/route to the
663663
/// target file. If creating a `Handler`, this is not needed since
664664
/// the router is responsible for the file's path on the server.
665-
entry_path: Option<&'a str>,
665+
entry_path: Option<String>,
666666
content_type: String,
667667
etag_str: String,
668668
lit_byte_str_contents: LitByteStr,
@@ -671,9 +671,9 @@ struct EmbeddedFileInfo<'a> {
671671
cache_busted: bool,
672672
}
673673

674-
impl<'a> EmbeddedFileInfo<'a> {
674+
impl EmbeddedFileInfo {
675675
fn from_path(
676-
pathbuf: &'a PathBuf,
676+
pathbuf: &PathBuf,
677677
assets_dir_abs_str: Option<&str>,
678678
should_compress: &LitBool,
679679
should_strip_html_ext: &LitBool,
@@ -695,18 +695,18 @@ impl<'a> EmbeddedFileInfo<'a> {
695695

696696
// entry_path is only needed for the router (embed_assets!)
697697
let entry_path = if let Some(dir) = assets_dir_abs_str {
698-
if should_strip_html_ext.value && content_type == "text/html" {
699-
Some(
700-
strip_html_ext(pathbuf)?
701-
.strip_prefix(dir)
702-
.unwrap_or_default(),
703-
)
698+
let relative_entry = pathbuf
699+
.strip_prefix(dir)
700+
.ok()
701+
.and_then(|p| p.to_str())
702+
.unwrap_or_default();
703+
let relative_path = if should_strip_html_ext.value && content_type == "text/html" {
704+
strip_html_ext_relative(relative_entry)
704705
} else {
705-
pathbuf
706-
.to_str()
707-
.ok_or(Error::InvalidUnicodeInEntryName)?
708-
.strip_prefix(dir)
709-
}
706+
relative_entry.to_owned()
707+
};
708+
709+
Some(normalize_web_path(&relative_path))
710710
} else {
711711
None
712712
};
@@ -820,21 +820,57 @@ fn etag(contents: &[u8]) -> String {
820820
format!("\"{hash:016x}\"")
821821
}
822822

823-
fn strip_html_ext(entry: &Path) -> Result<&str, Error> {
824-
let entry_str = entry.to_str().ok_or(Error::InvalidUnicodeInEntryName)?;
825-
let mut output = entry_str;
823+
/// Normalize a relative asset path, strip `.html`/`.htm`, and map
824+
/// `/index(.html|.htm)` to its directory route.
825+
///
826+
/// The input is normalized via `Path::components()` so separator style
827+
/// differences across platforms do not affect route generation.
828+
fn strip_html_ext_relative(entry: &str) -> String {
829+
let mut output = Path::new(entry)
830+
.components()
831+
.filter_map(|component| match component {
832+
std::path::Component::Normal(segment) => segment.to_str(),
833+
std::path::Component::CurDir
834+
| std::path::Component::ParentDir
835+
| std::path::Component::RootDir
836+
| std::path::Component::Prefix(_) => None,
837+
})
838+
.collect::<Vec<_>>()
839+
.join("/");
826840

827841
// Strip the extension
828842
if let Some(prefix) = output.strip_suffix(".html") {
829-
output = prefix;
843+
output = prefix.to_owned();
830844
} else if let Some(prefix) = output.strip_suffix(".htm") {
831-
output = prefix;
845+
output = prefix.to_owned();
832846
}
833847

834848
// If it was `/index.html` or `/index.htm`, also remove `index`
835849
if output.ends_with("/index") {
836-
output = output.strip_suffix("index").unwrap_or("/");
850+
output = output.strip_suffix("index").unwrap_or("/").to_owned();
851+
} else if output == "index" {
852+
output.clear();
837853
}
838854

839-
Ok(output)
855+
output
856+
}
857+
858+
/// Convert a relative filesystem-style path into a rooted web route.
859+
///
860+
/// Path segments are normalized via `Path::components()`. The returned
861+
/// route is always absolute (starts with `/`) and defaults to `/` for
862+
/// empty input.
863+
fn normalize_web_path(relative_path: &str) -> String {
864+
let normalized = Path::new(relative_path)
865+
.components()
866+
.filter_map(|component| match component {
867+
std::path::Component::Normal(segment) => segment.to_str(),
868+
std::path::Component::CurDir
869+
| std::path::Component::ParentDir
870+
| std::path::Component::RootDir
871+
| std::path::Component::Prefix(_) => None,
872+
})
873+
.collect::<Vec<_>>()
874+
.join("/");
875+
format!("/{normalized}")
840876
}

0 commit comments

Comments
 (0)