From 49fb9b1b9b29c78462f1740971ad13bd8904181c Mon Sep 17 00:00:00 2001 From: DualFroz Date: Fri, 11 Sep 2026 23:07:10 +0200 Subject: [PATCH] fix: percent-encode page URLs built from file paths build_url() escaped %, # and ? one by one, so a file name carrying any other character that a URL path cannot hold - a space, a quote, an angle bracket, a control character - was emitted raw and only resolved if the host happened to serve the same byte sequence the index was built from. Encode the URL that build_url derives from the file path with the URL spec's path percent-encode set, leaving / alone so the separators between path segments survive. % stays in the set, so a file literally named in encoded form keeps pointing at the file that is on disk. URLs supplied by the caller (data-pagefind-meta="url:...", addCustomRecord) do not go through build_url and are untouched. Fixes #619 --- Cargo.lock | 1 + pagefind/Cargo.toml | 1 + pagefind/src/fossick/mod.rs | 50 +++++++++++++++++++++++++++++++------ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f001908..b9a89a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2303,6 +2303,7 @@ dependencies = [ "minifier", "pagefind_stem 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)", "path-slash", + "percent-encoding", "portpicker", "rayon", "regex", diff --git a/pagefind/Cargo.toml b/pagefind/Cargo.toml index 586fdf2..6204c24 100644 --- a/pagefind/Cargo.toml +++ b/pagefind/Cargo.toml @@ -90,6 +90,7 @@ actix-web = { version = "4", optional = true } actix-files = { version = "0.7", optional = true } lexical-core = "1.0.0" path-slash = "0.2" +percent-encoding = "2" rayon = "1.10" rust-patch = "0.1.3" typed-builder = "0.23.2" diff --git a/pagefind/src/fossick/mod.rs b/pagefind/src/fossick/mod.rs index 9373047..907d3bf 100644 --- a/pagefind/src/fossick/mod.rs +++ b/pagefind/src/fossick/mod.rs @@ -7,6 +7,7 @@ use hashbrown::HashMap; use lazy_static::lazy_static; use pagefind_stem::{Algorithm, Stemmer}; use path_slash::PathExt as _; +use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; use regex::Regex; use std::collections::BTreeMap; use std::io::BufRead; @@ -589,6 +590,22 @@ impl Fossicker { } +/// The URL spec's path percent-encode set, minus `/` so that the separators +/// between the path segments survive. `%`, `#` and `?` are already encoded one +/// by one today; the rest of the set covers the remaining characters that a +/// file name can carry but a URL path cannot, such as a space or a quote. +const PATH_ENCODE_SET: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'"') + .add(b'#') + .add(b'%') + .add(b'<') + .add(b'>') + .add(b'?') + .add(b'`') + .add(b'{') + .add(b'}'); + fn strip_index_html(url: &str) -> &str { if url.ends_with("/index.html") { &url[..url.len() - 10] @@ -621,13 +638,7 @@ fn build_url(page_url: &Path, relative_to: Option<&Path>, options: &SearchOption url.to_slash_lossy().to_owned().to_string() }; - format!( - "/{}", - final_url - .replace('%', "%25") - .replace('#', "%23") - .replace('?', "%3F") - ) + format!("/{}", utf8_percent_encode(&final_url, PATH_ENCODE_SET)) } fn normalize_content(content: &str) -> String { @@ -1256,6 +1267,31 @@ mod tests { assert_eq!(&build_url(&p, None, &opts), "/100%25/C%23/why%3F.html"); } + #[cfg(not(target_os = "windows"))] + #[test] + fn building_urls_with_characters_that_need_encoding() { + std::env::set_var("PAGEFIND_SITE", "hello/world"); + let config = + PagefindInboundConfig::with_layers(&[Layer::Env(Some("PAGEFIND_".into()))]).unwrap(); + let opts = SearchOptions::load(config).unwrap(); + + let cwd = std::env::current_dir().unwrap(); + + let p: PathBuf = cwd.join::("hello/world/Müsli.html".into()); + assert_eq!(&build_url(&p, None, &opts), "/M%C3%BCsli.html"); + + let p: PathBuf = cwd.join::("hello/world/Müsli/index.html".into()); + assert_eq!(&build_url(&p, None, &opts), "/M%C3%BCsli/"); + + let p: PathBuf = cwd.join::("hello/world/a b/c#d?e.html".into()); + assert_eq!(&build_url(&p, None, &opts), "/a%20b/c%23d%3Fe.html"); + + // A literal percent in the file name is encoded, so the URL points back at + // the file that is on disk rather than at the one it looks like it names. + let p: PathBuf = cwd.join::("hello/world/M%C3%BCsli.html".into()); + assert_eq!(&build_url(&p, None, &opts), "/M%25C3%25BCsli.html"); + } + #[cfg(target_os = "windows")] #[test] fn building_windows_urls() {