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

Issue:
Incorrect Content-Encoding for pre-compressed zstd files
### Search for duplicate issues

- [X] I already searched, and this issue is not a duplicate.

### Issue scope

Library (crate)

### Describe the bug

When serving pre-compressed files using zstd, the Content-Encoding header is set to "zst" instead of the correct "zstd". This causes browsers and other clients to fail to handle the content properly.

### How to reproduce it

1. Start the static-web-server with compression-static enabled and with zstd compressed files. e.g. 
```
echo "Test Content" > index.html
zstd index.html
docker run -p 8080:80 -v "$(pwd):/public" joseluisq/static-web-server:2-debian@sha256:8208279b906b8382d616162a8ba04eda00e4b978fac368c282dfb4cb1bfec136  --compression-static=true
```
2. Access the server with Accept-Encoding set to zstd, and observe that the Content-Encoding is returned as "zst".
```
$ curl 127.0.0.1:8080 -v -H "Accept-Encoding: zstd" --compressed 
*   Trying 127.0.0.1:8080...
* Connected to 127.0.0.1 (127.0.0.1) port 8080 (#0)
> GET / HTTP/1.1
> Host: 127.0.0.1:8080
> User-Agent: curl/7.81.0
> Accept: */*
> Accept-Encoding: zstd
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< last-modified: Wed, 29 May 2024 02:46:36 GMT
< content-type: text/html
< accept-ranges: bytes
< content-encoding: zst
< vary: accept-encoding
< cache-control: public, max-age=86400
< transfer-encoding: chunked
< date: Wed, 29 May 2024 02:46:45 GMT
<
curl: (61) Unrecognized content encoding type. libcurl understands deflate, gzip, br, zstd content encodings.
```

### Expected behavior

The Content-Encoding header should be "zstd".

### Complementary information

_No response_

### Build target

Docker linux/amd64

### Environment and specs

- [x] **static-web-server:** v2.31.1
- [x] **Rust:** v1.78.0
- [x] **OS:** Docker on Ubuntu 22.04
- [x] **Arch:** x86_64 (64-bit)
- [x] **Docker:** 26.1.3-1
- [x] **Client:** curl


### Additional context

This behavior is caused by the precompressed_variant function in src/compression_static.rs.

Pull request description:
Fix incorrect `Content-Encoding` for pre-compressed Zstd file requests
<!--- Provide a general summary of your changes in the Title above -->

## Description
<!--- Describe your changes but try to be as concise as possible -->
This PR fixes an issue when performing file requests with the `Accept-Encoding: zstd` header and getting an incorrect `Content-Encoding` header value for Zstd files if enabling the `compression-static` feature.

Then incorrect `Content-Encoding` header value was `zst` (extension) rather than `zstd` (encoding).

## Related Issue
<!--- This project only accepts pull requests related to open issues -->
<!--- If suggesting a new feature or change, please discuss it in an issue first -->
<!--- If fixing a bug, there should be an issue describing it with steps to reproduce -->
<!--- Please link to the issue here: -->

Fixes #451

## Motivation and Context
<!--- Why is this change required? What problem does it solve? -->
<!--- If it fixes an open issue, please link to the issue here. -->

## How Has This Been Tested?
<!--- Please describe in detail how you tested your changes. -->
<!--- Include details of your testing environment and the tests you ran to -->
<!--- See how your change affects other areas of the code, etc. -->

The server will reply as expected.

```sh
$ docker run --rm joseluisq/alpine-curl curl -s -i -v -H "Accept-Encoding: zstd" --compressed http://100.100.1.100:8787/
# * processing: http://100.100.1.100:8787/
# *   Trying 100.100.1.100:8787...
# * Connected to 100.100.1.100 (100.100.1.100) port 8787
# > GET / HTTP/1.1
# > Host: 100.100.1.100:8787
# > User-Agent: curl/8.2.0
# > Accept: */*
> Accept-Encoding: zstd
# >
# HTTP/1.1 200 OK
# < HTTP/1.1 200 OK
# < vary: accept-encoding
# < content-type: text/html
# < accept-ranges: bytes
# < last-modified: Wed, 22 May 2024 22:43:10 GMT
< content-encoding: zstd
# < cache-control: public, max-age=86400
# < transfer-encoding: chunked
# < date: Wed, 29 May 2024 04:16:24 GMT
# <
# { [357 bytes data]
# * Connection #0 to host 100.100.1.100 left intact
# vary: accept-encoding
# content-type: text/html
# accept-ranges: bytes
# last-modified: Wed, 22 May 2024 22:43:10 GMT
content-encoding: zstd
# cache-control: public, max-age=86400
# transfer-encoding: chunked
# date: Wed, 29 May 2024 04:16:24 GMT

# <!DOCTYPE html>
# <html lang="en">
# <head>
#     <meta charset="utf-8">
#     <title>Static Web Server</title>
#     <link rel="stylesheet" href="/assets/main.css">
#     <link rel="shortcut icon" href="/assets/favicon.ico">
# </head>
# <body>
#     <h1>Static Web Server</h1>
#     <p>A cross-platform, high-performance and asynchronous web server for static files-serving. ⚡</p>
#     <p><a href="https://github.com/static-web-server/static-web-server/" target="_blank">View on GitHub</a></p>
#     <script src="/assets/main.js"></script>
# </body>
# </html>
```

