Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ rust-embed-for-web-utils = { version = "11.2.1", path = "utils" }
chrono = { version = "0.4", default-features = false }
flate2 = "1.0"
brotli = "6.0"
zstd = "0.13"
actix-web = "4.4"

[features]
default = ["interpolate-folder-path", "include-exclude"]
default = ["interpolate-folder-path", "include-exclude", "compression-zstd"]
# Even in debug mode use a release embed.
# We use this to test embed code in our tests.
always-embed = ["rust-embed-for-web-impl/always-embed"]
Expand All @@ -32,20 +33,31 @@ include-exclude = [
"rust-embed-for-web-impl/include-exclude",
"rust-embed-for-web-utils/include-exclude",
]
compression-zstd = ["rust-embed-for-web-impl/compression-zstd", "rust-embed-for-web-utils/compression-zstd"]

[workspace]
members = ["impl", "utils"]

[[test]]
name = "compression"
path = "tests/compression.rs"
required-features = ["always-embed", "compression-zstd"]

[[test]]
name = "compression_without_zstd"
path = "tests/compression_without_zstd.rs"
required-features = ["always-embed"]

[[test]]
name = "gzip"
path = "tests/gzip.rs"
required-features = ["always-embed"]

[[test]]
name = "zstd"
path = "tests/zstd.rs"
required-features = ["always-embed", "compression-zstd"]

[[test]]
name = "include-exclude"
path = "tests/include-exclude.rs"
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ executable in exchange for better performance at runtime. In particular:
or decompress anything at runtime.
- If the compression makes little difference, for example a jpeg file won't
compress much further if at all, then the compressed version is not included.
- You can also disable this behavior by adding an attribute `#[gzip = false]` and `#[br = false]`
- You can also disable this behavior by adding an attribute `#[gzip = false]`, `#[br = false]`, or `#[zstd = false]`
When disabled, the compressed files won't be included for that embed.
- Some metadata that is useful for web headers like `ETag` and `Last-Modified`
are computed ahead of time and embedded into the executable. This makes it
Expand Down Expand Up @@ -68,8 +68,8 @@ The path for the `folder` is resolved relative to where `Cargo.toml` is.

### Disabling compression

You can add `#[gzip = false]` and/or `#[br = false]` attributes to your embed to
disable gzip and brotli compression for the files in that embed.
You can add `#[gzip = false]`, `#[br = false]`, and/or `#[zstd = false]` attributes to your embed to
disable gzip, brotli, and/or zstd compression for the files in that embed.
`rust-embed-for-web` will only include compressed files where the compression
actually makes files smaller so files that won't compress well like images or
archives already don't include their compressed versions. However you can
Expand Down
2 changes: 2 additions & 0 deletions impl/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ walkdir = "2.4.0"
# Compression
flate2 = "1.0"
brotli = "6.0"
zstd = { version = "0.13", optional = true }

globset = { version = "0.4", optional = true }

Expand All @@ -39,3 +40,4 @@ default = []
interpolate-folder-path = ["shellexpand"]
include-exclude = ["rust-embed-for-web-utils/include-exclude", "globset"]
always-embed = []
compression-zstd = ["zstd", "rust-embed-for-web-utils/compression-zstd"]
1 change: 1 addition & 0 deletions impl/src/attributes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ pub(crate) fn read_attribute_config(ast: &syn::DeriveInput) -> Config {
"exclude" => parse_str(attribute).map(|v| config.add_exclude(v)),
"gzip" => parse_bool(attribute).map(|v| config.set_gzip(v)),
"br" => parse_bool(attribute).map(|v| config.set_br(v)),
"zstd" => parse_bool(attribute).map(|v| config.set_zstd(v)),
_ => None,
};
}
Expand Down
25 changes: 25 additions & 0 deletions impl/src/compress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ use std::io::{BufReader, Write};

use brotli::enc::BrotliEncoderParams;
use flate2::{write::GzEncoder, Compression};
#[cfg(feature = "compression-zstd")]
use zstd::stream::write::Encoder as ZstdEncoder;

/// Only include the compressed version if it is at least this much smaller than
/// the uncompressed version.
Expand Down Expand Up @@ -39,3 +41,26 @@ pub(crate) fn compress_br(data: &[u8]) -> Option<Vec<u8>> {
None
}
}

#[cfg(feature = "compression-zstd")]
pub(crate) fn compress_zstd(data: &[u8]) -> Option<Vec<u8>> {
let mut data_zstd: Vec<u8> = Vec::new();
let mut encoder = ZstdEncoder::new(&mut data_zstd, 3).expect("Failed to create zstd encoder");
encoder
.write_all(data)
.expect("Failed to compress zstd data");
encoder
.finish()
.expect("Failed to finish compression of zstd data");

if data_zstd.len() < ((data.len() as f64) * COMPRESSION_INCLUDE_THRESHOLD) as usize {
Some(data_zstd)
} else {
None
}
}

