Repository: static-web-server/static-web-server (language: rust)

Issue:
directory listing link href's are not properly escaped
**Describe the bug**
When generating the directory listing HTML, href values for file/directory links are not being escaped, leading to either (a) *dead links in the directory listing*, or even worse (b) links in the directory listing that actually *point to a different file than the one shown*.

Additionally, names with a ':' are not handled properly by some browsers (`<a href="a:b">a:b</a>` is seen as a link to schema 'a' and path 'b', or possibly as a malformed URI of the "name:password@hostname" form), making them unusable. Any such files should instead be href'd with a './' prefix to help the browser resolve the name correctly.

**To Reproduce**
```shell
# start with clean workspace
tmp_dir="$( mktemp -d )"
pushd "$tmp_dir"

# create files
echo 'clicking this file in the directory listing will yield a 404' > 'unreachable-file?'
echo 'this is the file you get for "some-file", "some-file?", and "some-file?jkl" 😬' > 'some-file'
echo 'again, unreachable' > 'some-file?'
echo 'again, unreachable' > 'some-file?jkl'
echo "Chrome (and presumably other browsers) doesn't know what to do with the link for this file" > 'my:file'

# go!
static-web-server --root='./' --port='3000' --directory-listing='true'

# clean up
popd
rm -rf "$tmp_dir"
```

**Expected behavior**
File/directory names should be properly escaped so that non-URI-safe characters (like '?', ':', ' ', etc) are properly encoded and clicking the directory listing links yields the correct file.

**Environment and Specs**
 - static-web-server: v2.12.0
 - OS: Ubuntu Jammy (22.04.1)
 - Client: Chrome 105.0.5195.134

**Additional context**
Yes, I realize these are unorthodox filenames. They are still, however, valid in most filesystems and therefore should not break `static-web-server`, IMO. For whatever it's worth, I'm submitting the bug not because I'm poring through code and found something to nitpick about, but because I actually ran into the above problems in my usage.  🤷

directory listing link href's are not properly escaped
**Describe the bug**
When generating the directory listing HTML, href values for file/directory links are not being escaped, leading to either (a) *dead links in the directory listing*, or even worse (b) links in the directory listing that actually *point to a different file than the one shown*.

Additionally, names with a ':' are not handled properly by some browsers (`<a href="a:b">a:b</a>` is seen as a link to schema 'a' and path 'b', or possibly as a malformed URI of the "name:password@hostname" form), making them unusable. Any such files should instead be href'd with a './' prefix to help the browser resolve the name correctly.

**To Reproduce**
```shell
# start with clean workspace
tmp_dir="$( mktemp -d )"
pushd "$tmp_dir"

# create files
echo 'clicking this file in the directory listing will yield a 404' > 'unreachable-file?'
echo 'this is the file you get for "some-file", "some-file?", and "some-file?jkl" 😬' > 'some-file'
echo 'again, unreachable' > 'some-file?'
echo 'again, unreachable' > 'some-file?jkl'
echo "Chrome (and presumably other browsers) doesn't know what to do with the link for this file" > 'my:file'

# go!
static-web-server --root='./' --port='3000' --directory-listing='true'

# clean up
popd
rm -rf "$tmp_dir"
```

**Expected behavior**
File/directory names should be properly escaped so that non-URI-safe characters (like '?', ':', ' ', etc) are properly encoded and clicking the directory listing links yields the correct file.

**Environment and Specs**
 - static-web-server: v2.12.0
 - OS: Ubuntu Jammy (22.04.1)
 - Client: Chrome 105.0.5195.134

**Additional context**
Yes, I realize these are unorthodox filenames. They are still, however, valid in most filesystems and therefore should not break `static-web-server`, IMO. For whatever it's worth, I'm submitting the bug not because I'm poring through code and found something to nitpick about, but because I actually ran into the above problems in my usage.  🤷



Discussion / hints:
Sure. Those URLs are simply not encoded therefore leading the browser to interpret them deliberately.
But in general, URL links that rely on directory/file names should be properly encoded.

I will also label this as a bug so a fix PR will come soon.
Sure. Those URLs are simply not encoded therefore leading the browser to interpret them deliberately.
But in general, URL links that rely on directory/file names should be properly encoded.

I will also label this as a bug so a fix PR will come soon.

Fix patch:
diff --git a/src/directory_listing.rs b/src/directory_listing.rs
index 0e12b1c5..188a35b8 100644
--- a/src/directory_listing.rs
+++ b/src/directory_listing.rs
@@ -5,7 +5,7 @@ use headers::{ContentLength, ContentType, HeaderMapExt};
 use humansize::{file_size_opts, FileSize};
 use hyper::{Body, Method, Response, StatusCode};
 use mime_guess::mime;
