Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ An entry looks like this:
| `id` | yes | kebab-case, unique |
| `name` | yes | display name |
| `folders` | yes | top-level folders the zip installs into `Interface\AddOns`. Used to recognise what's already on disk. |
| `group_required_dependencies` | no | group the first folder's direct required dependencies into this addon; defaults to `false`. See below. |
| `github` | one source | `owner/repo` that publishes GitHub Releases. Preferred: no key, no rate limit when the release has a `release.json` (the BigWigs packager makes one). |
| `wago` | one source | Wago Addons project id. Users need their own free Wago key. |
| `wowi` | one source | WoWInterface file id. |
Expand All @@ -42,6 +43,16 @@ An entry looks like this:
| `notice` | no | one short line (160 chars) shown in amber on the installed row. For "remove this once X is fixed" situations. |
| `added` | no | `YYYY-MM-DD` the entry was added. Feeds "New in the catalogue" when nothing is featured. |

Set `group_required_dependencies: true` only when the download bundles the
first folder's required dependencies, as Questie does with QuestieDB. Keep those
dependencies out of `folders` so they do not identify the addon on their own.
When the first folder is present, its direct `Dependencies` / `RequiredDeps`
join the package unless another managed or identified package owns them.
This opt-in skips optional dependencies, does not follow dependency chains, and
leaves dependencies claimed by multiple opted-in packages unassigned. Existing
module grouping still applies.
This controls grouping only; update checks still use the source's package version.

Rules:

- Only the author's own publishing location. Unofficial mirrors (for example `curseforge-mirror/*` or `hippuli/*`) are refused; several authors have DMCA'd them.
Expand Down
3 changes: 2 additions & 1 deletion scripts/validate-catalog.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const folderOwner = new Map();
const idRe = /^[a-z0-9]+(-[a-z0-9]+)*$/;
const repoRe = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
const dateRe = /^\d{4}-\d{2}-\d{2}$/;
const knownKeys = new Set(["id", "name", "desc", "category", "github", "wago", "wowi", "tukui", "curse", "asset_hint", "folders", "url", "forever", "icon", "notice", "added"]);
const knownKeys = new Set(["id", "name", "desc", "category", "github", "wago", "wowi", "tukui", "curse", "asset_hint", "folders", "group_required_dependencies", "url", "forever", "icon", "notice", "added"]);

