From bf3425b92999c0e41b023384e561017ef1b5e454 Mon Sep 17 00:00:00 2001 From: Sunshine Date: Fri, 16 Oct 2026 18:45:43 -0400 Subject: [PATCH] use the shortest encoding for data URLs, with minimal escaping --- src/html.rs | 5 ++-- src/url.rs | 39 +++++++++++++++++++++++-- tests/cli/basic.rs | 6 ++-- tests/cli/local_files.rs | 8 +++--- tests/cli/noscript.rs | 8 +++--- tests/css/embed_css.rs | 15 +++++++--- tests/session/retrieve_asset.rs | 4 +-- tests/url/create_data_url.rs | 50 +++++++++++++++++++++++++-------- 8 files changed, 101 insertions(+), 34 deletions(-) diff --git a/src/html.rs b/src/html.rs index 26ec0f6..9b2d97b 100644 --- a/src/html.rs +++ b/src/html.rs @@ -17,7 +17,8 @@ use crate::css::{embed_css, embed_stylesheet}; use crate::js::{attr_is_event_handler, escape_script_end_tag}; use crate::session::Session; use crate::url::{ - EMPTY_IMAGE_DATA_URL, Url, clean_url, create_data_url, is_url_and_has_protocol, resolve_url, + EMPTY_IMAGE_DATA_URL, Url, clean_url, create_data_url, create_srcset_data_url, + is_url_and_has_protocol, resolve_url, }; const FAVICON_VALUES: &[&str] = &["icon", "shortcut icon"]; @@ -160,7 +161,7 @@ pub fn embed_srcset(session: &mut Session, document_url: &Url, srcset: &str) -> Ok((image_data, image_final_url, image_media_type, image_charset)) => { if session.options.output_format == MonolithOutputFormat::HTML { // Append retrieved asset as a data URL - result.push_str(&create_data_url( + result.push_str(&create_srcset_data_url( &image_media_type, &image_charset, image_data, diff --git a/src/url.rs b/src/url.rs index f12c4fa..9265633 100644 --- a/src/url.rs +++ b/src/url.rs @@ -1,6 +1,6 @@ use std::fmt::Write; -use dataurl::{DataEncoding, DataUrl}; +use dataurl::{DataEncoding, DataUrl, Escaping}; pub use url::Url; use crate::core::detect_media_type; @@ -19,6 +19,11 @@ pub fn clean_url(url: Url) -> Url { /// Creates a data URL for an asset, with an optional fragment (already percent-encoded, as Url::fragment() returns it). /// Passing data as a Vec moves it in without copying. +/// +/// The data gets percent-encoded or base64-encoded, whichever is shorter, and percent-encoding only escapes +/// what has to be, which keeps text assets like SVG and CSS small and readable. That's only safe inside +/// double-quoted HTML attributes and CSS strings, which is where monolith puts data URLs; srcset is the exception, +/// see create_srcset_data_url(). pub fn create_data_url( media_type: &str, charset: &str, @@ -26,8 +31,33 @@ pub fn create_data_url( final_asset_url: &Url, fragment: Option<&str>, ) -> String { - let data: Vec = data.into(); + let mut data_url = new_data_url(media_type, charset, data.into(), final_asset_url, fragment); + data_url.set_data_encoding(DataEncoding::Shortest); + data_url.set_escaping(Escaping::Minimal); + serialize_data_url(&data_url) +} +/// Same as create_data_url(), but always uses base64, since srcset gets split on whitespace +/// and has trailing commas removed from URLs, which percent-encoded data could contain. +pub fn create_srcset_data_url( + media_type: &str, + charset: &str, + data: impl Into>, + final_asset_url: &Url, + fragment: Option<&str>, +) -> String { + let mut data_url = new_data_url(media_type, charset, data.into(), final_asset_url, fragment); + data_url.set_data_encoding(DataEncoding::Base64); + serialize_data_url(&data_url) +} + +fn new_data_url( + media_type: &str, + charset: &str, + data: Vec, + final_asset_url: &Url, + fragment: Option<&str>, +) -> DataUrl { // TODO: move this block out of this function let media_type: String = if media_type.is_empty() { detect_media_type(&data, final_asset_url) @@ -36,7 +66,6 @@ pub fn create_data_url( }; let mut data_url = DataUrl::new(); - data_url.set_data_encoding(DataEncoding::Base64); data_url.set_data(data); data_url.set_fragment(fragment); @@ -52,6 +81,10 @@ pub fn create_data_url( let _ = data_url.set_parameter("charset", Some(charset)); } + data_url +} + +fn serialize_data_url(data_url: &DataUrl) -> String { // Allocating the exact size upfront avoids reallocations (and copies) while the string grows let mut result = String::with_capacity(data_url.encoded_len()); write!(result, "{data_url}").unwrap(); diff --git a/tests/cli/basic.rs b/tests/cli/basic.rs index d1ca8c1..d6ea385 100644 --- a/tests/cli/basic.rs +++ b/tests/cli/basic.rs @@ -119,11 +119,11 @@ mod passing { @charset "UTF-8"; - @import "data:text/css;charset=utf-8;base64,Ym9keXtiYWNrZ3JvdW5kLWNvbG9yOiMwMDA7Y29sb3I6I2ZmZn0K"; + @import "data:text/css;charset=utf-8,body{background-color:%23000;color:%23fff}%0A"; - @import url("data:text/css;charset=utf-8;base64,Ym9keXtiYWNrZ3JvdW5kLWNvbG9yOiMwMDA7Y29sb3I6I2ZmZn0K"); + @import url("data:text/css;charset=utf-8,body{background-color:%23000;color:%23fff}%0A"); - @import url("data:text/css;charset=utf-8;base64,Ym9keXtiYWNrZ3JvdW5kLWNvbG9yOiMwMDA7Y29sb3I6I2ZmZn0K"); + @import url("data:text/css;charset=utf-8,body{background-color:%23000;color:%23fff}%0A"); diff --git a/tests/cli/local_files.rs b/tests/cli/local_files.rs index 37b760c..665a136 100644 --- a/tests/cli/local_files.rs +++ b/tests/cli/local_files.rs @@ -55,7 +55,7 @@ mod passing { r##" Local HTML file - + @@ -209,7 +209,7 @@ document.body.style.color = "red"; // STDOUT should contain HTML with date URL for background-image in it assert_eq!( String::from_utf8_lossy(&out.stdout), - r##"
+ r##"
"## ); @@ -333,7 +333,7 @@ document.body.style.color = "red"; String::from_utf8_lossy(&out.stdout), r##" - + "##.to_owned() + r##" @@ -393,7 +393,7 @@ document.body.style.color = "red"; String::from_utf8_lossy(&out.stdout), r##" Local HTML file - + diff --git a/tests/cli/noscript.rs b/tests/cli/noscript.rs index 5544eb3..2a76f2c 100644 --- a/tests/cli/noscript.rs +++ b/tests/cli/noscript.rs @@ -38,7 +38,7 @@ mod passing { // STDOUT should contain HTML with no CSS assert_eq!( String::from_utf8_lossy(&out.stdout), - "\n\n" + "\n\n" ); // Exit code should be 0 @@ -69,7 +69,7 @@ mod passing { // STDOUT should contain HTML with no CSS assert_eq!( String::from_utf8_lossy(&out.stdout), - "\n\n" + "\n\n" ); // Exit code should be 0 @@ -100,7 +100,7 @@ mod passing { // STDOUT should contain HTML with no CSS assert_eq!( String::from_utf8_lossy(&out.stdout), - "

JS is not active

\n\n" + "

JS is not active

\n\n" ); // Exit code should be 0 @@ -131,7 +131,7 @@ mod passing { // STDOUT should contain HTML with no CSS assert_eq!( String::from_utf8_lossy(&out.stdout), - r#" + r#" "# ); diff --git a/tests/css/embed_css.rs b/tests/css/embed_css.rs index ab05094..3201015 100644 --- a/tests/css/embed_css.rs +++ b/tests/css/embed_css.rs @@ -36,7 +36,14 @@ mod passing { ("", "p::before{content:'café'}".as_bytes()), ("", b"p::before{content:'caf\xe9'}".as_slice()), ] { - let asset = monolith::url::create_data_url("text/css", charset, bytes, &url, None); + // Base64, which can go anywhere in CSS, including the unquoted url() below + let mut asset = dataurl::DataUrl::new(); + asset.set_media_type(Some("text/css")).unwrap(); + if !charset.is_empty() { + asset.set_parameter("charset", Some(charset)).unwrap(); + } + asset.set_data_encoding(dataurl::DataEncoding::Base64); + asset.set_data(bytes); for source in [ format!("@import \"{}\";", asset), @@ -298,9 +305,9 @@ mod passing { "\ @charset \"UTF-8\";\n\ \n\ - @import \"data:text/css;charset=utf-8;base64,aHRtbHtiYWNrZ3JvdW5kLWNvbG9yOiMwMDB9\";\n\ + @import \"data:text/css;charset=utf-8,html{background-color:%23000}\";\n\ \n\ - @import url(\"data:text/css;charset=utf-8;base64,aHRtbHtjb2xvcjojZmZmfQ==\")\n\ + @import url(\"data:text/css;charset=utf-8,html{color:%23fff}\")\n\ " ); } @@ -421,7 +428,7 @@ mod passing { "; const CSS_OUT: &str = "\ #language a[href=\"#translations\"]:before {\n\ - content: url(\"data:text/plain;base64,\") \"\\a \";\n\ + content: url(\"data:text/plain,\") \"\\a \";\n\ white-space: pre }\n\ "; diff --git a/tests/session/retrieve_asset.rs b/tests/session/retrieve_asset.rs index f09a29b..8d3684e 100644 --- a/tests/session/retrieve_asset.rs +++ b/tests/session/retrieve_asset.rs @@ -33,7 +33,7 @@ mod passing { assert_eq!(&charset, ""); assert_eq!( url::create_data_url(&media_type, &charset, data, &final_url, None), - "data:text/html;base64,dGFyZ2V0", + "data:text/html,target", ); assert_eq!( final_url, @@ -70,7 +70,7 @@ mod passing { .unwrap(); assert_eq!(&media_type, "text/javascript"); assert_eq!(&charset, ""); - let data_url = "data:text/javascript;base64,ZG9jdW1lbnQuYm9keS5zdHlsZS5iYWNrZ3JvdW5kQ29sb3IgPSAiZ3JlZW4iOwpkb2N1bWVudC5ib2R5LnN0eWxlLmNvbG9yID0gInJlZCI7Cg=="; + let data_url = "data:text/javascript,document.body.style.backgroundColor = %22green%22;%0Adocument.body.style.color = %22red%22;%0A"; assert_eq!( url::create_data_url(&media_type, &charset, data, &final_url, None), data_url diff --git a/tests/url/create_data_url.rs b/tests/url/create_data_url.rs index a8f3cac..1dbce0a 100644 --- a/tests/url/create_data_url.rs +++ b/tests/url/create_data_url.rs @@ -25,7 +25,7 @@ mod passing { assert_eq!( data_url.as_str(), - "data:application/javascript;base64,dmFyIHdvcmQgPSAnaGVsbG8nOwphbGVydCh3b3JkKTsK" + "data:application/javascript,var word = 'hello';%0Aalert(word);%0A" ); } @@ -42,7 +42,7 @@ mod passing { assert_eq!( data_url.as_str(), - "data:image/svg+xml;base64,PHN2Zz48L3N2Zz4K" + "data:image/svg+xml,%3Csvg%3E%3C/svg%3E%0A" ); } @@ -61,7 +61,7 @@ mod passing { assert_eq!( data_url.as_str(), - "data:application/javascript;charset=utf8;base64,dmFyIHdvcmQgPSAnaGVsbG8nOwphbGVydCh3b3JkKTsK" + "data:application/javascript;charset=utf8,var word = 'hello';%0Aalert(word);%0A" ); } @@ -78,7 +78,7 @@ mod passing { None, ); - assert_eq!(data_url.as_str(), "data:;base64,"); + assert_eq!(data_url.as_str(), "data:,"); } #[test] @@ -94,7 +94,7 @@ mod passing { None, ); - assert_eq!(data_url.as_str(), "data:;charset=utf8;base64,"); + assert_eq!(data_url.as_str(), "data:;charset=utf8,"); } #[test] @@ -110,7 +110,7 @@ mod passing { None, ); - assert_eq!(data_url.as_str(), "data:text/plain;charset=utf8;base64,"); + assert_eq!(data_url.as_str(), "data:text/plain;charset=utf8,"); } #[test] @@ -135,7 +135,7 @@ mod passing { let data_url = url::create_data_url("Image/PNG", "", b"", &Url::parse("data:,").unwrap(), None); - assert_eq!(data_url.as_str(), "data:image/png;base64,"); + assert_eq!(data_url.as_str(), "data:image/png,"); } #[test] @@ -148,14 +148,14 @@ mod passing { None, ); - assert_eq!(data_url.as_str(), "data:text/css;charset=utf-8;base64,"); + assert_eq!(data_url.as_str(), "data:text/css;charset=utf-8,"); } #[test] fn create_data_url_omits_invalid_media_type() { let data_url = url::create_data_url("wrong", "", b"", &Url::parse("data:,").unwrap(), None); - assert_eq!(data_url.as_str(), "data:;base64,"); + assert_eq!(data_url.as_str(), "data:,"); } #[test] @@ -168,7 +168,7 @@ mod passing { None, ); - assert_eq!(data_url.as_str(), "data:text/css;base64,"); + assert_eq!(data_url.as_str(), "data:text/css,"); } #[test] @@ -182,7 +182,7 @@ mod passing { asset_url.fragment(), ); - assert_eq!(data_url, "data:image/svg+xml;base64,PHN2Zy8+#a%20b"); + assert_eq!(data_url, "data:image/svg+xml,%3Csvg/%3E#a%20b"); } #[test] @@ -195,10 +195,36 @@ mod passing { &Url::parse("data:,").unwrap(), Some(fragment), ); - let mut expected = Url::parse("data:text/plain;base64,eA==").unwrap(); + let mut expected = Url::parse("data:text/plain,x").unwrap(); expected.set_fragment(Some(fragment)); assert_eq!(data_url, expected.as_str(), "{fragment:?}"); } } + + #[test] + fn create_data_url_uses_base64_when_shorter() { + let data_url = url::create_data_url( + "image/png", + "", + [0u8; 16], + &Url::parse("data:,").unwrap(), + None, + ); + + assert_eq!(data_url, "data:image/png;base64,AAAAAAAAAAAAAAAAAAAAAA=="); + } + + #[test] + fn create_srcset_data_url_always_uses_base64() { + let data_url = url::create_srcset_data_url( + "image/svg+xml", + "", + b"", + &Url::parse("data:,").unwrap(), + Some("a"), + ); + + assert_eq!(data_url, "data:image/svg+xml;base64,PHN2Zy8+#a"); + } }