## Screenshots (if appropriate):


Patch:
diff --git a/src/compression_static.rs b/src/compression_static.rs
index 88c6799..6dd47a0 100644
--- a/src/compression_static.rs
+++ b/src/compression_static.rs
@@ -19,13 +19,13 @@ use crate::headers_ext::ContentCoding;
 use crate::Error;
 
 /// It defines the pre-compressed file variant metadata of a particular file path.
-pub struct CompressedFileVariant<'a> {
+pub struct CompressedFileVariant {
     /// Current file path.
     pub file_path: PathBuf,
     /// The metadata of the current file.
     pub metadata: Metadata,
-    /// The file extension.
-    pub extension: &'a str,
+    /// The content encoding based on the file extension.
+    pub encoding: ContentCoding,
 }
 
 /// Initializes static compression.
@@ -57,7 +57,7 @@ pub(crate) fn post_process<T>(
 pub async fn precompressed_variant<'a>(
     file_path: &Path,
     headers: &'a HeaderMap<HeaderValue>,
-) -> Option<CompressedFileVariant<'a>> {
+) -> Option<CompressedFileVariant> {
     tracing::trace!(
         "preparing pre-compressed file variant path of {}",
         file_path.display()
@@ -119,7 +119,7 @@ pub async fn precompressed_variant<'a>(
         return Some(CompressedFileVariant {
             file_path,
             metadata,
-            extension: if comp_ext == "gz" { "gzip" } else { comp_ext },
+            encoding,
         });
     }
 
diff --git a/src/fs/meta.rs b/src/fs/meta.rs
index 44a1eb0..8a1a782 100644
--- a/src/fs/meta.rs
+++ b/src/fs/meta.rs
@@ -10,6 +10,7 @@ use http::StatusCode;
 use std::fs::Metadata;
 use std::path::{Path, PathBuf};
 
+use crate::headers_ext::ContentCoding;
 use crate::Result;
 
 /// It defines a composed file metadata structure containing the current file
@@ -24,7 +25,7 @@ pub(crate) struct FileMetadata<'a> {
     // If either `file_path` or `precompressed_variant` is a directory.
     pub is_dir: bool,
     // The precompressed file variant for the current `file_path`.
-    pub precompressed_variant: Option<(PathBuf, &'a str)>,
+    pub precompressed_variant: Option<(PathBuf, ContentCoding)>,
 }
 
 /// Try to find the file system metadata for the given file path or return a `Not Found` error.
diff --git a/src/static_files.rs b/src/static_files.rs
index f5e3243..6dccc37 100644
--- a/src/static_files.rs
+++ b/src/static_files.rs
@@ -182,13 +182,22 @@ pub async fn handle<'a>(opts: &HandleOpts<'a>) -> Result<StaticFileResponse, Sta
 
     // Check for a pre-compressed file variant if present under the `opts.compression_static` context
     if let Some(precompressed_meta) = precompressed_variant {
-        let (precomp_path, precomp_ext) = precompressed_meta;
+        let (precomp_path, precomp_encoding) = precompressed_meta;
         let mut resp = file_reply(headers_opt, file_path, &metadata, Some(precomp_path)).await?;
 
         // Prepare corresponding headers to let know how to decode the payload
         resp.headers_mut().remove(CONTENT_LENGTH);
-        resp.headers_mut()
-            .insert(CONTENT_ENCODING, precomp_ext.parse().unwrap());
+        let encoding = match HeaderValue::from_str(precomp_encoding.as_str()) {
+            Ok(val) => val,
+            Err(err) => {
+                tracing::error!(
+                    "unable to parse header value from content encoding: {:?}",
+                    err
+                );
+                return Err(StatusCode::INTERNAL_SERVER_ERROR);
+            }
+        };
+        resp.headers_mut().insert(CONTENT_ENCODING, encoding);
 
         return Ok(StaticFileResponse {
             resp,
@@ -245,7 +254,7 @@ async fn get_composed_file_metadata<'a>(
                                 file_path,
                                 metadata: p.metadata,
                                 is_dir: false,
-                                precompressed_variant: Some((p.file_path, p.extension)),
+                                precompressed_variant: Some((p.file_path, p.encoding)),
                             });
                         }
                     }
