Addon files resolve case-insensitively, as on 1.12.1's Windows client (#860)
An addon's Lua and XML files were read with the exact case its `.toc` or `<Script file>` spelt. On a case-sensitive filesystem, Linux's, a file named in another case never loaded: Questie 3.7.1 lists `Astrolabe\Astrolabe.lua` from a folder shipped as `astrolabe`, so `Astrolabe` stayed nil and its OnUpdate raised every frame. The reference is a Windows client, and every path it opens matches without regard to case. Addon textures and sounds already resolved that way through `loose_addon_file`; addon scripts, XML and `Bindings.xml` now go through the same resolver, and the second, case-sensitive one is gone. The shared resolver also refuses a component the host reads as more than a plain name (a Windows `C:`), so it still cannot leave the AddOns root. Test: a file asked for in three spellings resolves to the one on disk; it fails without the change on a case-sensitive volume. Fixes #851 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ff49eb5b8f
commit
f356e90fe6
@@ -237,39 +237,18 @@ fn is_lua(entry: &str) -> bool {
|
||||
.is_some_and(|(_, ext)| ext.eq_ignore_ascii_case("lua"))
|
||||
}
|
||||
|
||||
/// `root/rel`, refusing to escape `root`. `rel` is already resolved by
|
||||
/// [`benilla_ui::loader::join_ref`], so an escape shows as a leading `..`, refused with anything
|
||||
/// absolute before any filesystem call; a symlinked addon folder still works.
|
||||
fn read_under(root: &Path, rel: &str) -> Option<Vec<u8>> {
|
||||
let rel = Path::new(rel);
|
||||
if rel
|
||||
.components()
|
||||
.any(|c| !matches!(c, std::path::Component::Normal(_)))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
std::fs::read(root.join(rel)).ok()
|
||||
}
|
||||
|
||||
/// The one file namespace, install-relative, as in the reference: `0x647e60` answers a name from
|
||||
/// the install tree's hash index, then the archive chain, after `0x6ede10` collapses `..`. So an
|
||||
/// addon's `..\Blizzard_AuctionUI\…` reaches the archive behind a `.pub`-only folder. Only a path
|
||||
/// under `Interface/AddOns/` touches the filesystem, and never above the AddOns root.
|
||||
/// under `Interface/AddOns/` touches the filesystem, never above the AddOns root, and each of its
|
||||
/// components matches case-insensitively, as on the reference's Windows filesystem: the resolver
|
||||
/// addon art and sound use ([`benilla_assets::loose_addon_file`]).
|
||||
pub(crate) fn read_addon_file(root: &Path, req: &str) -> Option<Vec<u8>> {
|
||||
under_addons(req)
|
||||
.and_then(|rel| read_under(root, rel))
|
||||
benilla_assets::loose_addon_file(root, &benilla_assets::normalize_path(req))
|
||||
.and_then(|file| std::fs::read(file).ok())
|
||||
.or_else(|| super::reference_ui::read(&req.replace('/', "\\")))
|
||||
}
|
||||
|
||||
/// `req` with the `Interface/AddOns/` prefix stripped, or `None` without it; matched
|
||||
/// case-insensitively, as the reference is a Windows client (`Interface\Addons\` is the same path).
|
||||
fn under_addons(req: &str) -> Option<&str> {
|
||||
let rest = req.get(ADDONS_PREFIX.len()..)?;
|
||||
req.get(..ADDONS_PREFIX.len())
|
||||
.filter(|p| p.eq_ignore_ascii_case(ADDONS_PREFIX))
|
||||
.map(|_| rest)
|
||||
}
|
||||
|
||||
/// A `Dir` addon's base before its folder name ([`Addon::prefix`]), `/`-separated as `join_ref` is.
|
||||
const ADDONS_PREFIX: &str = "Interface/AddOns/";
|
||||
|
||||
@@ -1244,6 +1223,44 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// A manifest entry names its file in whatever case its author typed on Windows: Questie's
|
||||
/// `.toc` lists `Astrolabe\Astrolabe.lua` from a folder shipped as `astrolabe`. Every
|
||||
/// component matches case-insensitively, the folder and the file alike, on any filesystem.
|
||||
#[test]
|
||||
fn an_addon_file_is_found_whatever_the_case_of_its_path() {
|
||||
let tmp =
|
||||
std::env::temp_dir().join(format!("benilla-addon-case-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let root = tmp.join("AddOns");
|
||||
std::fs::create_dir_all(root.join("!Probe/astrolabe")).unwrap();
|
||||
std::fs::write(
|
||||
root.join("!Probe/astrolabe/astrolabe.lua"),
|
||||
"Astrolabe = {}",
|
||||
)
|
||||
.unwrap();
|
||||
let addon = Addon {
|
||||
name: "!Probe".into(),
|
||||
toc: Toc::default(),
|
||||
source: Source::Dir(root),
|
||||
};
|
||||
|
||||
for req in [
|
||||
"Interface/AddOns/!Probe/Astrolabe/Astrolabe.lua",
|
||||
"Interface/AddOns/!PROBE/ASTROLABE/ASTROLABE.LUA",
|
||||
"interface/addons/!probe/astrolabe/astrolabe.lua",
|
||||
] {
|
||||
assert_eq!(
|
||||
addon.read(req).as_deref(),
|
||||
Some(&b"Astrolabe = {}"[..]),
|
||||
"{req} names the file on disk as the reference's filesystem would"
|
||||
);
|
||||
}
|
||||
assert!(addon
|
||||
.read("Interface/AddOns/!Probe/astrolabe/missing.lua")
|
||||
.is_none());
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
}
|
||||
|
||||
/// A `.toc`-listed `Bindings.xml` is walked as FrameXML, each `<Binding>` an unknown frame
|
||||
/// type, which the reference logs and carries on from (`0x6ee356`).
|
||||
#[test]
|
||||
|
||||
@@ -187,13 +187,17 @@ pub fn read_chain_or_loose(
|
||||
}
|
||||
|
||||
/// Map a normalized `interface\addons\…` path onto a file under the addon root, matching each
|
||||
/// component case-insensitively as the Windows reference does; a dot-component is refused, so a
|
||||
/// path cannot escape the root.
|
||||
/// component case-insensitively as the Windows reference does; a dot-component, or one the host
|
||||
/// reads as more than a plain name (a Windows `C:`), is refused, so a path cannot escape the root.
|
||||
pub fn loose_addon_file(root: &Path, candidate: &str) -> Option<PathBuf> {
|
||||
let rel = candidate.strip_prefix("interface\\addons\\")?;
|
||||
let mut at = root.to_path_buf();
|
||||
for comp in rel.split('\\') {
|
||||
if comp.is_empty() || comp.starts_with('.') {
|
||||
let plain = matches!(
|
||||
Path::new(comp).components().collect::<Vec<_>>()[..],
|
||||
[std::path::Component::Normal(_)]
|
||||
);
|
||||
if !plain || comp.starts_with('.') {
|
||||
return None;
|
||||
}
|
||||
let direct = at.join(comp);
|
||||
|
||||
Reference in New Issue
Block a user