for (const [i, a] of (cat.addons || []).entries()) {
const where = `addons[${i}]${a && a.id ? ` (${a.id})` : ""}`;
Expand All @@ -49,6 +49,7 @@ for (const [i, a] of (cat.addons || []).entries()) {
if (a.notice !== undefined && (typeof a.notice !== "string" || a.notice.length > 160)) problems.push(`${where}: "notice" must be a short string (160 chars max)`);
if (a.added !== undefined && !dateRe.test(a.added)) problems.push(`${where}: "added" must be YYYY-MM-DD`);
if (a.forever !== undefined && a.forever !== null && typeof a.forever !== "boolean") problems.push(`${where}: "forever" must be true, false or null`);
if (a.group_required_dependencies !== undefined && typeof a.group_required_dependencies !== "boolean") problems.push(`${where}: "group_required_dependencies" must be true or false`);
if (!Array.isArray(a.folders) || a.folders.length === 0 || !a.folders.every((f) => typeof f === "string" && f.trim() && !/[\\/]/.test(f))) {
problems.push(`${where}: "folders" must list at least one AddOns folder name (no slashes)`);
} else {
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/src/catalog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ pub struct CatalogEntry {
/// already on disk to this entry).
#[serde(default)]
pub folders: Vec<String>,
/// Group otherwise unowned direct required dependencies of the first folder.
#[serde(default)]
pub group_required_dependencies: bool,
#[serde(default)]
pub url: Option<String>,
/// Does the author publish a Forever build? true / false / null=unknown.
Expand Down
169 changes: 149 additions & 20 deletions src-tauri/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

use anyhow::{anyhow, Context};
use futures_util::StreamExt;
use std::collections::{HashMap, HashSet};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -241,7 +242,13 @@ pub fn safety_scan(root: &Path) -> anyhow::Result<usize> {

/// Extract `zip_path` and move its addon folders into `addons_dir`,
/// replacing same-named folders. Returns the folder names installed.
pub fn place(zip_path: &Path, addons_dir: &Path) -> anyhow::Result<Vec<String>> {
/// `protected_folders` maps lowercase folder names to their owning package;
/// ownership only prevents replacing folders that are still present on disk.
pub fn place(
zip_path: &Path,
addons_dir: &Path,
protected_folders: &HashMap<String, String>,
) -> anyhow::Result<Vec<String>> {
fs::create_dir_all(addons_dir)?;
let work = addons_dir.join(TMP_DIR).join(unique());
let extract_dir = work.join("x");
Expand All @@ -256,7 +263,29 @@ pub fn place(zip_path: &Path, addons_dir: &Path) -> anyhow::Result<Vec<String>>
let files = safety_scan(&extract_dir)?;
crate::logi!("zip ok: {} files, no executables", files);

let sources = addon_dirs(&extract_dir)?;
// Compare actual names case-insensitively, including on case-sensitive volumes.
let on_disk: HashSet<String> = fs::read_dir(addons_dir)?
.map(|entry| entry.map(|e| e.file_name().to_string_lossy().to_ascii_lowercase()))
.collect::<std::io::Result<_>>()?;
// Resolve every final target before moving any installed folder aside.
let sources: Vec<(PathBuf, String)> = addon_dirs(&extract_dir)?
.into_iter()
.map(|src| {
let original = src.file_name().ok_or_else(|| anyhow!("bad folder name"))?
.to_string_lossy().to_string();
let name = canonical_name(&src).unwrap_or(original);
if name.starts_with('.') || name.contains("..") {
anyhow::bail!("refusing suspicious folder name {name}");
}
let lower = name.to_ascii_lowercase();
if on_disk.contains(&lower) {
if let Some(owner) = protected_folders.get(&lower) {
anyhow::bail!("cannot replace addon folder {name}: it belongs to {owner}");
}
}
Ok((src, name))
})
.collect::<anyhow::Result<_>>()?;
let mut moved: Vec<(PathBuf, PathBuf)> = Vec::new(); // (target, backup)
let mut installed = Vec::new();

Expand All @@ -267,24 +296,7 @@ pub fn place(zip_path: &Path, addons_dir: &Path) -> anyhow::Result<Vec<String>>
}
};

for mut src in sources {
let mut name = src
.file_name()
.ok_or_else(|| anyhow!("bad folder name"))?
.to_string_lossy()
.to_string();
if let Some(wanted) = canonical_name(&src) {
if wanted != name {
let renamed = src.with_file_name(&wanted);
fs::rename(&src, &renamed).with_context(|| format!("rename {name} to {wanted}"))?;
crate::logi!("folder {name} renamed to {wanted} (its .toc name)");
src = renamed;
name = wanted;
}
}
if name.starts_with('.') || name.contains("..") {
anyhow::bail!("refusing suspicious folder name {name}");
}
for (src, name) in sources {
let target = addons_dir.join(&name);
let backup = backup_dir.join(&name);
if target.exists() {
Expand Down Expand Up @@ -332,6 +344,123 @@ pub fn remove_folders(addons_dir: &Path, folders: &[String]) -> anyhow::Result<V
mod tests {
use super::*;

fn addon_zip(root: &Path, files: &[(&str, &str)]) -> PathBuf {
fs::create_dir_all(root).unwrap();
let path = root.join("release.zip");
let mut archive = zip::ZipWriter::new(fs::File::create(&path).unwrap());
for (name, contents) in files {
archive.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
archive.write_all(contents.as_bytes()).unwrap();
}
archive.finish().unwrap();
path
}

#[test]
fn protected_folder_prevents_changes_to_every_installed_folder() {
let root = std::env::temp_dir().join(format!("af-owned-{}", unique()));
let addons = root.join("AddOns");
for name in ["AAddon", "ZDatabase"] {
fs::create_dir_all(addons.join(name)).unwrap();
fs::write(addons.join(name).join("keep.lua"), format!("original {name}")).unwrap();
}
let zip = addon_zip(&root, &[
("AAddon/AAddon.toc", "## Version: 2\n"),
("AAddon/new.lua", "new primary"),
("ZDATABASE/ZDatabase.toc", "## Version: 2\n"),
("ZDATABASE/new.lua", "new database"),
]);
let protected = HashMap::from([("zdatabase".into(), "github:author/Database".into())]);
let error = place(&zip, &addons, &protected).unwrap_err().to_string();
assert!(error.contains("ZDATABASE"), "{error}");
assert!(error.contains("github:author/Database"), "{error}");
for name in ["AAddon", "ZDatabase"] {
assert_eq!(fs::read_to_string(addons.join(name).join("keep.lua")).unwrap(), format!("original {name}"));
assert!(!addons.join(name).join("new.lua").exists());
assert!(!addons.join(name).join(format!("{name}.toc")).exists());
}
fs::remove_dir_all(&root).unwrap();
}

#[test]
fn ownership_guard_checks_the_canonical_source_archive_name() {
let root = std::env::temp_dir().join(format!("af-owned-canon-{}", unique()));
let addons = root.join("AddOns");
fs::create_dir_all(addons.join("SharedData")).unwrap();
fs::write(addons.join("SharedData").join("keep.lua"), "keep database").unwrap();
let zip = addon_zip(&root, &[
("Project-1.2/SharedData_Forever.toc", "## Version: 2\n"),
]);
let protected = HashMap::from([("shareddata".into(), "folder:SharedData".into())]);
let error = place(&zip, &addons, &protected).unwrap_err().to_string();
assert!(error.contains("SharedData"), "{error}");
assert!(error.contains("folder:SharedData"), "{error}");
assert_eq!(fs::read_to_string(addons.join("SharedData").join("keep.lua")).unwrap(), "keep database");
assert!(!addons.join("SharedData").join("SharedData_Forever.toc").exists());
assert!(!addons.join("Project-1.2").exists());
fs::remove_dir_all(&root).unwrap();
}

#[test]
fn missing_protected_folder_can_be_installed_but_is_protected_once_present() {
let root = std::env::temp_dir().join(format!("af-owned-missing-{}", unique()));
let addons = root.join("AddOns");
let protected = HashMap::from([("shareddata".into(), "github:author/Database".into())]);
let zip = addon_zip(&root, &[
("Project-1.2/SharedData_Forever.toc", "## Version: 1\n"),
("Project-1.2/data.lua", "original database"),
]);
let installed = place(&zip, &addons, &protected).unwrap();
assert_eq!(installed, vec!["SharedData"]);
assert_eq!(fs::read_to_string(addons.join("SharedData").join("SharedData_Forever.toc")).unwrap(), "## Version: 1\n");
assert_eq!(fs::read_to_string(addons.join("SharedData").join("data.lua")).unwrap(), "original database");
assert!(!addons.join("Project-1.2").exists());

fs::create_dir_all(addons.join("AAddon")).unwrap();
fs::write(addons.join("AAddon").join("keep.lua"), "original primary").unwrap();
let zip = addon_zip(&root, &[
("AAddon/AAddon.toc", "## Version: 2\n"),
("AAddon/new.lua", "new primary"),
("Project-2.0/SharedData_Forever.toc", "## Version: 2\n"),
("Project-2.0/data.lua", "new database"),
]);
let error = place(&zip, &addons, &protected).unwrap_err().to_string();
assert!(error.contains("SharedData"), "{error}");
assert!(error.contains("github:author/Database"), "{error}");
assert_eq!(fs::read_to_string(addons.join("AAddon").join("keep.lua")).unwrap(), "original primary");
assert!(!addons.join("AAddon").join("new.lua").exists());
assert!(!addons.join("AAddon").join("AAddon.toc").exists());
assert_eq!(fs::read_to_string(addons.join("SharedData").join("SharedData_Forever.toc")).unwrap(), "## Version: 1\n");
assert_eq!(fs::read_to_string(addons.join("SharedData").join("data.lua")).unwrap(), "original database");
assert!(!addons.join("Project-2.0").exists());
fs::remove_dir_all(&root).unwrap();
}

#[test]
fn addon_folders_are_installed_under_canonical_names() {
let root = std::env::temp_dir().join(format!("af-folders-{}", unique()));
let addons = root.join("AddOns");
fs::create_dir_all(addons.join("Core")).unwrap();
fs::write(addons.join("Core").join("old.lua"), "old primary").unwrap();
let zip = addon_zip(&root, &[
("Bundle-1.2/Core-2.0/Core_Forever.toc", "## Version: 2\n"),
("Bundle-1.2/Core-2.0/new.lua", "new primary"),
("Bundle-1.2/Data/Data.toc", "## Version: 3\n"),
("Bundle-1.2/Data/new.lua", "new database"),
]);
let mut installed = place(&zip, &addons, &HashMap::new()).unwrap();
installed.sort();
assert_eq!(installed, vec!["Core", "Data"]);
assert_eq!(fs::read_to_string(addons.join("Core").join("Core_Forever.toc")).unwrap(), "## Version: 2\n");
assert_eq!(fs::read_to_string(addons.join("Data").join("Data.toc")).unwrap(), "## Version: 3\n");
assert_eq!(fs::read_to_string(addons.join("Core").join("new.lua")).unwrap(), "new primary");
assert_eq!(fs::read_to_string(addons.join("Data").join("new.lua")).unwrap(), "new database");
assert!(!addons.join("Core").join("old.lua").exists());
assert!(!addons.join("Core-2.0").exists());
assert!(!addons.join("Bundle-1.2").exists());
fs::remove_dir_all(&root).unwrap();
}

/// ForeverSVFix puts a directory link inside addon folders that points at
/// the user's SavedVariables. Replacing or cleaning up an addon folder must
/// remove the link, never what it points to.
Expand Down
Loading
Loading