-use percent_encoding::percent_decode_str;
+use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
 use std::cmp::Ordering;
 use std::future::Future;
 use std::io;
@@ -92,15 +92,16 @@ async fn read_dir_entries(
     while let Some(entry) = file_entries.next_entry().await? {
         let meta = entry.metadata().await?;
 
-        let mut name = entry
+        let name = entry
             .file_name()
             .into_string()
             .map_err(|err| anyhow::anyhow!(err.into_string().unwrap_or_default()))?;
+        let mut name_encoded = utf8_percent_encode(&name, NON_ALPHANUMERIC).to_string();
 
         let mut filesize = 0_u64;
 
         if meta.is_dir() {
-            name += "/";
+            name_encoded += "/";
             dirs_count += 1;
         } else if meta.is_file() {
             filesize = meta.len();
@@ -108,7 +109,7 @@ async fn read_dir_entries(
         } else if meta.file_type().is_symlink() {
             let m = tokio::fs::symlink_metadata(entry.path().canonicalize()?).await?;
             if m.is_dir() {
-                name += "/";
+                name_encoded += "/";
                 dirs_count += 1;
             } else {
                 filesize = meta.len();
@@ -141,7 +142,7 @@ async fn read_dir_entries(
                 }
                 base_str.push('/');
             }
-            base_str.push_str(&name);
+            base_str.push_str(&name_encoded);
             uri = Some(base_str);
         }
 
@@ -152,7 +153,7 @@ async fn read_dir_entries(
                 String::from("-")
             }
         };
-        files_found.push((name, modified, filesize, uri));
+        files_found.push((name_encoded, modified, filesize, uri));
     }
 
     // Check the query request uri for a sorting type. E.g https://blah/?sort=5
@@ -228,14 +229,15 @@ fn create_auto_index(
         }
 
         let file_uri = uri.clone().unwrap_or_else(|| file_name.to_owned());
+        let file_name_decoded = percent_decode_str(file_name).decode_utf8()?.to_string();
 
         table_row = format!(
             "{}<tr><td><a href=\"{}\">{}</a></td><td>{}</td><td align=\"right\">{}</td></tr>",
-            table_row, file_uri, file_name, file_modified, filesize_str
+            table_row, file_uri, file_name_decoded, file_modified, filesize_str
         );
     }
 
-    let current_path = percent_decode_str(base_path).decode_utf8()?.to_owned();
+    let current_path = percent_decode_str(base_path).decode_utf8()?.to_string();
     let dirs_str = if dirs_count == 1 {
         "directory"
     } else {
diff --git "a/tests/fixtures/public/sp\303\251cial direct\303\266ry/sp\303\251cial file.txt~" "b/tests/fixtures/public/sp\303\251cial direct\303\266ry/sp\303\251cial file.txt~"
new file mode 100644
index 00000000..e69de29b


Test patch:
diff --git a/tests/dir_listing.rs b/tests/dir_listing.rs
index c6694b10..d70b3452 100644
--- a/tests/dir_listing.rs
+++ b/tests/dir_listing.rs
@@ -160,4 +160,44 @@ mod tests {
             }
         }
     }
+
+    #[tokio::test]
+    async fn dir_listing_links_properly_encoded() {
+        for method in METHODS {
+            match static_files::handle(&HandleOpts {
+                method: &method,
+                headers: &HeaderMap::new(),
+                base_path: &root_dir("tests/fixtures/public/"),
+                uri_path: "/",
+                uri_query: None,
+                dir_listing: true,
+                dir_listing_order: 6,
+                redirect_trailing_slash: true,
+                compression_static: false,
+            })
+            .await
+            {
+                Ok((mut res, _)) => {
+                    assert_eq!(res.status(), 200);
+                    assert_eq!(res.headers()["content-type"], "text/html; charset=utf-8");
+
+                    let body = hyper::body::to_bytes(res.body_mut())
+                        .await
+                        .expect("unexpected bytes error during `body` conversion");
+                    let body_str = std::str::from_utf8(&body).unwrap();
+
+                    assert_eq!(
+                        body_str.contains(
+                            r#"<a href="sp%C3%A9cial%20direct%C3%B6ry/">spécial directöry/</a>"#
+                        ),
+                        method == Method::GET
+                    );
+                }
+                Err(status) => {
+                    assert!(method != Method::GET && method != Method::HEAD);
+                    assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
+                }
+            }
+        }
+    }
 }