From 656c224bbf0d676d1c8f4e358aea229f71a7e246 Mon Sep 17 00:00:00 2001 From: hachispin Date: Tue, 11 Aug 2026 18:37:02 +0100 Subject: [PATCH 01/12] Handle UTF-16; Restrict INI semantics to match INF --- src/formats/inf.rs | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/formats/inf.rs b/src/formats/inf.rs index bf4102f..ecefd44 100644 --- a/src/formats/inf.rs +++ b/src/formats/inf.rs @@ -8,7 +8,34 @@ use crate::{ use std::{collections::HashMap, fs, path::Path}; use anyhow::{Context, Result, anyhow, bail}; -use configparser::ini::Ini; // inf is an "ini-like" format +use configparser::ini::{Ini, IniDefault}; // inf is an "ini-like" format + +fn inf_new() -> Ini { + let mut defaults = IniDefault::default(); + + // non-exhaustive so we gotta do this + defaults.comment_symbols = vec![';']; + defaults.delimiters = vec!['=']; + + Ini::new_from_defaults(defaults) +} + +/// [`fs::read_to_string`] with UTF16 considerations. +/// +/// Follows the BOM if present, otherwise, parses as +/// lossy UTF-8 to account for ASCII and ANSI code pages. +fn read_to_string_utf16(path: &Path) -> Result { + let bytes = fs::read(path)?; + let bom = bytes.get(0..2); + + if bom == Some(&[0xff, 0xfe]) { + return Ok(String::from_utf16le(&bytes)?); + } else if bom == Some(&[0xfe, 0xff]) { + return Ok(String::from_utf16be(&bytes)?); + } + + Ok(String::from_utf8_lossy_owned(bytes)) +} /// Attempts to parse `inf_path` as an installer file for a cursor theme. /// @@ -49,17 +76,17 @@ use configparser::ini::Ini; // inf is an "ini-like" format /// ; they're variables (in the `Strings` section), sometimes not /// ``` pub fn parse_inf_installer(inf_path: &Path) -> Result<(String, Vec)> { - let inf_string = fs::read_to_string(inf_path)?; + let inf_string = read_to_string_utf16(inf_path)?; let parent = inf_path .parent() .ok_or_else(|| anyhow!("no parent for inf_path={}", inf_path.display()))?; - let inf: HashMap>> = Ini::new() + let inf = inf_new() .read(inf_string) .map_err(|e| anyhow!("failed to read inf, error e={e}"))?; - let defaultinstall: &HashMap> = inf + let defaultinstall = inf .get("defaultinstall") .ok_or_else(|| anyhow!("no defaultinstall section found"))?; From 8ca2ddd4d3b2b4a1645f5eab068ea9da1a1250d4 Mon Sep 17 00:00:00 2001 From: hachispin Date: Tue, 11 Aug 2026 21:13:39 +0100 Subject: [PATCH 02/12] Only allow one cursor-related entry --- src/formats/inf.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/formats/inf.rs b/src/formats/inf.rs index ecefd44..70d9024 100644 --- a/src/formats/inf.rs +++ b/src/formats/inf.rs @@ -97,13 +97,20 @@ pub fn parse_inf_installer(inf_path: &Path) -> Result<(String, Vec = addreg .split(',') .filter_map(|k| inf.get(&k.to_ascii_lowercase())) .flat_map(|v| v.keys()) - .find(|k| k.contains(r#""control panel\cursors\schemes","#)) - .ok_or_else(|| anyhow!("couldn't find cursor mappings"))?; + .filter(|k| k.contains(r#""control panel\cursors\schemes","#)) + .collect(); + + let scheme = match scheme.as_slice() { + [] => bail!("couldn't find any cursor mappings"), + [entry] => entry, + _ => bail!("more than one cursor mapping found: {scheme:?}"), + }; let subs = inf.get("strings"); let expanded_reg = expand_scheme(scheme, subs)?; From a920bec338ea1a0a90106dfbb0eaa394af54ee43 Mon Sep 17 00:00:00 2001 From: hachispin Date: Wed, 12 Aug 2026 00:42:42 +0100 Subject: [PATCH 03/12] Use proper CSV parsing; Rename some stuff --- Cargo.lock | 54 +++++++++++++++++++++++++++++ Cargo.toml | 1 + src/formats/inf.rs | 86 +++++++++++++++++++++++++++------------------- 3 files changed, 106 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9301aa0..db936e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -381,6 +381,27 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "currust" version = "2.0.1" @@ -390,6 +411,7 @@ dependencies = [ "bytemuck", "clap", "configparser", + "csv", "dialoguer", "documented", "fast_image_resize", @@ -649,6 +671,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jobserver" version = "0.1.35" @@ -1174,6 +1202,32 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "shell-words" version = "1.1.1" diff --git a/Cargo.toml b/Cargo.toml index c50108f..12412fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ binrw = "0.15.0" bytemuck = "1.25.0" clap = { version = "4.5.53", features = ["derive"] } configparser = "3.1.0" +csv = "1.4.0" dialoguer = "0.12.0" documented = "0.9.2" fast_image_resize = { version = "6.0.0", features = ["rayon"] } diff --git a/src/formats/inf.rs b/src/formats/inf.rs index 70d9024..f8b97f6 100644 --- a/src/formats/inf.rs +++ b/src/formats/inf.rs @@ -8,7 +8,8 @@ use crate::{ use std::{collections::HashMap, fs, path::Path}; use anyhow::{Context, Result, anyhow, bail}; -use configparser::ini::{Ini, IniDefault}; // inf is an "ini-like" format +use configparser::ini::{Ini, IniDefault}; +use csv::{ReaderBuilder, StringRecord}; // inf is an "ini-like" format fn inf_new() -> Ini { let mut defaults = IniDefault::default(); @@ -37,6 +38,28 @@ fn read_to_string_utf16(path: &Path) -> Result { Ok(String::from_utf8_lossy_owned(bytes)) } +/// Reads a single record and splits fields following CSV behaviour. +/// +/// Also trims. +fn split_csv(record_str: &str) -> Result { + let mut rdr = ReaderBuilder::new() + .has_headers(false) + .from_reader(record_str.as_bytes()); + + let Some(record) = rdr.records().next() else { + bail!("no records found") + }; + + let mut record = record?; + record.trim(); + + if rdr.records().next().is_some() { + warn!("more than one record found, returning first"); + } + + Ok(record) +} + /// Attempts to parse `inf_path` as an installer file for a cursor theme. /// /// Returns the tuple (`theme_name`, `cursor_mappings`). @@ -90,17 +113,18 @@ pub fn parse_inf_installer(inf_path: &Path) -> Result<(String, Vec = addreg - .split(',') + let scheme: Vec<_> = addreg_sections + .iter() .filter_map(|k| inf.get(&k.to_ascii_lowercase())) .flat_map(|v| v.keys()) .filter(|k| k.contains(r#""control panel\cursors\schemes","#)) @@ -114,38 +138,30 @@ pub fn parse_inf_installer(inf_path: &Path) -> Result<(String, Vec = reg_info - .map(|s| { - s.rsplit_once('\\') - .ok_or_else(|| anyhow!("failed to extract filename from path, s={s}")) - .map(|s| s.1.to_ascii_lowercase()) + + // reg-root,[subkey],[value-entry-name],[flags],[value][,[value]] + let reg_info = split_csv(&expanded_reg)?; + + let (Some(name), Some(paths)) = (reg_info.get(2), reg_info.get(4)) else { + bail!("expected cursor registry entry to have at least five fields, reg_info={reg_info:?}"); + }; + + let name = name.to_string(); + let paths = split_csv(paths)?; + + // get filenames + let dst_filenames: Vec<_> = paths + .iter() + .map(|p| { + p.rsplit_once('\\') + .ok_or_else(|| anyhow!("failed to extract filename from path, p={p}")) + .map(|p| p.1.to_ascii_lowercase()) }) .collect::>()?; - let end = paths.len() - 1; - paths[end] = paths[paths.len() - 1] - .strip_suffix('"') - .ok_or_else(|| anyhow!("expected closing quotation for paths, didn't find it"))? - .to_string(); - - let paths = resolve_paths(&inf, defaultinstall, &paths)?; + let src_paths = resolve_paths(&inf, defaultinstall, &dst_filenames)?; - let mappings: Vec<_> = paths + let mappings: Vec<_> = src_paths .into_iter() .zip(0..15) .map(|(p, i)| CursorMapping { From 1e12f00bf2f46ea358c7ea27a67819de9db2c0bd Mon Sep 17 00:00:00 2001 From: hachispin Date: Wed, 12 Aug 2026 01:12:30 +0100 Subject: [PATCH 04/12] Use CSV splitting for `resolve_paths()` --- src/formats/inf.rs | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/src/formats/inf.rs b/src/formats/inf.rs index f8b97f6..84ca099 100644 --- a/src/formats/inf.rs +++ b/src/formats/inf.rs @@ -207,36 +207,35 @@ fn resolve_paths( .flatten() .ok_or_else(|| anyhow!("no copyfiles section"))?; + let fields = split_csv(©files)?; + // paths are coerced to lowercase because they're "keys" (from configparser's perspective). // this most likely causes some extra lookups, since the initial path most likely has // the correct casing. could be solved with Ini::new_cs() but probably isn't worth it. let mut mappings = HashMap::with_capacity(paths.len()); - for field in copyfiles.split(',') { + for field in &fields { // TODO: Implement this later. - if matches!(copyfiles.chars().next(), Some('@')) { + if matches!(field.chars().next(), Some('@')) { bail!("unsupported '@' syntax in copyfiles"); } - let field = field.trim(); - let section = inf .get(&field.to_ascii_lowercase()) .ok_or_else(|| anyhow!("copyfiles specifies '{field}' should exist, but doesn't"))?; for k in section.keys() { // destination-file-name[,[source-file-name][,[unused][,flag]]] - let entry: Vec<_> = k.split(',').map(|f| f.replace('\\', "/")).collect(); + let entry = split_csv(k)?; + let mut entry = entry.iter().map(|f| f.replace('\\', "/")); + + let Some(dst) = entry.next() else { + bail!("empty entry in section={field}") + }; - if entry.is_empty() { - bail!("empty entry in section={field}"); - } + let src = entry.next().unwrap_or_else(|| dst.clone()); - if entry.len() == 1 { - mappings.insert(dequote(&entry[0]), dequote(&entry[0])); - } else { - mappings.insert(dequote(&entry[0]), dequote(&entry[1])); - } + mappings.insert(dst, src); } } @@ -245,7 +244,7 @@ fn resolve_paths( for p in paths { new.push( mappings - .get(p.as_str()) + .get(p) .ok_or_else(|| anyhow!("missing mapping for {p}"))? .clone(), ); From 04571bcfa03f261047f7c500f5cc38bc7b49e419 Mon Sep 17 00:00:00 2001 From: hachispin Date: Wed, 12 Aug 2026 01:43:36 +0100 Subject: [PATCH 05/12] Fix byte offset after BOM --- src/formats/inf.rs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/formats/inf.rs b/src/formats/inf.rs index 84ca099..3c03013 100644 --- a/src/formats/inf.rs +++ b/src/formats/inf.rs @@ -26,16 +26,17 @@ fn inf_new() -> Ini { /// Follows the BOM if present, otherwise, parses as /// lossy UTF-8 to account for ASCII and ANSI code pages. fn read_to_string_utf16(path: &Path) -> Result { - let bytes = fs::read(path)?; - let bom = bytes.get(0..2); - - if bom == Some(&[0xff, 0xfe]) { - return Ok(String::from_utf16le(&bytes)?); - } else if bom == Some(&[0xfe, 0xff]) { - return Ok(String::from_utf16be(&bytes)?); - } - - Ok(String::from_utf8_lossy_owned(bytes)) + let mut bytes = fs::read(path)?; + + Ok(match bytes.as_slice() { + [0xff, 0xfe, rest @ ..] => String::from_utf16le(rest)?, + [0xfe, 0xff, rest @ ..] => String::from_utf16be(rest)?, + [0xef, 0xbb, 0xbf, ..] => { + bytes.drain(0..3); + String::from_utf8(bytes)? + } + _ => String::from_utf8_lossy_owned(bytes), + }) } /// Reads a single record and splits fields following CSV behaviour. From 74304582fe7bb870a537d7e9b3ebe3a87272581a Mon Sep 17 00:00:00 2001 From: hachispin Date: Wed, 12 Aug 2026 01:55:03 +0100 Subject: [PATCH 06/12] Effectively inline `dequote()` --- src/formats/inf.rs | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/src/formats/inf.rs b/src/formats/inf.rs index 3c03013..b94c8cc 100644 --- a/src/formats/inf.rs +++ b/src/formats/inf.rs @@ -272,30 +272,23 @@ fn expand_scheme(reg: &str, subs: Option<&HashMap>>) -> R expand(reg, &subs).with_context(|| format!("for input reg={reg}")) } -/// Dequotes following INF spec. -/// -/// The INF parser not only discards the outermost pair of enclosing double quotation -/// marks for any "quoted string" in this section, but also condenses each subsequent -/// sequential pair of double quotation marks into a single double quotation marks character. -/// -/// For example, """some string""" also becomes "some string" when it is parsed. -fn dequote(input: &str) -> String { - let mut input = input.trim(); - - if input.starts_with('"') && input.ends_with('"') && input.len() >= 2 { - input = &input[1..(input.len() - 1)]; - } - - input.replace("\"\"", "\"") -} - /// Helper function for [`expand_scheme`] to remove the outer pair of quotes. /// /// This is because [`configparser`] takes _everything_ as a string, /// for example: `key = "value"` means `config["key"] == "\"value\""`. fn dequote_value(entry: (&String, &Option)) -> Option<(String, String)> { match entry { - (k, Some(v)) => Some((k.clone(), dequote(v))), + (k, Some(v)) => { + let value = v.trim(); + + let value = value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .unwrap_or(value) + .replace("\"\"", "\""); + + Some((k.clone(), value)) + } (k, None) => { // side effect but shhh warn!("key={k} has value None"); From 1d8725b61f3b34316ab3bd7d6d26b062f6d0b200 Mon Sep 17 00:00:00 2001 From: hachispin Date: Wed, 12 Aug 2026 11:29:15 +0100 Subject: [PATCH 07/12] Skip empty paths; Make scheme filtering less strict --- src/formats/inf.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/formats/inf.rs b/src/formats/inf.rs index b94c8cc..eb0f03e 100644 --- a/src/formats/inf.rs +++ b/src/formats/inf.rs @@ -128,7 +128,7 @@ pub fn parse_inf_installer(inf_path: &Path) -> Result<(String, Vec Result<(String, Vec = src_paths .into_iter() .zip(0..15) + .filter(|(p, _)| !p.is_empty()) .map(|(p, i)| CursorMapping { r#type: index_to_cursor_type(i), path: parent.join(p), @@ -191,8 +192,7 @@ const fn index_to_cursor_type(index: usize) -> CursorType { 12 => Move, 13 => CenterPtr, 14 => Hand, _ => unreachable!(), - // 15/16 are person and pin, which do not - // have (commonly-used) xcursor equivalents + // 15/16 are pin and person, which do not have (commonly-used) Xcursor equivalents } } @@ -325,7 +325,7 @@ fn expand(input: &str, subs: &HashMap) -> Result { .or_else(|| (key == "%%").then_some("%")) .or_else(|| { if key.chars().all(|c| c.is_ascii_digit() || c == '%') { - // let's just assume it's a DIRID and leave it :) + // let's just assume it's a DIRID and leave it, ok? Some(key) } else { None From 06c4967311378670a8fe594920b6c54749ef05aa Mon Sep 17 00:00:00 2001 From: hachispin Date: Wed, 12 Aug 2026 11:33:45 +0100 Subject: [PATCH 08/12] Downgrade missing CopyFiles-specified section to warn --- src/formats/inf.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/formats/inf.rs b/src/formats/inf.rs index eb0f03e..16650c3 100644 --- a/src/formats/inf.rs +++ b/src/formats/inf.rs @@ -221,9 +221,10 @@ fn resolve_paths( bail!("unsupported '@' syntax in copyfiles"); } - let section = inf - .get(&field.to_ascii_lowercase()) - .ok_or_else(|| anyhow!("copyfiles specifies '{field}' should exist, but doesn't"))?; + let Some(section) = inf.get(&field.to_ascii_lowercase()) else { + warn!("copyfiles refers to section '{field}', but the section is missing"); + continue; + }; for k in section.keys() { // destination-file-name[,[source-file-name][,[unused][,flag]]] From f06430f7da75e6bf07b258b5ed8b121e9731505c Mon Sep 17 00:00:00 2001 From: hachispin Date: Wed, 12 Aug 2026 13:35:30 +0100 Subject: [PATCH 09/12] Clone `dst` if `src` is empty --- src/formats/inf.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/formats/inf.rs b/src/formats/inf.rs index 16650c3..79b7281 100644 --- a/src/formats/inf.rs +++ b/src/formats/inf.rs @@ -235,9 +235,13 @@ fn resolve_paths( bail!("empty entry in section={field}") }; - let src = entry.next().unwrap_or_else(|| dst.clone()); - - mappings.insert(dst, src); + if let Some(src) = entry.next() + && !src.is_empty() + { + mappings.insert(dst, src); + } else { + mappings.insert(dst.clone(), dst); + } } } From d81dabde122b37372e3356932452f9ad2c3e9626 Mon Sep 17 00:00:00 2001 From: hachispin Date: Wed, 12 Aug 2026 14:10:42 +0100 Subject: [PATCH 10/12] Fix empty paths causing indices to be off --- src/formats/inf.rs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/formats/inf.rs b/src/formats/inf.rs index 79b7281..56177c8 100644 --- a/src/formats/inf.rs +++ b/src/formats/inf.rs @@ -155,8 +155,8 @@ pub fn parse_inf_installer(inf_path: &Path) -> Result<(String, Vec>()?; @@ -165,10 +165,11 @@ pub fn parse_inf_installer(inf_path: &Path) -> Result<(String, Vec = src_paths .into_iter() .zip(0..15) - .filter(|(p, _)| !p.is_empty()) - .map(|(p, i)| CursorMapping { - r#type: index_to_cursor_type(i), - path: parent.join(p), + .filter_map(|(path, i)| { + path.map(|p| CursorMapping { + r#type: index_to_cursor_type(i), + path: parent.join(p), + }) }) .collect(); @@ -200,8 +201,8 @@ const fn index_to_cursor_type(index: usize) -> CursorType { fn resolve_paths( inf: &HashMap>>, defaultinstall: &HashMap>, - paths: &[String], -) -> Result> { + paths: &[Option], +) -> Result>> { let copyfiles = defaultinstall .get("copyfiles") .cloned() @@ -248,12 +249,17 @@ fn resolve_paths( let mut new = Vec::with_capacity(paths.len()); for p in paths { - new.push( + let Some(p) = p else { + new.push(None); + continue; + }; + + new.push(Some( mappings .get(p) .ok_or_else(|| anyhow!("missing mapping for {p}"))? .clone(), - ); + )); } Ok(new) From b145e100a546653e6ee1f54604635328d52a9cc6 Mon Sep 17 00:00:00 2001 From: hachispin Date: Wed, 12 Aug 2026 23:07:22 +0100 Subject: [PATCH 11/12] Add some TODOs --- src/formats/inf.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/formats/inf.rs b/src/formats/inf.rs index 56177c8..32728f6 100644 --- a/src/formats/inf.rs +++ b/src/formats/inf.rs @@ -43,6 +43,8 @@ fn read_to_string_utf16(path: &Path) -> Result { /// /// Also trims. fn split_csv(record_str: &str) -> Result { + // TODO: Consider handling cases such as `a , "b"` becoming ["a", "\"b\""] + let mut rdr = ReaderBuilder::new() .has_headers(false) .from_reader(record_str.as_bytes()); @@ -100,6 +102,8 @@ fn split_csv(record_str: &str) -> Result { /// ; they're variables (in the `Strings` section), sometimes not /// ``` pub fn parse_inf_installer(inf_path: &Path) -> Result<(String, Vec)> { + // TODO: Handle line continuation. Or just don't and delete this TODO. + let inf_string = read_to_string_utf16(inf_path)?; let parent = inf_path From 47a44779e34ea67e1c2f6d68207ed8f268765f49 Mon Sep 17 00:00:00 2001 From: hachispin Date: Thu, 13 Aug 2026 00:24:02 +0100 Subject: [PATCH 12/12] Fix empty paths for hopefully the last time --- src/formats/inf.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/formats/inf.rs b/src/formats/inf.rs index 32728f6..3f10828 100644 --- a/src/formats/inf.rs +++ b/src/formats/inf.rs @@ -158,6 +158,10 @@ pub fn parse_inf_installer(inf_path: &Path) -> Result<(String, Vec = paths .iter() .map(|p| { + if p.is_empty() { + return Ok(None); + } + p.rsplit_once('\\') .map(|(_, filename)| Some(filename.to_ascii_lowercase())) .ok_or_else(|| anyhow!("failed to extract filename from path, p={p}"))