From c7c579bf04f1d4620f89814fc7ad640f9d7d5c78 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 1 Sep 2026 16:28:05 +0800 Subject: [PATCH] bread-theme: make sanitize_output injective (percent-encode) The old sanitiser mapped '/', ':' and space -- and every other unsafe byte -- all to '_', so "a/b" and "a:b" both became "a_b" and silently shared one palettes/.json + themes/.css, last writer wins. No collision detection at all. Percent-encode every byte outside [A-Za-z0-9._-] as %XX instead. That is injective, so two connectors can never land on the same file. '%' itself isn't in the keep-set so it encodes to %25 and the mapping stays reversible in principle (nothing decodes today -- reload_binds_for_ sanitized only compares sanitize_output(name) against the on-disk stem). Real Hyprland connectors (eDP-1, HDMI-A-1, DP-2) are already only keep-set bytes, so their filenames are unchanged; the files are session-scoped under XDG_RUNTIME_DIR and regenerated at login anyway. --- bread-theme/src/output.rs | 63 ++++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/bread-theme/src/output.rs b/bread-theme/src/output.rs index ebf3a1b..7733001 100644 --- a/bread-theme/src/output.rs +++ b/bread-theme/src/output.rs @@ -22,23 +22,31 @@ pub(crate) fn runtime_bread_dir() -> PathBuf { .join("bread") } -/// Keep `[A-Za-z0-9._-]`; replace everything else with `_`. +/// Turn an output/connector name into a single path segment for +/// `palettes/.json` / `themes/.css`. +/// +/// Keep `[A-Za-z0-9._-]` verbatim (every real Hyprland connector — `eDP-1`, +/// `HDMI-A-1`, `DP-2` — is already only those); **percent-encode** every other +/// byte as `%XX` (upper-hex). Percent-encoding is injective, so two different +/// connectors can never land on the same file: the old scheme mapped `/`, `:` +/// and space all to `_`, so `a/b` and `a:b` both became `a_b` and silently +/// shared one palette + stylesheet, last writer wins. `%` itself is not in the +/// keep-set, so it encodes to `%25` and the mapping stays reversible in +/// principle (nothing needs to decode today — [`reload_binds_for_sanitized`] +/// only compares `sanitize_output(name)` against the on-disk file stem). pub fn sanitize_output(output: &str) -> String { - let s: String = output - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { - c - } else { - '_' - } - }) - .collect(); - if s.is_empty() { - "_".into() - } else { - s + if output.is_empty() { + return "_".into(); } + let mut s = String::with_capacity(output.len()); + for b in output.bytes() { + if b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-') { + s.push(b as char); + } else { + s.push_str(&format!("%{b:02X}")); + } + } + s } pub fn themes_dir() -> PathBuf { @@ -268,11 +276,26 @@ mod tests { } #[test] - fn sanitize_output_replaces_unsafe_chars() { - assert_eq!(sanitize_output("HDMI A:1"), "HDMI_A_1"); - assert_eq!(sanitize_output("foo/bar"), "foo_bar"); + fn sanitize_output_percent_encodes_unsafe_chars() { + assert_eq!(sanitize_output("HDMI A:1"), "HDMI%20A%3A1"); + assert_eq!(sanitize_output("foo/bar"), "foo%2Fbar"); assert_eq!(sanitize_output(""), "_"); assert_eq!(sanitize_output("..ok_name-1"), "..ok_name-1"); + assert_eq!(sanitize_output("a%b"), "a%25b"); + } + + #[test] + fn sanitize_output_is_injective_across_old_collisions() { + // The old `_`-for-everything scheme collapsed all of these together. + let names = ["a/b", "a:b", "a b", "a_b", "a%2Fb"]; + let mut seen = std::collections::HashSet::new(); + for n in names { + assert!( + seen.insert(sanitize_output(n)), + "collision on {n} -> {}", + sanitize_output(n) + ); + } } #[test] @@ -281,8 +304,8 @@ mod tests { std::env::set_var("XDG_RUNTIME_DIR", "/run/user/1234"); let css = output_css_path("HDMI A:1"); let pal = output_palette_path("HDMI A:1"); - assert_eq!(css, themes_dir().join("HDMI_A_1.css")); - assert_eq!(pal, palettes_dir().join("HDMI_A_1.json")); + assert_eq!(css, themes_dir().join("HDMI%20A%3A1.css")); + assert_eq!(pal, palettes_dir().join("HDMI%20A%3A1.json")); assert!(css.starts_with(themes_dir())); assert!(pal.starts_with(palettes_dir())); assert_eq!(