@@ -293,7 +302,7 @@ async fn get_composed_file_metadata<'a>(
                             file_path,
                             metadata: p.metadata,
                             is_dir: false,
-                            precompressed_variant: Some((p.file_path, p.extension)),
+                            precompressed_variant: Some((p.file_path, p.encoding)),
                         });
                     }
                 }
@@ -324,7 +333,7 @@ async fn get_composed_file_metadata<'a>(
                         file_path,
                         metadata: p.metadata,
                         is_dir: false,
-                        precompressed_variant: Some((p.file_path, p.extension)),
+                        precompressed_variant: Some((p.file_path, p.encoding)),
                     });
                 }
             }
@@ -362,7 +371,7 @@ async fn get_composed_file_metadata<'a>(
                                 file_path,
                                 metadata: p.metadata,
                                 is_dir: false,
-                                precompressed_variant: Some((p.file_path, p.extension)),
+                                precompressed_variant: Some((p.file_path, p.encoding)),
                             });
                         }
                     }


Test patch:
diff --git a/tests/compression_static.rs b/tests/compression_static.rs
index e044c06..fff1c7c 100644
--- a/tests/compression_static.rs
+++ b/tests/compression_static.rs
@@ -258,4 +258,74 @@ mod tests {
         .await
         .expect("unexpected error response on `handle` function");
     }
+
+    #[tokio::test]
+    async fn compression_static_zstd_file_exists() {
+        let mut headers = HeaderMap::new();
+        headers.insert(
+            http::header::ACCEPT_ENCODING,
+            "gzip, deflate, br, zstd".parse().unwrap(),
+        );
+
+        let mainjs_zst_path = PathBuf::from("tests/fixtures/public/main.js.zst");
+        let mainjs_zst_path_public = public_dir().join("main.js.zst");
+        std::fs::copy(&mainjs_zst_path, &mainjs_zst_path_public)
+            .expect("unexpected error copying fixture file");
+
+        let result = static_files::handle(&HandleOpts {
+            method: &Method::GET,
+            headers: &headers,
+            base_path: &public_dir(),
+            uri_path: "main.js",
+            uri_query: None,
+            #[cfg(feature = "directory-listing")]
+            dir_listing: false,
+            #[cfg(feature = "directory-listing")]
+            dir_listing_order: 6,
+            #[cfg(feature = "directory-listing")]
+            dir_listing_format: &DirListFmt::Html,
+            redirect_trailing_slash: true,
+            #[cfg(any(
+                feature = "compression",
+                feature = "compression-deflate",
+                feature = "compression-gzip",
+                feature = "compression-deflate",
+                feature = "compression-brotli",
+                feature = "compression-zstd"
+            ))]
+            compression_static: true,
+            ignore_hidden_files: false,
+            index_files: &[],
+        })
+        .await
+        .expect("unexpected error response on `handle` function");
+        let mut res = result.resp;
+
+        let mainjs_zst_buf =
+            std::fs::read(&mainjs_zst_path).expect("unexpected error when reading index.html.gz");
+        let mainjs_zst_buf = Bytes::from(mainjs_zst_buf);
+
+        std::fs::remove_file(mainjs_zst_path_public).unwrap();
+
+        let headers = res.headers();
+
+        assert_eq!(res.status(), 200);
+        assert!(!headers.contains_key("content-length"));
+        assert_eq!(headers["content-encoding"], "zstd");
+        assert_eq!(headers["accept-ranges"], "bytes");
+        assert!(!headers["last-modified"].is_empty());
+        assert_eq!(
+            &headers["content-type"], "application/javascript",
+            "content-type is not html"
+        );
+
+        let body = hyper::body::to_bytes(res.body_mut())
+            .await
+            .expect("unexpected bytes error during `body` conversion");
+
+        assert_eq!(
+            body, mainjs_zst_buf,
+            "body and mainjs_zst_buf are not equal in length"
+        );
+    }
 }
diff --git a/tests/dir_listing.rs b/tests/dir_listing.rs
index 01edfd9..fe97d5e 100644
--- a/tests/dir_listing.rs
+++ b/tests/dir_listing.rs
@@ -266,7 +266,7 @@ mod tests {
 
                     if method == Method::GET {
                         let entries: Vec<FileEntry> = serde_json::from_str(body_str).unwrap();
-                        assert_eq!(entries.len(), 4);
+                        assert_eq!(entries.len(), 5);
 
                         let first_entry = entries.first().unwrap();
                         assert_eq!(first_entry.name, "spécial-directöry.net");