#[cfg(not(feature = "compression-zstd"))]
pub(crate) fn compress_zstd(_data: &[u8]) -> Option<Vec<u8>> {
None
}
8 changes: 7 additions & 1 deletion impl/src/embed.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use proc_macro2::TokenStream as TokenStream2;
use rust_embed_for_web_utils::{get_files, Config, DynamicFile, EmbedableFile, FileEntry};

use crate::compress::{compress_br, compress_gzip};
use crate::compress::{compress_br, compress_gzip, compress_zstd};

/// Anything that can be embedded into the program.
///
Expand Down Expand Up @@ -70,6 +70,11 @@ impl<'t> MakeEmbed for EmbedDynamicFile<'t> {
} else {
None::<Vec<u8>>.make_embed()
};
let data_zstd = if self.config.should_zstd() {
compress_zstd(&data).make_embed()
} else {
None::<Vec<u8>>.make_embed()
};
let data = data.make_embed();
let hash = file.hash().make_embed();
let etag = file.etag().make_embed();
Expand All @@ -83,6 +88,7 @@ impl<'t> MakeEmbed for EmbedDynamicFile<'t> {
#data,
#data_gzip,
#data_br,
#data_zstd,
#hash,
#etag,
#last_modified,
Expand Down
5 changes: 4 additions & 1 deletion impl/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,10 @@ fn impl_rust_embed_for_web(ast: &syn::DeriveInput) -> TokenStream2 {
}
}

#[proc_macro_derive(RustEmbed, attributes(folder, prefix, include, exclude, gzip, br))]
#[proc_macro_derive(
RustEmbed,
attributes(folder, prefix, include, exclude, gzip, br, zstd)
)]
/// A folder that is embedded into your program.
///
/// For example:
Expand Down
1 change: 1 addition & 0 deletions tests/basic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ fn file_name_exists() {
#[test]
fn readme_example() {
let index = Embed::get("index.html").unwrap().data();
#[allow(clippy::useless_asref)]
let contents = std::str::from_utf8(index.as_ref()).unwrap();
assert!(!contents.is_empty());
}
17 changes: 15 additions & 2 deletions tests/compression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ struct Embed;
fn html_files_are_compressed() {
assert!(Embed::get("index.html").unwrap().data_gzip().is_some());
assert!(Embed::get("index.html").unwrap().data_br().is_some());
assert!(Embed::get("index.html").unwrap().data_zstd().is_some());
}

#[test]
Expand All @@ -20,14 +21,18 @@ fn image_files_are_not_compressed() {
.data_gzip()
.is_none());
assert!(Embed::get("images/flower.jpg").unwrap().data_br().is_none());
assert!(Embed::get("images/flower.jpg")
.unwrap()
.data_zstd()
.is_none());
}

#[test]
fn compression_gzip_roundtrip() {
let compressed = Embed::get("index.html").unwrap().data_gzip().unwrap();
let mut decompressed: Vec<u8> = Vec::new();
let mut decoder = GzDecoder::new(&mut decompressed);
decoder.write_all(&compressed[..]).unwrap();
decoder.write_all(compressed).unwrap();
decoder.finish().unwrap();
let decompressed_body = String::from_utf8_lossy(&decompressed[..]);
assert!(decompressed_body.starts_with("<!DOCTYPE html>"));
Expand All @@ -37,8 +42,16 @@ fn compression_gzip_roundtrip() {
fn compression_br_roundtrip() {
let compressed = Embed::get("index.html").unwrap().data_br().unwrap();
let mut decompressed: Vec<u8> = Vec::new();
let mut data_read = BufReader::new(&compressed[..]);
let mut data_read = BufReader::new(compressed);
brotli::BrotliDecompress(&mut data_read, &mut decompressed).unwrap();
let decompressed_body = String::from_utf8_lossy(&decompressed[..]);
assert!(decompressed_body.starts_with("<!DOCTYPE html>"));
}

#[test]
fn compression_zstd_roundtrip() {
let compressed = Embed::get("index.html").unwrap().data_zstd().unwrap();
let decompressed = zstd::bulk::decompress(compressed, 1024 * 1024).unwrap();
let decompressed_body = String::from_utf8_lossy(&decompressed[..]);
assert!(decompressed_body.starts_with("<!DOCTYPE html>"));
}
64 changes: 64 additions & 0 deletions tests/compression_without_zstd.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
use std::io::{BufReader, Write};

use flate2::write::GzDecoder;
use rust_embed_for_web::{EmbedableFile, RustEmbed};

#[derive(RustEmbed)]
#[folder = "examples/public"]
struct Embed;

#[test]
fn html_files_gzip_and_br_compression() {
assert!(Embed::get("index.html").unwrap().data_gzip().is_some());
assert!(Embed::get("index.html").unwrap().data_br().is_some());
}

#[test]
fn zstd_behavior_without_feature() {
// When compression-zstd feature is not enabled, data_zstd should return None
#[cfg(not(feature = "compression-zstd"))]
{
assert!(Embed::get("index.html").unwrap().data_zstd().is_none());
}

// When compression-zstd feature is enabled, it may return Some or None based on effectiveness
#[cfg(feature = "compression-zstd")]
{
// Just test that the method doesn't panic
let _ = Embed::get("index.html").unwrap().data_zstd();
}
}

#[test]
fn image_files_are_not_compressed() {
assert!(Embed::get("images/flower.jpg")
.unwrap()
.data_gzip()
.is_none());
assert!(Embed::get("images/flower.jpg").unwrap().data_br().is_none());
assert!(Embed::get("images/flower.jpg")
.unwrap()
.data_zstd()
.is_none());
}

#[test]
fn compression_gzip_roundtrip() {
let compressed = Embed::get("index.html").unwrap().data_gzip().unwrap();
let mut decompressed: Vec<u8> = Vec::new();
let mut decoder = GzDecoder::new(&mut decompressed);
decoder.write_all(compressed).unwrap();
decoder.finish().unwrap();
let decompressed_body = String::from_utf8_lossy(&decompressed[..]);
assert!(decompressed_body.starts_with("<!DOCTYPE html>"));
}

#[test]
fn compression_br_roundtrip() {
let compressed = Embed::get("index.html").unwrap().data_br().unwrap();
let mut decompressed: Vec<u8> = Vec::new();
let mut data_read = BufReader::new(compressed);
brotli::BrotliDecompress(&mut data_read, &mut decompressed).unwrap();
let decompressed_body = String::from_utf8_lossy(&decompressed[..]);
assert!(decompressed_body.starts_with("<!DOCTYPE html>"));
}
104 changes: 104 additions & 0 deletions tests/dynamic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
use rust_embed_for_web::{EmbedableFile, RustEmbed};

// This test is designed to run in debug mode without always-embed feature
// to test the DynamicFile code paths that always return None for compressed data
#[derive(RustEmbed)]
#[folder = "examples/public/"]
struct DynamicAssets;

#[test]
fn dynamic_file_compressed_data_is_none() {
// In debug mode without always-embed, this should use DynamicFile
let file = DynamicAssets::get("index.html").unwrap();

// When always-embed is not enabled, DynamicFile always returns None for compressed data
#[cfg(not(feature = "always-embed"))]
{
assert!(file.data_gzip().is_none());
assert!(file.data_br().is_none());
assert!(file.data_zstd().is_none());
}

// When always-embed is enabled, EmbeddedFile may have compressed data
#[cfg(feature = "always-embed")]
{
// Just verify the file exists and has data - compression depends on the build
assert!(!file.data().is_empty());
}

// But it should always have the original data
assert!(!file.data().is_empty());
}

#[test]
fn dynamic_file_image_compressed_data_is_none() {
// Test with an image file too
let file = DynamicAssets::get("images/flower.jpg").unwrap();

// When always-embed is not enabled, DynamicFile always returns None for compressed data
#[cfg(not(feature = "always-embed"))]
{
assert!(file.data_gzip().is_none());
assert!(file.data_br().is_none());
assert!(file.data_zstd().is_none());
}

// When always-embed is enabled, EmbeddedFile may have compressed data (usually None for images)
#[cfg(feature = "always-embed")]
{
// Just verify the file exists and has data
assert!(!file.data().is_empty());
}

// But it should always have the original data
assert!(!file.data().is_empty());
}

#[test]
fn explicit_dynamic_compression_coverage() {
// Explicitly test to ensure coverage of DynamicFile compression methods
let file = DynamicAssets::get("index.html").unwrap();

// When always-embed is not enabled, test the DynamicFile paths
#[cfg(not(feature = "always-embed"))]
{
// Test each compression method explicitly to ensure coverage
let gzip_result = file.data_gzip();
assert_eq!(gzip_result, None);

let br_result = file.data_br();
assert_eq!(br_result, None);

let zstd_result = file.data_zstd();
assert_eq!(zstd_result, None);
}

// When always-embed is enabled, test the EmbeddedFile paths
#[cfg(feature = "always-embed")]
{
// Just verify the methods work - the actual compressed data depends on build configuration
let _gzip_result = file.data_gzip();
let _br_result = file.data_br();
let _zstd_result = file.data_zstd();
}

// Ensure we have actual data though
let actual_data = file.data();
assert!(!actual_data.is_empty());
}

#[test]
fn specific_dynamic_none_coverage() {
// Create a DynamicFile directly to ensure we test the None paths
use rust_embed_for_web::{DynamicFile, EmbedableFile};

let file = DynamicFile::read_from_fs("examples/public/index.html").unwrap();

// These should all return None for DynamicFile, ensuring coverage of those lines
assert!(file.data_gzip().is_none());
assert!(file.data_br().is_none());
assert!(file.data_zstd().is_none());

// But the regular data should work
assert!(!file.data().is_empty());
}
Loading