Skip to main content

thunderstore_engine/
install.rs

1//! Schema-driven, root-relative mod install routing.
2//!
3//! Given a package's extracted contents and a game's
4//! [`GameProfile`](crate::ecosystem::GameProfile) from the Thunderstore ecosystem
5//! schema, this module computes an [`InstallPlan`](crate::install::InstallPlan): a
6//! list of source files mapped to destination paths *relative to a profile/loader
7//! root*. The caller supplies the absolute root and applies the plan with
8//! [`apply_install`](crate::install::apply_install), which records a
9//! [`FileManifest`](crate::install::FileManifest) of every written path so
10//! uninstall is exact and reversible.
11//!
12//! Routing is driven entirely by the schema's install rules (no per-game code):
13//! a package's override folders route by matching a rule's route leaf, loose files
14//! route by longest-matching extension, and anything else falls to the rule flagged
15//! as the default location. A package that appears in the ecosystem's mod-loader
16//! registry ([`Ecosystem::modloader_package`](crate::ecosystem::Ecosystem::modloader_package))
17//! is instead unpacked to the root.
18//!
19//! Deliberately, this module does NOT resolve the absolute game directory, do
20//! Steam/Epic discovery, or place the loader proxy next to the executable — those
21//! are game-specific concerns left to the caller.
22
23#![deny(missing_docs)]
24
25use crate::ecosystem::{Ecosystem, GameProfile, InstallRule, ModloaderPackage, TrackingMethod};
26use crate::error::{Error, Result};
27use crate::util::io_context;
28use serde::{Deserialize, Serialize};
29use std::collections::BTreeSet;
30use std::fs;
31use std::path::{Component, Path, PathBuf};
32
33/// Records the files a `State`-tracked mod installed, so enable/disable can undo
34/// and redo them. Mirrors r2modman's `_state/<mod>-state.yml` `ModFileTracker`:
35/// each pair is `(cache source path, profile-relative install path)`.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38#[non_exhaustive]
39pub struct ModFileTracker {
40  /// The mod's `Owner-Name` identifier.
41  pub mod_name: String,
42  /// `(source, install-relative)` pairs, in plan order.
43  pub files: Vec<(String, String)>,
44}
45
46/// Collects the `State`-tracked entries of `plan` into a [`ModFileTracker`], or
47/// `None` when the plan installs no `State` files. Pure — no I/O.
48pub fn state_tracker_from_plan(plan: &InstallPlan, mod_name: &str) -> Option<ModFileTracker> {
49  let files: Vec<(String, String)> = plan
50    .entries
51    .iter()
52    .filter(|e| e.tracking == TrackingMethod::State)
53    .map(|e| {
54      (
55        e.source.to_string_lossy().to_string(),
56        e.dest_relative.to_string_lossy().replace('\\', "/"),
57      )
58    })
59    .collect();
60
61  if files.is_empty() {
62    return None;
63  }
64
65  Some(ModFileTracker {
66    mod_name: mod_name.to_string(),
67    files,
68  })
69}
70
71/// The `_state/<mod_name>-state.yml` path within a profile directory.
72pub fn state_file_path(profile_dir: &Path, mod_name: &str) -> PathBuf {
73  profile_dir
74    .join("_state")
75    .join(format!("{mod_name}-state.yml"))
76}
77
78/// Writes a mod's state tracker to `<profile_dir>/_state/<mod>-state.yml`.
79pub fn write_state_file(profile_dir: &Path, tracker: &ModFileTracker) -> Result<()> {
80  let path = state_file_path(profile_dir, &tracker.mod_name);
81
82  if let Some(parent) = path.parent() {
83    fs::create_dir_all(parent)
84      .map_err(|e| io_context(format!("creating {}", parent.display()), e))?;
85  }
86
87  let text = serde_yaml_ng::to_string(tracker)
88    .map_err(|e| Error::Install(format!("serializing state file: {e}")))?;
89
90  fs::write(&path, text).map_err(|e| io_context(format!("writing {}", path.display()), e))
91}
92
93/// Reads a mod's state tracker, or `None` if the file does not exist.
94pub fn read_state_file(profile_dir: &Path, mod_name: &str) -> Result<Option<ModFileTracker>> {
95  let path = state_file_path(profile_dir, mod_name);
96
97  if !path.exists() {
98    return Ok(None);
99  }
100
101  let text =
102    fs::read_to_string(&path).map_err(|e| io_context(format!("reading {}", path.display()), e))?;
103  let tracker = serde_yaml_ng::from_str(&text)
104    .map_err(|e| Error::Install(format!("parsing {}: {e}", path.display())))?;
105
106  Ok(Some(tracker))
107}
108
109/// Removes a mod's `State`-tracked payload files and then its `_state` file, so
110/// an uninstall leaves nothing behind for a mod whose files live under
111/// non-namespaced routes (which [`remove_delisted`] does not reconcile).
112///
113/// A missing `_state` file is a no-op — the mod tracked no `State` files. A
114/// payload file that is already gone (e.g. the mod was disabled first) is
115/// skipped. Unlike [`disable_mod`], this also deletes the tracker itself, since
116/// after an uninstall there is nothing left to restore.
117pub fn remove_state_payload(profile_dir: &Path, mod_name: &str) -> Result<()> {
118  let tracker = match read_state_file(profile_dir, mod_name)? {
119    Some(tracker) => tracker,
120    None => return Ok(()),
121  };
122
123  remove_state_files(profile_dir, &tracker)?;
124
125  let path = state_file_path(profile_dir, mod_name);
126
127  fs::remove_file(&path).map_err(|e| io_context(format!("removing {}", path.display()), e))
128}
129
130/// Validates that `dest` is a safe root-relative path: it must not be absolute,
131/// carry a path prefix/root, or use `..` to climb out of the install root.
132///
133/// This guards every destination computed from untrusted inputs — a package's
134/// `Owner-Name` identifier and the ecosystem schema's route `target` — so a
135/// malicious or malformed package cannot read or write outside the caller's root.
136fn ensure_safe_relative(dest: &Path) -> Result<()> {
137  for component in dest.components() {
138    match component {
139      Component::Normal(_) | Component::CurDir => {}
140      Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
141        return Err(Error::Install(format!(
142          "unsafe install path escapes the install root: {}",
143          dest.display()
144        )));
145      }
146    }
147  }
148
149  Ok(())
150}
151
152/// A single source -> destination mapping within an install plan.
153#[derive(Debug, Clone, PartialEq, Eq)]
154#[non_exhaustive]
155pub struct InstallEntry {
156  /// Absolute path to the source file within the extracted package (or, for a
157  /// `package-zip` install, the package archive itself).
158  pub source: PathBuf,
159  /// Destination path relative to the profile/loader root.
160  pub dest_relative: PathBuf,
161  /// How this file is tracked, which governs whether it is namespaced, recorded
162  /// for uninstall, and whether an existing file is preserved.
163  pub tracking: TrackingMethod,
164}
165
166/// A computed, root-relative plan for installing one package.
167#[derive(Debug, Clone, Default)]
168#[non_exhaustive]
169pub struct InstallPlan {
170  /// The ordered source -> destination mappings for this package's files.
171  pub entries: Vec<InstallEntry>,
172}
173
174/// Records every path written by [`apply_install`], for exact uninstall.
175#[derive(Debug, Clone, Default)]
176#[non_exhaustive]
177pub struct FileManifest {
178  /// Paths written, relative to the root passed to [`apply_install`].
179  pub written: Vec<PathBuf>,
180}
181
182/// Builds an [`InstallPlan`] for a package.
183///
184/// If `package_full_name` is in the ecosystem's mod-loader registry, the package
185/// is a loader pack and unpacks to the root; otherwise its files are routed by the
186/// profile's [`InstallRule`]s. `archive_path` is the package's original zip, needed
187/// only for `package-zip` loaders (e.g. GodotML); pass `None` when unavailable.
188pub fn plan_install(
189  ecosystem: &Ecosystem,
190  profile: &GameProfile,
191  package_full_name: &str,
192  extracted_root: &Path,
193  archive_path: Option<&Path>,
194) -> Result<InstallPlan> {
195  if let Some(pack) = ecosystem.modloader_package(package_full_name) {
196    return plan_loader_pack(pack, profile, extracted_root);
197  }
198
199  plan_regular_mod(profile, package_full_name, extracted_root, archive_path)
200}
201
202/// Plans a loader pack: unpack its payload to the profile root.
203///
204/// Many packs nest their payload under a single subfolder;
205/// [`ModloaderPackage::root_folder`](crate::ecosystem::ModloaderPackage::root_folder)
206/// names it so the folder's *contents* land at the root. Package-root metadata
207/// (`manifest.json`, `icon.png`, `README.md`) is dropped. Files landing under an
208/// untracked route (e.g. the pack's own `BepInEx/config/`) are preserved across
209/// reinstalls; the rest are recorded so the loader can be uninstalled exactly.
210fn plan_loader_pack(
211  pack: &ModloaderPackage,
212  profile: &GameProfile,
213  extracted_root: &Path,
214) -> Result<InstallPlan> {
215  let base = match extracted_root.join(&pack.root_folder) {
216    nested if !pack.root_folder.is_empty() && nested.is_dir() => nested,
217    _ => extracted_root.to_path_buf(),
218  };
219
220  let mut untracked = Vec::new();
221
222  collect_routes(
223    &profile.install_rules,
224    Path::new(""),
225    &|rule| rule.tracking_method == TrackingMethod::None,
226    &mut untracked,
227  );
228
229  let mut entries = Vec::new();
230
231  for file in walk_files(&base)? {
232    let rel = file.strip_prefix(&base).unwrap_or(&file).to_path_buf();
233
234    if let Some(tracking) =
235      classify_loader_file(&rel, &untracked, &profile.relative_file_exclusions)?
236    {
237      entries.push(InstallEntry {
238        source: file,
239        dest_relative: rel,
240        tracking,
241      });
242    }
243  }
244
245  Ok(InstallPlan { entries })
246}
247
248/// Decides whether and how a loader-pack file (path `rel`, relative to the pack
249/// payload root) is installed.
250///
251/// Returns `None` for package-root metadata (`manifest.json`, `icon.png`,
252/// `README.md`) and for a file whose name is in `exclusions` (e.g. `LICENCE`).
253/// Otherwise it returns the tracking method: `none` (shared config, preserved
254/// across reinstalls) when the file lands under one of the pack's own `untracked`
255/// routes, else `state` (recorded for exact uninstall). The safety check on `rel`
256/// is surfaced as an `Err`. Pure — no filesystem access.
257fn classify_loader_file(
258  rel: &Path,
259  untracked: &[PathBuf],
260  exclusions: &[String],
261) -> Result<Option<TrackingMethod>> {
262  if is_package_metadata(rel) {
263    return Ok(None);
264  }
265
266  if let Some(name) = rel.file_name().and_then(|n| n.to_str())
267    && exclusions.iter().any(|e| e == name)
268  {
269    return Ok(None);
270  }
271
272  ensure_safe_relative(rel)?;
273
274  let tracking = if untracked.iter().any(|route| rel.starts_with(route)) {
275    TrackingMethod::None
276  } else {
277    TrackingMethod::State
278  };
279
280  Ok(Some(tracking))
281}
282
283/// Plans a regular mod install by routing each file through the profile's rules.
284fn plan_regular_mod(
285  profile: &GameProfile,
286  ident: &str,
287  extracted_root: &Path,
288  archive_path: Option<&Path>,
289) -> Result<InstallPlan> {
290  let mut flat = Vec::new();
291
292  flatten_rules(&profile.install_rules, Path::new(""), &mut flat);
293
294  // Whole-package zip loaders (e.g. GodotML): the catch-all rule is `package-zip`,
295  // so the entire archive is installed as a single file rather than per-file.
296  if let Some(rule) = flat
297    .iter()
298    .find(|r| r.rule.is_default_location && r.rule.tracking_method == TrackingMethod::PackageZip)
299  {
300    return plan_package_zip(&rule.full_route, ident, archive_path);
301  }
302
303  let mut entries = Vec::new();
304
305  for file in walk_files(extracted_root)? {
306    let rel = file
307      .strip_prefix(extracted_root)
308      .unwrap_or(&file)
309      .to_path_buf();
310
311    match route_file(profile, &flat, ident, &rel)? {
312      RouteOutcome::Route {
313        dest_relative,
314        tracking,
315      } => entries.push(InstallEntry {
316        source: file,
317        dest_relative,
318        tracking,
319      }),
320      RouteOutcome::Skip(reason) => log_route_skip(&rel, reason),
321    }
322  }
323
324  Ok(InstallPlan { entries })
325}
326
327/// The outcome of routing a single extracted file through a profile's rules.
328#[derive(Debug, Clone, PartialEq, Eq)]
329enum RouteOutcome {
330  /// Route the file to `dest_relative` (relative to the profile root) tracked
331  /// as `tracking`.
332  Route {
333    dest_relative: PathBuf,
334    tracking: TrackingMethod,
335  },
336  /// The file is not installed, for the given reason.
337  Skip(SkipReason),
338}
339
340/// Why [`route_file`] declined to install a file.
341#[derive(Debug, Clone, PartialEq, Eq)]
342enum SkipReason {
343  /// The path has no usable file name.
344  NoName,
345  /// The file matched the profile's file exclusions (e.g. `LICENCE`).
346  Excluded,
347  /// No install rule claimed the file.
348  NoRoute,
349  /// The file routed to a rule whose tracking method has no per-file placement
350  /// (e.g. `package-zip`).
351  UnsupportedTracking(TrackingMethod),
352}
353
354/// Routes one extracted file (path `rel`, relative to the package root) through
355/// the profile's rules, returning where it should land or why it is skipped.
356///
357/// This is the whole per-file routing decision, lifted out of the filesystem
358/// walk in [`plan_regular_mod`]: it touches no disk, so the entire routing table
359/// — override-folder matching, longest-extension routing, the default location,
360/// exclusions, and `<ident>` namespacing — is unit-testable against string paths.
361/// The safety check on the computed destination is still enforced, surfaced as an
362/// `Err`.
363fn route_file(
364  profile: &GameProfile,
365  flat: &[FlatRule<'_>],
366  ident: &str,
367  rel: &Path,
368) -> Result<RouteOutcome> {
369  let name = match rel.file_name().and_then(|n| n.to_str()) {
370    Some(n) => n,
371    None => return Ok(RouteOutcome::Skip(SkipReason::NoName)),
372  };
373
374  if profile.relative_file_exclusions.iter().any(|e| e == name) {
375    return Ok(RouteOutcome::Skip(SkipReason::Excluded));
376  }
377
378  let resolution = resolve_dir(&profile.install_rules, Path::new(""), rel)
379    .or_else(|| loose_file_route(flat, name))
380    .or_else(|| default_route(flat, rel));
381
382  let RouteResolution {
383    route,
384    tracking,
385    remainder,
386  } = match resolution {
387    Some(resolved) => resolved,
388    None => return Ok(RouteOutcome::Skip(SkipReason::NoRoute)),
389  };
390
391  let dest = match build_dest(&route, tracking, ident, &remainder) {
392    Some(dest) => dest,
393    None => {
394      return Ok(RouteOutcome::Skip(SkipReason::UnsupportedTracking(
395        tracking,
396      )));
397    }
398  };
399
400  ensure_safe_relative(&dest)?;
401
402  Ok(RouteOutcome::Route {
403    dest_relative: dest,
404    tracking,
405  })
406}
407
408/// Emits the same skip warnings the inline routing loop used to, for the reasons
409/// worth surfacing (a file with no route, or an unsupported tracking method).
410fn log_route_skip(rel: &Path, reason: SkipReason) {
411  match reason {
412    SkipReason::NoRoute => tracing::warn!("no install route for {}; skipping", rel.display()),
413    SkipReason::UnsupportedTracking(tracking) => tracing::warn!(
414      "unsupported tracking {:?} for {}; skipping",
415      tracking,
416      rel.display()
417    ),
418    SkipReason::Excluded | SkipReason::NoName => {}
419  }
420}
421
422/// Plans a `package-zip` install: copy the original archive into the route.
423fn plan_package_zip(route: &Path, ident: &str, archive_path: Option<&Path>) -> Result<InstallPlan> {
424  let archive = match archive_path {
425    Some(archive) => archive,
426    None => {
427      tracing::warn!("package-zip loader requires the original archive; skipping install");
428
429      return Ok(InstallPlan::default());
430    }
431  };
432
433  let dest = route.join(format!("{ident}.zip"));
434
435  ensure_safe_relative(&dest)?;
436
437  Ok(InstallPlan {
438    entries: vec![InstallEntry {
439      source: archive.to_path_buf(),
440      dest_relative: dest,
441      tracking: TrackingMethod::PackageZip,
442    }],
443  })
444}
445
446/// A single rule with its route resolved to a full profile-root-relative path.
447struct FlatRule<'a> {
448  full_route: PathBuf,
449  rule: &'a InstallRule,
450}
451
452/// A resolved route for one file: the destination route, how it is tracked, and
453/// the remaining sub-path to place under the route.
454#[derive(Debug, Clone, PartialEq, Eq)]
455struct RouteResolution {
456  route: PathBuf,
457  tracking: TrackingMethod,
458  remainder: PathBuf,
459}
460
461/// Flattens the rule forest into `(full_route, rule)` pairs, resolving each nested
462/// rule's route against its parent so subroute routes become absolute.
463fn flatten_rules<'a>(rules: &'a [InstallRule], parent: &Path, out: &mut Vec<FlatRule<'a>>) {
464  for rule in rules {
465    let full = join_route(parent, &rule.route);
466
467    out.push(FlatRule {
468      full_route: full.clone(),
469      rule,
470    });
471
472    if !rule.sub_routes.is_empty() {
473      flatten_rules(&rule.sub_routes, &full, out);
474    }
475  }
476}
477
478/// Resolves a file whose top component names an override folder (recursively into
479/// subroutes), returning the destination route, its tracking, and the remaining
480/// path to place under it.
481fn resolve_dir(rules: &[InstallRule], parent_full: &Path, rel: &Path) -> Option<RouteResolution> {
482  let mut comps = rel.components();
483  let first = comps.next()?.as_os_str().to_str()?;
484  let rest = comps.as_path();
485
486  for rule in rules {
487    if !route_leaf(&rule.route).eq_ignore_ascii_case(first) {
488      continue;
489    }
490
491    let full = join_route(parent_full, &rule.route);
492
493    if !rule.sub_routes.is_empty() && rest.components().next().is_some() {
494      if let Some(found) = resolve_dir(&rule.sub_routes, &full, rest) {
495        return Some(found);
496      }
497
498      // A loose file directly inside this folder routes by subroute extension.
499      if rest.components().count() == 1
500        && let Some(name) = rest.file_name().and_then(|n| n.to_str())
501      {
502        let mut sub_flat = Vec::new();
503
504        flatten_rules(&rule.sub_routes, &full, &mut sub_flat);
505
506        if let Some(matched) = best_extension(&sub_flat, name) {
507          return Some(RouteResolution {
508            route: matched.full_route.clone(),
509            tracking: matched.rule.tracking_method,
510            remainder: PathBuf::from(name),
511          });
512        }
513      }
514    }
515
516    return Some(RouteResolution {
517      route: full,
518      tracking: rule.tracking_method,
519      remainder: rest.to_path_buf(),
520    });
521  }
522
523  None
524}
525
526/// Routes a file not claimed by an override folder by longest-matching extension.
527///
528/// This applies to any file — a root file or one inside an ordinary subfolder
529/// (e.g. `Wrapper/Hook.mm.dll` still routes to the `.mm.dll` rule) — so a matched
530/// file lands in its extension's route by base name rather than falling through to
531/// the default location.
532fn loose_file_route(flat: &[FlatRule<'_>], name: &str) -> Option<RouteResolution> {
533  let matched = best_extension(flat, name)?;
534
535  Some(RouteResolution {
536    route: matched.full_route.clone(),
537    tracking: matched.rule.tracking_method,
538    remainder: PathBuf::from(name),
539  })
540}
541
542/// Routes a file that matched nothing to the profile's default-location rule,
543/// preserving its full relative path under that route.
544fn default_route(flat: &[FlatRule<'_>], rel: &Path) -> Option<RouteResolution> {
545  let matched = flat.iter().find(|r| r.rule.is_default_location)?;
546
547  Some(RouteResolution {
548    route: matched.full_route.clone(),
549    tracking: matched.rule.tracking_method,
550    remainder: rel.to_path_buf(),
551  })
552}
553
554/// Finds the flattened rule whose longest `default_file_extensions` suffix matches
555/// `name`, so a more specific extension (e.g. `.lib.dll`) wins over `.dll`.
556fn best_extension<'a>(flat: &'a [FlatRule<'a>], name: &str) -> Option<&'a FlatRule<'a>> {
557  flat
558    .iter()
559    .filter_map(|fr| {
560      fr.rule
561        .default_file_extensions
562        .iter()
563        .filter(|ext| name.ends_with(ext.as_str()))
564        .map(|ext| ext.len())
565        .max()
566        .map(|len| (len, fr))
567    })
568    .max_by_key(|(len, _)| *len)
569    .map(|(_, fr)| fr)
570}
571
572/// Computes the destination for a routed file, applying `<ident>` namespacing for
573/// per-mod tracking methods. Returns `None` for methods with no per-file placement
574/// (`package-zip`, unknown) and for a flattened `subdir` remainder that has no file
575/// name, all of which the caller skips.
576fn build_dest(
577  route: &Path,
578  tracking: TrackingMethod,
579  ident: &str,
580  remainder: &Path,
581) -> Option<PathBuf> {
582  match tracking {
583    // `subdir` flattens: the matched file lands at `route/<ident>/<basename>`,
584    // dropping any intermediate directories (mirrors r2modman's installSubDir).
585    // A remainder with no file name (defensive) has no placement, so skip it.
586    TrackingMethod::Subdir => remainder
587      .file_name()
588      .map(|name| route.join(ident).join(name)),
589    // `subdir-no-flatten` namespaces the file but preserves its relative sub-path.
590    TrackingMethod::SubdirNoFlatten => Some(route.join(ident).join(remainder)),
591    TrackingMethod::None | TrackingMethod::State => Some(route.join(remainder)),
592    TrackingMethod::PackageZip | TrackingMethod::Other => None,
593  }
594}
595
596/// The final path segment of a route, i.e. the override-folder name matched at a
597/// package's root (`"BepInEx/plugins"` -> `"plugins"`).
598fn route_leaf(route: &str) -> &str {
599  route.rsplit(['/', '\\']).next().unwrap_or(route)
600}
601
602/// Joins a parent full route with a (possibly slash-separated) child route.
603fn join_route(parent_full: &Path, child_route: &str) -> PathBuf {
604  let child = PathBuf::from(child_route);
605
606  if parent_full.as_os_str().is_empty() {
607    child
608  } else {
609    parent_full.join(child)
610  }
611}
612
613/// Whether `rel` is package-root metadata that a loader pack should not install.
614fn is_package_metadata(rel: &Path) -> bool {
615  if rel.components().count() != 1 {
616    return false;
617  }
618
619  match rel.file_name().and_then(|n| n.to_str()) {
620    Some(name) => {
621      let lower = name.to_ascii_lowercase();
622
623      lower == "manifest.json" || lower == "icon.png" || lower == "readme.md"
624    }
625    None => false,
626  }
627}
628
629/// Collects the full routes of every rule (recursively) matching `pred`.
630///
631/// Used with a `none`-tracking predicate to find a loader pack's shared/untracked
632/// routes (preserved on reinstall), and with a namespaced predicate to find the
633/// per-mod `<Owner-Name>/` routes reconciled by [`remove_delisted`].
634fn collect_routes<F: Fn(&InstallRule) -> bool>(
635  rules: &[InstallRule],
636  parent: &Path,
637  pred: &F,
638  out: &mut Vec<PathBuf>,
639) {
640  for rule in rules {
641    let full = join_route(parent, &rule.route);
642
643    if pred(rule) {
644      out.push(full.clone());
645    }
646
647    if !rule.sub_routes.is_empty() {
648      collect_routes(&rule.sub_routes, &full, pred, out);
649    }
650  }
651}
652
653/// Applies an install plan into `root`, copying files and recording what was
654/// written.
655///
656/// `root` is the caller-supplied profile/loader root. Parent directories are
657/// created as needed. Files tracked as `none` (shared config) are written only if
658/// absent and are never recorded, so a user's edits survive reinstalls and they are
659/// never auto-removed; all other files are (over)written and recorded in the
660/// returned [`FileManifest`] (paths relative to `root`).
661///
662/// The apply is all-or-nothing: if any entry fails, every file this call created
663/// is removed before the error is returned — along with any directory tree this
664/// call newly created (now left empty) — so a partial install leaves `root` as it
665/// found it, with no untracked files or empty directories behind.
666///
667/// For a `none`-tracked (shared config) destination that already exists — a
668/// regular file or a symlink the user planted — the entry is skipped and left
669/// untouched, so a user's edits (or their symlinked config) survive. For a
670/// tracked destination, a symlink already sitting there is refused rather than
671/// followed, so a pre-planted leaf link cannot redirect the write. This is
672/// best-effort: it inspects the final path component only (not symlinked parent
673/// directories) and is not race-free against a concurrent attacker mutating
674/// `root` mid-install.
675pub fn apply_install(plan: &InstallPlan, root: &Path) -> Result<FileManifest> {
676  let mut written = Vec::with_capacity(plan.entries.len());
677  let mut created: Vec<PathBuf> = Vec::new();
678  let mut created_dirs: Vec<PathBuf> = Vec::new();
679
680  for entry in &plan.entries {
681    if let Err(err) = ensure_safe_relative(&entry.dest_relative) {
682      rollback_partial_install(&created, &created_dirs);
683
684      return Err(err);
685    }
686
687    let dest = root.join(&entry.dest_relative);
688
689    let dest_is_symlink = fs::symlink_metadata(&dest)
690      .map(|metadata| metadata.file_type().is_symlink())
691      .unwrap_or(false);
692
693    match classify_entry(entry.tracking, dest.exists(), dest_is_symlink) {
694      // Shared config already present (a file or a symlink the user planted) is
695      // left untouched so a user's edits survive.
696      ApplyAction::SkipExistingConfig => continue,
697      // A pre-planted symlink at a tracked destination is refused rather than
698      // followed, so it cannot redirect the write elsewhere.
699      ApplyAction::RefuseSymlink => {
700        rollback_partial_install(&created, &created_dirs);
701
702        return Err(Error::Install(format!(
703          "refusing to install through a symlink at {}",
704          dest.display()
705        )));
706      }
707      ApplyAction::Write { record } => {
708        if let Err(err) = copy_entry(entry, &dest, root, &mut created_dirs) {
709          rollback_partial_install(&created, &created_dirs);
710
711          return Err(err);
712        }
713
714        created.push(dest);
715
716        if record {
717          written.push(entry.dest_relative.clone());
718        }
719      }
720    }
721  }
722
723  Ok(FileManifest { written })
724}
725
726/// What [`apply_install`] should do with one entry, given the state at its
727/// destination.
728#[derive(Debug, Clone, Copy, PartialEq, Eq)]
729enum ApplyAction {
730  /// Copy the source into place; `record` says whether to add it to the
731  /// [`FileManifest`] (tracked files are recorded, shared config is not).
732  Write { record: bool },
733  /// Leave an existing shared-config (`none`-tracked) file/symlink untouched.
734  SkipExistingConfig,
735  /// Refuse to write through a pre-existing symlink at a tracked destination.
736  RefuseSymlink,
737}
738
739/// Decides what to do with an entry from its tracking method and whether the
740/// destination already exists / is a symlink. Pure — no filesystem access — so
741/// the write/skip/refuse policy is exhaustively table-testable.
742fn classify_entry(tracking: TrackingMethod, exists: bool, is_symlink: bool) -> ApplyAction {
743  if tracking == TrackingMethod::None && (exists || is_symlink) {
744    return ApplyAction::SkipExistingConfig;
745  }
746
747  if is_symlink {
748    return ApplyAction::RefuseSymlink;
749  }
750
751  ApplyAction::Write {
752    record: tracking != TrackingMethod::None,
753  }
754}
755
756/// Copies one entry's source to `dest`, creating parent directories as needed and
757/// recording any it newly created in `created_dirs` so a failed apply can undo them.
758fn copy_entry(
759  entry: &InstallEntry,
760  dest: &Path,
761  root: &Path,
762  created_dirs: &mut Vec<PathBuf>,
763) -> Result<()> {
764  if let Some(parent) = dest.parent() {
765    record_created_dirs(parent, root, created_dirs);
766
767    fs::create_dir_all(parent).map_err(|e| {
768      Error::Install(format!(
769        "failed to create install directory {}: {}",
770        parent.display(),
771        e
772      ))
773    })?;
774  }
775
776  fs::copy(&entry.source, dest).map_err(|e| {
777    Error::Install(format!(
778      "failed to copy {} to {}: {}",
779      entry.source.display(),
780      dest.display(),
781      e
782    ))
783  })?;
784
785  Ok(())
786}
787
788/// Records, into `created_dirs`, each not-yet-existing ancestor of `dir` (down to
789/// and including `dir`) that lies strictly below `root`, ordered shallowest first.
790///
791/// Called before `create_dir_all` so a failed apply can remove exactly the
792/// directory trees this call introduced — never `root` itself and never a
793/// directory that already existed. Already-recorded (now-existing) directories are
794/// skipped, so entries sharing a parent do not double-record it.
795fn record_created_dirs(dir: &Path, root: &Path, created_dirs: &mut Vec<PathBuf>) {
796  let mut missing = Vec::new();
797  let mut current = dir;
798
799  while current != root && current.starts_with(root) && !current.exists() {
800    missing.push(current.to_path_buf());
801
802    match current.parent() {
803      Some(parent) => current = parent,
804      None => break,
805    }
806  }
807
808  for path in missing.into_iter().rev() {
809    created_dirs.push(path);
810  }
811}
812
813/// Best-effort removal of the files an [`apply_install`] created before it
814/// failed, then the now-empty directory trees it newly created, so a failed apply
815/// leaves `root` as it found it rather than half applied. Directories are removed
816/// deepest-first (children before parents) and only if empty, so a pre-existing or
817/// still-populated directory is never taken. Cleanup errors are logged, not
818/// propagated: the caller needs to see the original failure, not a secondary one.
819fn rollback_partial_install(created: &[PathBuf], created_dirs: &[PathBuf]) {
820  for path in created.iter().rev() {
821    if let Err(err) = fs::remove_file(path) {
822      tracing::warn!(
823        "failed to roll back partially-installed file {}: {}",
824        path.display(),
825        err
826      );
827    }
828  }
829
830  for dir in created_dirs.iter().rev() {
831    if let Err(err) = fs::remove_dir(dir) {
832      tracing::warn!(
833        "failed to roll back partially-created directory {}: {}",
834        dir.display(),
835        err
836      );
837    }
838  }
839}
840
841/// The `<Owner-Name>`-shaped mod folders under `profile`'s namespaced install
842/// routes that `keep` does not name, as root-relative paths. These are exactly
843/// the directories [`remove_delisted`] would delete.
844///
845/// Each regular mod installs into its own `<Owner-Name>` folder under a
846/// namespaced route (e.g. `BepInEx/plugins/<Owner-Name>/`), so the folders
847/// present under those routes *are* the record of what is installed.
848/// Non-namespaced areas are never considered, which is why loader files at the
849/// root and shared/`none` routes like `BepInEx/config` never appear here.
850///
851/// Public so a caller can ask what a sweep would take before taking it, which is
852/// what lets a CLI warn about folders it does not manage. Sharing this with
853/// [`remove_delisted`] is the point: a second implementation of "what does the
854/// sweep touch" can drift from the one that actually deletes.
855///
856/// Sorted and deduplicated, which `read_dir` order alone does not give, so a
857/// caller listing these one per line reads the same list every run.
858pub fn plan_delisted(
859  profile: &GameProfile,
860  root: &Path,
861  keep: &BTreeSet<String>,
862) -> Result<Vec<PathBuf>> {
863  let mut routes = Vec::new();
864
865  collect_routes(
866    &profile.install_rules,
867    Path::new(""),
868    &|rule| rule.tracking_method.is_namespaced(),
869    &mut routes,
870  );
871
872  let mut planned = BTreeSet::new();
873
874  for route in routes {
875    ensure_safe_relative(&route)?;
876
877    let dir = root.join(&route);
878
879    if !dir.is_dir() {
880      continue;
881    }
882
883    let mut listing = Vec::new();
884
885    for entry in fs::read_dir(&dir)
886      .map_err(|e| io_context(format!("reading install route {}", dir.display()), e))?
887    {
888      let path = entry
889        .map_err(|e| io_context(format!("reading an entry under {}", dir.display()), e))?
890        .path();
891      let metadata = fs::symlink_metadata(&path)
892        .map_err(|e| io_context(format!("inspecting {}", path.display()), e))?;
893
894      listing.push((path, metadata.is_dir(), metadata.file_type().is_symlink()));
895    }
896
897    for path in plan_reconcile(&listing, keep) {
898      planned.insert(path.strip_prefix(root).unwrap_or(&path).to_path_buf());
899    }
900  }
901
902  Ok(planned.into_iter().collect())
903}
904
905/// Reconciles the on-disk install to a desired set, removing the folders of mods
906/// that are no longer wanted, without any sidecar state file.
907///
908/// This is [`plan_delisted`] followed by the removal, so there is one enumeration
909/// and one decision about what the sweep covers. Returns the removed directories
910/// (relative to `root`), which is exactly what `plan_delisted` returned.
911pub fn remove_delisted(
912  profile: &GameProfile,
913  root: &Path,
914  keep: &BTreeSet<String>,
915) -> Result<Vec<PathBuf>> {
916  let planned = plan_delisted(profile, root, keep)?;
917
918  for relative in &planned {
919    let path = root.join(relative);
920
921    fs::remove_dir_all(&path)
922      .map_err(|e| io_context(format!("removing delisted mod {}", path.display()), e))?;
923  }
924
925  Ok(planned)
926}
927
928/// Decides which directories under a namespaced route should be removed, given a
929/// directory `listing` and the set of mod idents to `keep`.
930///
931/// Each tuple is `(path, is_dir, is_symlink)`. A symlink or non-directory is
932/// ignored — never followed or removed. A real directory whose name has the
933/// `<Owner-Name>` shape ([`looks_like_mod_ident`]) and is not in `keep` is
934/// selected for removal; a loader-shipped subfolder (e.g. `MMHOOK`) or a
935/// user-created folder that does not match that shape is left alone. Pure — no
936/// filesystem access — so the reconcile decision is testable without planting
937/// real directory trees.
938fn plan_reconcile(listing: &[(PathBuf, bool, bool)], keep: &BTreeSet<String>) -> Vec<PathBuf> {
939  listing
940    .iter()
941    .filter(|(path, is_dir, is_symlink)| {
942      if *is_symlink || !*is_dir {
943        return false;
944      }
945
946      match path.file_name().and_then(|n| n.to_str()) {
947        Some(ident) => looks_like_mod_ident(ident) && !keep.contains(ident),
948        None => false,
949      }
950    })
951    .map(|(path, _, _)| path.clone())
952    .collect()
953}
954
955/// Whether a directory name has the `<Owner-Name>` shape a mod folder uses: at
956/// least one `-` separating segments of `[A-Za-z0-9_]`. This is the identifier
957/// [`build_dest`] namespaces under, so it distinguishes per-mod folders from
958/// loader-shipped or user-created folders under the same route.
959fn looks_like_mod_ident(name: &str) -> bool {
960  name.contains('-')
961    && name
962      .chars()
963      .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
964}
965
966/// Recursively collects all file paths under `dir` (skipping directories).
967fn walk_files(dir: &Path) -> Result<Vec<PathBuf>> {
968  let mut files = Vec::new();
969  let mut stack = vec![dir.to_path_buf()];
970
971  while let Some(current) = stack.pop() {
972    for entry in fs::read_dir(&current)
973      .map_err(|e| io_context(format!("reading package dir {}", current.display()), e))?
974    {
975      let entry = entry
976        .map_err(|e| io_context(format!("reading an entry under {}", current.display()), e))?;
977      let path = entry.path();
978      let metadata = fs::symlink_metadata(&path)
979        .map_err(|e| io_context(format!("inspecting {}", path.display()), e))?;
980
981      if metadata.file_type().is_symlink() {
982        // Never follow symlinks: a symlinked directory could let an extracted
983        // package escape its root or create a traversal cycle.
984        tracing::warn!("Skipping symlink in package contents: {}", path.display());
985
986        continue;
987      }
988
989      if metadata.is_dir() {
990        stack.push(path);
991      } else {
992        files.push(path);
993      }
994    }
995  }
996
997  files.sort();
998
999  Ok(files)
1000}
1001
1002/// Computes the rename each file needs to toggle its enabled state, omitting
1003/// files already in the desired state. Disabling appends `.old` to a file that
1004/// does not already end in `.old`; enabling strips a trailing `.old`. Pure — no
1005/// I/O — so the policy is table-testable (mirrors `classify_entry`).
1006pub fn plan_suffix_renames(files: &[PathBuf], disable: bool) -> Vec<(PathBuf, PathBuf)> {
1007  let mut renames = Vec::new();
1008
1009  for file in files {
1010    let name = file.to_string_lossy();
1011    let is_old = name.to_lowercase().ends_with(".old");
1012
1013    if disable && !is_old {
1014      renames.push((file.clone(), PathBuf::from(format!("{name}.old"))));
1015    } else if !disable && is_old {
1016      renames.push((file.clone(), PathBuf::from(&name[..name.len() - 4])));
1017    }
1018  }
1019
1020  renames
1021}
1022
1023/// Disables an installed mod: renames its `Subdir`-tracked files to `.old` and
1024/// removes its `State`-tracked files (recoverable via the `_state` file on
1025/// enable). Leaves shared/`none` config and the loader untouched.
1026pub fn disable_mod(profile: &GameProfile, profile_dir: &Path, mod_name: &str) -> Result<()> {
1027  let tracker = read_state_file(profile_dir, mod_name)?;
1028
1029  toggle_subdir_files(profile, profile_dir, mod_name, true)?;
1030
1031  toggle_state_files(profile_dir, tracker, true)
1032}
1033
1034/// Enables an installed mod: strips `.old` from its `Subdir`-tracked files and
1035/// restores its `State`-tracked files by copying them back from the cache
1036/// sources recorded in the `_state` file.
1037pub fn enable_mod(profile: &GameProfile, profile_dir: &Path, mod_name: &str) -> Result<()> {
1038  let tracker = read_state_file(profile_dir, mod_name)?;
1039
1040  toggle_subdir_files(profile, profile_dir, mod_name, false)?;
1041
1042  toggle_state_files(profile_dir, tracker, false)
1043}
1044
1045/// Applies the `.old` rename policy to every file under each namespaced route's
1046/// `<route>/<mod_name>/` folder.
1047fn toggle_subdir_files(
1048  profile: &GameProfile,
1049  profile_dir: &Path,
1050  mod_name: &str,
1051  disable: bool,
1052) -> Result<()> {
1053  let mut routes = Vec::new();
1054
1055  collect_routes(
1056    &profile.install_rules,
1057    Path::new(""),
1058    &|rule| rule.tracking_method.is_namespaced(),
1059    &mut routes,
1060  );
1061
1062  for route in routes {
1063    let mod_dir = profile_dir.join(&route).join(mod_name);
1064
1065    if !mod_dir.is_dir() {
1066      continue;
1067    }
1068
1069    let files = walk_files(&mod_dir)?;
1070
1071    for (from, to) in plan_suffix_renames(&files, disable) {
1072      fs::rename(&from, &to).map_err(|e| io_context(format!("toggling {}", from.display()), e))?;
1073    }
1074  }
1075
1076  Ok(())
1077}
1078
1079/// Removes (disable) or restores from cache (enable) the `State`-tracked files
1080/// recorded in `tracker`. `None` is a no-op: the mod tracked no `State` files.
1081///
1082/// Takes an already-read tracker rather than reading one itself so its caller can
1083/// do that read before any other mutation. Reading it here instead meant an
1084/// unreadable record failed only after [`toggle_subdir_files`] had renamed the
1085/// mod's files, leaving them toggled on disk while the caller's `mods.yml` still
1086/// recorded the old state.
1087fn toggle_state_files(
1088  profile_dir: &Path,
1089  tracker: Option<ModFileTracker>,
1090  disable: bool,
1091) -> Result<()> {
1092  let Some(tracker) = tracker else {
1093    return Ok(());
1094  };
1095
1096  if disable {
1097    return remove_state_files(profile_dir, &tracker);
1098  }
1099
1100  restore_state_files(profile_dir, &tracker)
1101}
1102
1103/// Removes each `State`-tracked destination that still exists, leaving the
1104/// `_state` file itself in place so [`restore_state_files`] can undo this.
1105///
1106/// Each recorded path is re-validated before use: the `_state` file sits in a
1107/// caller-owned directory and could be hand-edited, so a tampered entry must not
1108/// delete a file outside the profile root.
1109fn remove_state_files(profile_dir: &Path, tracker: &ModFileTracker) -> Result<()> {
1110  for (_, install_rel) in &tracker.files {
1111    ensure_safe_relative(Path::new(install_rel))?;
1112
1113    let dest = profile_dir.join(install_rel);
1114
1115    if !dest.exists() {
1116      continue;
1117    }
1118
1119    fs::remove_file(&dest).map_err(|e| io_context(format!("removing {}", dest.display()), e))?;
1120  }
1121
1122  Ok(())
1123}
1124
1125/// Copies each `State`-tracked file back from its recorded cache source,
1126/// creating any missing destination directories.
1127fn restore_state_files(profile_dir: &Path, tracker: &ModFileTracker) -> Result<()> {
1128  for (source, install_rel) in &tracker.files {
1129    let dest = profile_dir.join(install_rel);
1130
1131    if let Some(parent) = dest.parent() {
1132      fs::create_dir_all(parent)
1133        .map_err(|e| io_context(format!("creating {}", parent.display()), e))?;
1134    }
1135
1136    fs::copy(source, &dest)
1137      .map_err(|e| io_context(format!("restoring {} from {}", dest.display(), source), e))?;
1138  }
1139
1140  Ok(())
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145  use super::*;
1146  use crate::ecosystem::{Ecosystem, GameProfile};
1147  use std::fs::File;
1148  use std::io::Write;
1149  use tempfile::tempdir;
1150
1151  fn eco() -> Ecosystem {
1152    Ecosystem::bundled()
1153  }
1154
1155  fn profile(eco: &Ecosystem, slug: &str) -> GameProfile {
1156    eco.game(slug).unwrap().profile().unwrap().clone()
1157  }
1158
1159  fn write(path: &Path, contents: &[u8]) {
1160    if let Some(parent) = path.parent() {
1161      fs::create_dir_all(parent).unwrap();
1162    }
1163
1164    let mut f = File::create(path).unwrap();
1165    f.write_all(contents).unwrap();
1166  }
1167
1168  fn dest_set(plan: &InstallPlan) -> Vec<String> {
1169    let mut v: Vec<String> = plan
1170      .entries
1171      .iter()
1172      .map(|e| e.dest_relative.to_string_lossy().replace('\\', "/"))
1173      .collect();
1174    v.sort();
1175
1176    v
1177  }
1178
1179  #[test]
1180  fn test_bepinex_regular_mod_routing() {
1181    let eco = eco();
1182    let profile = profile(&eco, "valheim");
1183
1184    let pkg = tempdir().unwrap();
1185    let root = pkg.path();
1186
1187    write(&root.join("MyMod.dll"), b"dll");
1188    write(&root.join("manifest.json"), b"{}");
1189    write(&root.join("config/MyMod.cfg"), b"cfg");
1190    write(&root.join("patchers/MyPatcher.dll"), b"patch");
1191    write(&root.join("MyHook.mm.dll"), b"hook");
1192
1193    let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
1194    let dests = dest_set(&plan);
1195
1196    assert!(dests.contains(&"BepInEx/plugins/Author-MyMod/MyMod.dll".to_string()));
1197    // BepInEx defines no exclusions, so metadata routes to the plugin folder.
1198    assert!(dests.contains(&"BepInEx/plugins/Author-MyMod/manifest.json".to_string()));
1199    // config is shared/untracked (not namespaced).
1200    assert!(dests.contains(&"BepInEx/config/MyMod.cfg".to_string()));
1201    // override patchers folder routes (namespaced), contents only.
1202    assert!(dests.contains(&"BepInEx/patchers/Author-MyMod/MyPatcher.dll".to_string()));
1203    // .mm.dll extension wins over .dll and routes to monomod.
1204    assert!(dests.contains(&"BepInEx/monomod/Author-MyMod/MyHook.mm.dll".to_string()));
1205  }
1206
1207  #[test]
1208  fn route_file_routes_by_table_without_disk() {
1209    let eco = eco();
1210    let profile = profile(&eco, "valheim");
1211
1212    let mut flat = Vec::new();
1213
1214    flatten_rules(&profile.install_rules, Path::new(""), &mut flat);
1215
1216    let ident = "Author-MyMod";
1217
1218    // A loose DLL routes to the (subdir-tracked, namespaced) plugins folder.
1219    match route_file(&profile, &flat, ident, Path::new("MyMod.dll")).unwrap() {
1220      RouteOutcome::Route {
1221        dest_relative,
1222        tracking,
1223      } => {
1224        assert_eq!(
1225          dest_relative,
1226          PathBuf::from("BepInEx/plugins/Author-MyMod/MyMod.dll")
1227        );
1228        assert_eq!(tracking, TrackingMethod::Subdir);
1229      }
1230      other => panic!("expected a route, got {other:?}"),
1231    }
1232
1233    // The most-specific extension wins: `.mm.dll` beats `.dll` and routes to monomod.
1234    match route_file(&profile, &flat, ident, Path::new("MyHook.mm.dll")).unwrap() {
1235      RouteOutcome::Route { dest_relative, .. } => assert_eq!(
1236        dest_relative,
1237        PathBuf::from("BepInEx/monomod/Author-MyMod/MyHook.mm.dll")
1238      ),
1239      other => panic!("expected a route, got {other:?}"),
1240    }
1241
1242    // A config file is shared/untracked (`none`), not namespaced.
1243    match route_file(&profile, &flat, ident, Path::new("config/MyMod.cfg")).unwrap() {
1244      RouteOutcome::Route {
1245        dest_relative,
1246        tracking,
1247      } => {
1248        assert_eq!(dest_relative, PathBuf::from("BepInEx/config/MyMod.cfg"));
1249        assert_eq!(tracking, TrackingMethod::None);
1250      }
1251      other => panic!("expected a route, got {other:?}"),
1252    }
1253
1254    // A path with no file name is skipped rather than routed.
1255    assert_eq!(
1256      route_file(&profile, &flat, ident, Path::new("")).unwrap(),
1257      RouteOutcome::Skip(SkipReason::NoName)
1258    );
1259  }
1260
1261  #[test]
1262  fn route_leaf_handles_both_separators() {
1263    assert_eq!(route_leaf("BepInEx/plugins"), "plugins");
1264    assert_eq!(route_leaf("BepInEx\\plugins"), "plugins");
1265    assert_eq!(route_leaf("plugins"), "plugins");
1266  }
1267
1268  #[test]
1269  fn classify_entry_truth_table() {
1270    // Shared config (`none`): skip if a file or a symlink already sits there;
1271    // otherwise write it, but never record it in the manifest.
1272    assert_eq!(
1273      classify_entry(TrackingMethod::None, true, false),
1274      ApplyAction::SkipExistingConfig
1275    );
1276    assert_eq!(
1277      classify_entry(TrackingMethod::None, false, true),
1278      ApplyAction::SkipExistingConfig
1279    );
1280    assert_eq!(
1281      classify_entry(TrackingMethod::None, false, false),
1282      ApplyAction::Write { record: false }
1283    );
1284
1285    // Tracked: refuse a pre-existing symlink; otherwise write and record it.
1286    assert_eq!(
1287      classify_entry(TrackingMethod::Subdir, false, true),
1288      ApplyAction::RefuseSymlink
1289    );
1290    assert_eq!(
1291      classify_entry(TrackingMethod::State, true, false),
1292      ApplyAction::Write { record: true }
1293    );
1294    assert_eq!(
1295      classify_entry(TrackingMethod::SubdirNoFlatten, false, false),
1296      ApplyAction::Write { record: true }
1297    );
1298  }
1299
1300  #[test]
1301  fn plan_reconcile_selects_only_unwanted_mod_dirs() {
1302    let keep: BTreeSet<String> = ["Author-Keep".to_string()].into_iter().collect();
1303
1304    // (path, is_dir, is_symlink)
1305    let listing = vec![
1306      (PathBuf::from("Author-Keep"), true, false),  // kept
1307      (PathBuf::from("Author-Drop"), true, false),  // removed
1308      (PathBuf::from("MMHOOK"), true, false),       // not an ident -> ignored
1309      (PathBuf::from("Author-Link"), true, true),   // symlink -> ignored
1310      (PathBuf::from("Author-File"), false, false), // not a dir -> ignored
1311    ];
1312
1313    let removable = plan_reconcile(&listing, &keep);
1314
1315    assert_eq!(removable, vec![PathBuf::from("Author-Drop")]);
1316  }
1317
1318  #[test]
1319  fn classify_loader_file_drops_metadata_excluded_and_routes_rest() {
1320    let untracked = vec![PathBuf::from("BepInEx/config")];
1321    let exclusions = vec!["LICENCE".to_string()];
1322
1323    // Package-root metadata is dropped.
1324    assert_eq!(
1325      classify_loader_file(Path::new("manifest.json"), &untracked, &exclusions).unwrap(),
1326      None
1327    );
1328
1329    // An excluded file is dropped.
1330    assert_eq!(
1331      classify_loader_file(Path::new("LICENCE"), &untracked, &exclusions).unwrap(),
1332      None
1333    );
1334
1335    // A file under an untracked route is preserved (`none` tracking).
1336    assert_eq!(
1337      classify_loader_file(Path::new("BepInEx/config/x.cfg"), &untracked, &exclusions).unwrap(),
1338      Some(TrackingMethod::None)
1339    );
1340
1341    // Anything else is recorded (`state` tracking).
1342    assert_eq!(
1343      classify_loader_file(
1344        Path::new("BepInEx/core/loader.dll"),
1345        &untracked,
1346        &exclusions
1347      )
1348      .unwrap(),
1349      Some(TrackingMethod::State)
1350    );
1351
1352    // A traversal path is rejected.
1353    assert!(classify_loader_file(Path::new("../evil"), &untracked, &exclusions).is_err());
1354  }
1355
1356  #[test]
1357  fn apply_install_empty_plan_writes_nothing() {
1358    let root = tempdir().unwrap();
1359
1360    let manifest = apply_install(&InstallPlan::default(), root.path()).unwrap();
1361
1362    assert!(manifest.written.is_empty());
1363  }
1364
1365  #[test]
1366  fn test_subdir_tracking_flattens_nested_file() {
1367    let eco = eco();
1368    let profile = profile(&eco, "valheim");
1369
1370    let pkg = tempdir().unwrap();
1371    let root = pkg.path();
1372
1373    // A file nested inside the subdir-tracked plugins override folder is flattened
1374    // into the mod's namespaced folder, dropping the intermediate directory.
1375    write(&root.join("plugins/nested/Deep.dll"), b"deep");
1376
1377    let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
1378    let dests = dest_set(&plan);
1379
1380    assert!(dests.contains(&"BepInEx/plugins/Author-MyMod/Deep.dll".to_string()));
1381    assert!(!dests.iter().any(|d| d.contains("nested")));
1382  }
1383
1384  #[test]
1385  fn test_subdir_no_flatten_preserves_subpath() {
1386    let eco = eco();
1387    let profile = profile(&eco, "rumble");
1388
1389    let pkg = tempdir().unwrap();
1390    let root = pkg.path();
1391
1392    // rumble's default location (UserData/ModManager) is subdir-no-flatten, so a
1393    // nested file keeps its relative sub-path under the mod's namespaced folder.
1394    write(&root.join("data/nested/asset.bin"), b"asset");
1395
1396    let plan = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
1397    let dests = dest_set(&plan);
1398
1399    assert!(dests.contains(&"UserData/ModManager/Author-Mod/data/nested/asset.bin".to_string()));
1400  }
1401
1402  #[test]
1403  fn test_extension_routing_applies_to_nested_files() {
1404    let eco = eco();
1405    let profile = profile(&eco, "valheim");
1406
1407    let pkg = tempdir().unwrap();
1408    let root = pkg.path();
1409
1410    // A .mm.dll inside an ordinary (non-override) subfolder still routes by its
1411    // extension to monomod rather than falling to the default plugins route; the
1412    // subdir flatten then drops the wrapper directory.
1413    write(&root.join("Wrapper/Hook.mm.dll"), b"hook");
1414
1415    let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
1416    let dests = dest_set(&plan);
1417
1418    assert!(dests.contains(&"BepInEx/monomod/Author-MyMod/Hook.mm.dll".to_string()));
1419    assert!(!dests.iter().any(|d| d.contains("Wrapper")));
1420  }
1421
1422  #[test]
1423  fn test_loader_pack_unpacks_to_root_stripping_nested_subfolder() {
1424    let eco = eco();
1425    let profile = profile(&eco, "valheim");
1426
1427    let pkg = tempdir().unwrap();
1428    let root = pkg.path();
1429
1430    write(&root.join("BepInExPack_Valheim/winhttp.dll"), b"proxy");
1431    write(
1432      &root.join("BepInExPack_Valheim/BepInEx/core/BepInEx.dll"),
1433      b"core",
1434    );
1435    write(&root.join("manifest.json"), b"{}");
1436    write(&root.join("icon.png"), b"png");
1437
1438    let plan = plan_install(&eco, &profile, "denikson-BepInExPack_Valheim", root, None).unwrap();
1439    let dests = dest_set(&plan);
1440
1441    assert!(dests.contains(&"winhttp.dll".to_string()));
1442    assert!(dests.contains(&"BepInEx/core/BepInEx.dll".to_string()));
1443    assert!(!dests.iter().any(|d| d.contains("manifest.json")));
1444    assert!(!dests.iter().any(|d| d.contains("BepInExPack_Valheim")));
1445    assert!(!dests.iter().any(|d| d.contains("icon.png")));
1446  }
1447
1448  #[test]
1449  fn test_loader_pack_preserves_config_on_reinstall() {
1450    let eco = eco();
1451    let profile = profile(&eco, "valheim");
1452
1453    let pkg = tempdir().unwrap();
1454    let pkg_root = pkg.path();
1455    write(&pkg_root.join("BepInExPack_Valheim/winhttp.dll"), b"proxy");
1456    write(
1457      &pkg_root.join("BepInExPack_Valheim/BepInEx/config/BepInEx.cfg"),
1458      b"default",
1459    );
1460
1461    let profile_dir = tempdir().unwrap();
1462    let root = profile_dir.path();
1463
1464    let plan = plan_install(
1465      &eco,
1466      &profile,
1467      "denikson-BepInExPack_Valheim",
1468      pkg_root,
1469      None,
1470    )
1471    .unwrap();
1472    apply_install(&plan, root).unwrap();
1473
1474    let cfg = root.join("BepInEx/config/BepInEx.cfg");
1475    write(&cfg, b"user-edited");
1476
1477    let plan = plan_install(
1478      &eco,
1479      &profile,
1480      "denikson-BepInExPack_Valheim",
1481      pkg_root,
1482      None,
1483    )
1484    .unwrap();
1485    apply_install(&plan, root).unwrap();
1486
1487    assert_eq!(fs::read_to_string(&cfg).unwrap(), "user-edited");
1488    assert!(root.join("winhttp.dll").exists());
1489  }
1490
1491  #[test]
1492  fn test_loader_pack_without_nesting_falls_back_to_root() {
1493    let eco = eco();
1494    let profile = profile(&eco, "valheim");
1495
1496    let pkg = tempdir().unwrap();
1497    let root = pkg.path();
1498
1499    // The pack declares a nested root folder, but it is absent here: copy the
1500    // package root directly.
1501    write(&root.join("winhttp.dll"), b"proxy");
1502
1503    let plan = plan_install(&eco, &profile, "denikson-BepInExPack_Valheim", root, None).unwrap();
1504    let dests = dest_set(&plan);
1505
1506    assert!(dests.contains(&"winhttp.dll".to_string()));
1507  }
1508
1509  #[test]
1510  fn test_loader_pack_skips_relative_file_exclusions() {
1511    let eco = eco();
1512    // The boneworks profile lists LICENCE in relativeFileExclusions.
1513    let profile = profile(&eco, "boneworks");
1514
1515    let pkg = tempdir().unwrap();
1516    let root = pkg.path();
1517
1518    // A loader pack from the bundled registry whose declared root folder is absent,
1519    // so its own root is copied directly.
1520    write(&root.join("winhttp.dll"), b"proxy");
1521    write(&root.join("LICENCE"), b"license text");
1522
1523    let plan = plan_install(&eco, &profile, "denikson-BepInExPack_Valheim", root, None).unwrap();
1524    let dests = dest_set(&plan);
1525
1526    assert!(dests.contains(&"winhttp.dll".to_string()));
1527    // The profile-excluded LICENCE file is neither installed nor recorded, just
1528    // like plan_regular_mod honors the same exclusions.
1529    assert!(!dests.iter().any(|d| d.contains("LICENCE")));
1530  }
1531
1532  #[test]
1533  fn test_config_is_preserved_across_reinstall() {
1534    let eco = eco();
1535    let profile = profile(&eco, "valheim");
1536
1537    let pkg = tempdir().unwrap();
1538    let pkg_root = pkg.path();
1539    write(&pkg_root.join("MyMod.dll"), b"dll");
1540    write(&pkg_root.join("config/MyMod.cfg"), b"default-config");
1541
1542    let profile_dir = tempdir().unwrap();
1543    let root = profile_dir.path();
1544
1545    let plan = plan_install(&eco, &profile, "Author-MyMod", pkg_root, None).unwrap();
1546    let first = apply_install(&plan, root).unwrap();
1547
1548    assert_eq!(first.written.len(), 1);
1549    assert!(root.join("BepInEx/config/MyMod.cfg").exists());
1550
1551    let cfg = root.join("BepInEx/config/MyMod.cfg");
1552    write(&cfg, b"user-edited");
1553
1554    let plan = plan_install(&eco, &profile, "Author-MyMod", pkg_root, None).unwrap();
1555    let second = apply_install(&plan, root).unwrap();
1556
1557    assert_eq!(fs::read_to_string(&cfg).unwrap(), "user-edited");
1558    assert!(second.written.iter().any(|p| p.ends_with("MyMod.dll")));
1559    assert!(
1560      !second
1561        .written
1562        .iter()
1563        .any(|p| p.to_string_lossy().contains("MyMod.cfg"))
1564    );
1565  }
1566
1567  #[test]
1568  fn test_apply_and_manifest_roundtrip() {
1569    let eco = eco();
1570    let profile = profile(&eco, "valheim");
1571
1572    let pkg = tempdir().unwrap();
1573    let pkg_root = pkg.path();
1574    write(&pkg_root.join("MyMod.dll"), b"dll");
1575    write(&pkg_root.join("config/MyMod.cfg"), b"cfg");
1576
1577    let plan = plan_install(&eco, &profile, "Author-MyMod", pkg_root, None).unwrap();
1578
1579    let profile_dir = tempdir().unwrap();
1580    let root = profile_dir.path();
1581    let manifest = apply_install(&plan, root).unwrap();
1582
1583    assert_eq!(manifest.written.len(), 1);
1584    assert!(root.join("BepInEx/plugins/Author-MyMod/MyMod.dll").exists());
1585    assert!(root.join("BepInEx/config/MyMod.cfg").exists());
1586
1587    for rel in &manifest.written {
1588      fs::remove_file(root.join(rel)).unwrap();
1589    }
1590
1591    assert!(!root.join("BepInEx/plugins/Author-MyMod/MyMod.dll").exists());
1592  }
1593
1594  #[test]
1595  fn test_apply_install_rolls_back_on_failure() {
1596    let src = tempdir().unwrap();
1597
1598    let good = src.path().join("good.dll");
1599    write(&good, b"good");
1600
1601    let plan = InstallPlan {
1602      entries: vec![
1603        InstallEntry {
1604          source: good,
1605          dest_relative: PathBuf::from("BepInEx/plugins/Author-Mod/good.dll"),
1606          tracking: TrackingMethod::State,
1607        },
1608        InstallEntry {
1609          source: src.path().join("does_not_exist.dll"),
1610          dest_relative: PathBuf::from("BepInEx/plugins/Author-Mod/missing.dll"),
1611          tracking: TrackingMethod::State,
1612        },
1613      ],
1614    };
1615
1616    let profile_dir = tempdir().unwrap();
1617    let root = profile_dir.path();
1618
1619    let result = apply_install(&plan, root);
1620
1621    // The second entry fails to copy (its source is absent); the first,
1622    // already-written file is rolled back so no untracked file is left behind.
1623    assert!(result.is_err());
1624    assert!(!root.join("BepInEx/plugins/Author-Mod/good.dll").exists());
1625    // The directory tree this apply newly created is removed too, so no empty
1626    // directories are left behind.
1627    assert!(!root.join("BepInEx/plugins/Author-Mod").exists());
1628    assert!(!root.join("BepInEx/plugins").exists());
1629    assert!(!root.join("BepInEx").exists());
1630    // The caller-supplied root itself is left in place.
1631    assert!(root.exists());
1632  }
1633
1634  #[test]
1635  #[cfg(unix)]
1636  fn test_apply_install_preserves_symlinked_shared_config() {
1637    use std::os::unix::fs::symlink;
1638
1639    let src = tempdir().unwrap();
1640    let source = src.path().join("mod.cfg");
1641    write(&source, b"packaged-default");
1642
1643    let outside = tempdir().unwrap();
1644    let user_config = outside.path().join("user.cfg");
1645    write(&user_config, b"user-edited");
1646
1647    let profile_dir = tempdir().unwrap();
1648    let root = profile_dir.path();
1649
1650    // The user symlinked their shared config to a file they maintain elsewhere.
1651    let dest_relative = PathBuf::from("BepInEx/config/mod.cfg");
1652    let dest = root.join(&dest_relative);
1653    fs::create_dir_all(dest.parent().unwrap()).unwrap();
1654    symlink(&user_config, &dest).unwrap();
1655
1656    let plan = InstallPlan {
1657      entries: vec![InstallEntry {
1658        source,
1659        dest_relative,
1660        tracking: TrackingMethod::None,
1661      }],
1662    };
1663
1664    let manifest = apply_install(&plan, root).unwrap();
1665
1666    // The install succeeds, the None entry is not recorded, and the user's
1667    // symlinked config is left exactly as it was.
1668    assert!(manifest.written.is_empty());
1669    assert_eq!(fs::read_to_string(&user_config).unwrap(), "user-edited");
1670  }
1671
1672  #[test]
1673  #[cfg(unix)]
1674  fn test_apply_install_refuses_to_write_through_symlink() {
1675    use std::os::unix::fs::symlink;
1676
1677    let src = tempdir().unwrap();
1678    let source = src.path().join("mod.cfg");
1679    write(&source, b"new-content");
1680
1681    let outside = tempdir().unwrap();
1682    let secret = outside.path().join("secret.txt");
1683    write(&secret, b"original");
1684
1685    let profile_dir = tempdir().unwrap();
1686    let root = profile_dir.path();
1687
1688    // Plant a symlink at the destination pointing outside the root.
1689    let dest_relative = PathBuf::from("BepInEx/config/mod.cfg");
1690    let dest = root.join(&dest_relative);
1691    fs::create_dir_all(dest.parent().unwrap()).unwrap();
1692    symlink(&secret, &dest).unwrap();
1693
1694    let plan = InstallPlan {
1695      entries: vec![InstallEntry {
1696        source,
1697        dest_relative,
1698        tracking: TrackingMethod::State,
1699      }],
1700    };
1701
1702    let result = apply_install(&plan, root);
1703
1704    // The write is refused and the symlink target outside the root is untouched.
1705    assert!(result.is_err());
1706    assert_eq!(fs::read_to_string(&secret).unwrap(), "original");
1707  }
1708
1709  #[test]
1710  fn test_remove_delisted_removes_only_unwanted_mod_folders() {
1711    let eco = eco();
1712    let profile = profile(&eco, "valheim");
1713
1714    let profile_dir = tempdir().unwrap();
1715    let root = profile_dir.path();
1716
1717    write(&root.join("BepInEx/plugins/Owner-ModA/A.dll"), b"a");
1718    write(&root.join("BepInEx/plugins/Owner-ModB/B.dll"), b"b");
1719    write(&root.join("BepInEx/core/BepInEx.dll"), b"loader");
1720    write(&root.join("BepInEx/config/BepInEx.cfg"), b"cfg");
1721
1722    let keep: BTreeSet<String> = ["Owner-ModA".to_string()].into_iter().collect();
1723    let removed = remove_delisted(&profile, root, &keep).unwrap();
1724
1725    assert!(root.join("BepInEx/plugins/Owner-ModA/A.dll").exists());
1726    assert!(!root.join("BepInEx/plugins/Owner-ModB").exists());
1727    // A loose loader file in core (not a per-mod folder) and user config survive.
1728    assert!(root.join("BepInEx/core/BepInEx.dll").exists());
1729    assert!(root.join("BepInEx/config/BepInEx.cfg").exists());
1730
1731    assert_eq!(removed.len(), 1);
1732  }
1733
1734  #[test]
1735  fn plan_delisted_lists_what_remove_delisted_would_take_without_taking_it() {
1736    let eco = eco();
1737    let profile = profile(&eco, "valheim");
1738
1739    let profile_dir = tempdir().unwrap();
1740    let root = profile_dir.path();
1741
1742    write(&root.join("BepInEx/plugins/Owner-ModA/A.dll"), b"a");
1743    write(&root.join("BepInEx/plugins/Owner-ModB/B.dll"), b"b");
1744    write(&root.join("BepInEx/plugins/Owner-ModC/C.dll"), b"c");
1745    // Not `<Owner-Name>`-shaped, so the sweep never takes it.
1746    write(&root.join("BepInEx/plugins/MMHOOK/hook.dll"), b"hook");
1747
1748    let keep: BTreeSet<String> = ["Owner-ModA".to_string()].into_iter().collect();
1749    let planned = plan_delisted(&profile, root, &keep).unwrap();
1750
1751    // Root-relative and sorted, so a caller listing these one per line gets the
1752    // same order every run rather than whatever `read_dir` happened to yield.
1753    assert_eq!(
1754      planned,
1755      vec![
1756        PathBuf::from("BepInEx/plugins/Owner-ModB"),
1757        PathBuf::from("BepInEx/plugins/Owner-ModC"),
1758      ]
1759    );
1760    // Planning is not applying.
1761    assert!(root.join("BepInEx/plugins/Owner-ModB/B.dll").exists());
1762    assert!(root.join("BepInEx/plugins/Owner-ModC/C.dll").exists());
1763
1764    // What was planned is exactly what the apply takes, because they share the
1765    // one enumeration rather than each deciding for themselves.
1766    let removed = remove_delisted(&profile, root, &keep).unwrap();
1767
1768    assert_eq!(removed, planned);
1769    assert!(!root.join("BepInEx/plugins/Owner-ModB").exists());
1770    assert!(!root.join("BepInEx/plugins/Owner-ModC").exists());
1771    assert!(root.join("BepInEx/plugins/Owner-ModA/A.dll").exists());
1772    assert!(root.join("BepInEx/plugins/MMHOOK/hook.dll").exists());
1773  }
1774
1775  #[test]
1776  fn test_remove_delisted_ignores_non_mod_directories() {
1777    let eco = eco();
1778    let profile = profile(&eco, "valheim");
1779
1780    let profile_dir = tempdir().unwrap();
1781    let root = profile_dir.path();
1782
1783    // A per-mod folder (has the `Owner-Name` shape) and a loader-shipped folder
1784    // with no hyphen sitting directly under the namespaced plugins route.
1785    write(&root.join("BepInEx/plugins/Owner-Delete/x.dll"), b"x");
1786    write(&root.join("BepInEx/plugins/MMHOOK/hook.dll"), b"hook");
1787
1788    let keep: BTreeSet<String> = BTreeSet::new();
1789    let removed = remove_delisted(&profile, root, &keep).unwrap();
1790
1791    // The mod folder is reconciled away; the non-ident loader folder is left.
1792    assert!(!root.join("BepInEx/plugins/Owner-Delete").exists());
1793    assert!(root.join("BepInEx/plugins/MMHOOK/hook.dll").exists());
1794    assert_eq!(removed.len(), 1);
1795  }
1796
1797  #[test]
1798  fn test_melonloader_flat_routing() {
1799    let eco = eco();
1800    let profile = profile(&eco, "boneworks");
1801
1802    let pkg = tempdir().unwrap();
1803    let root = pkg.path();
1804    write(&root.join("MyMod.dll"), b"dll");
1805    write(&root.join("manifest.json"), b"{}");
1806
1807    let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
1808    let dests = dest_set(&plan);
1809
1810    // .dll routes to Mods (state tracking, not namespaced).
1811    assert!(dests.contains(&"Mods/MyMod.dll".to_string()));
1812    // manifest.json is excluded by the profile's relativeFileExclusions.
1813    assert!(!dests.iter().any(|d| d.contains("manifest.json")));
1814  }
1815
1816  #[test]
1817  fn test_melonloader_specific_extension_wins_over_generic() {
1818    let eco = eco();
1819    let profile = profile(&eco, "boneworks");
1820
1821    let pkg = tempdir().unwrap();
1822    let root = pkg.path();
1823
1824    write(&root.join("MyLib.lib.dll"), b"lib");
1825    write(&root.join("MyMod.dll"), b"dll");
1826
1827    let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
1828    let dests = dest_set(&plan);
1829
1830    assert!(dests.contains(&"UserLibs/MyLib.lib.dll".to_string()));
1831    assert!(dests.contains(&"Mods/MyMod.dll".to_string()));
1832  }
1833
1834  #[test]
1835  fn test_melonloader_subroute_extension_routing() {
1836    let eco = eco();
1837    let profile = profile(&eco, "bonelab");
1838
1839    let pkg = tempdir().unwrap();
1840    let root = pkg.path();
1841
1842    // A loose .melon routes to the UserData/CustomItems subroute by extension.
1843    write(&root.join("cool.melon"), b"item");
1844    // A file already under UserData routes via folder + subroute extension.
1845    write(&root.join("UserData/other.melon"), b"item2");
1846
1847    let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
1848    let dests = dest_set(&plan);
1849
1850    assert!(dests.contains(&"UserData/CustomItems/cool.melon".to_string()));
1851    assert!(dests.contains(&"UserData/CustomItems/other.melon".to_string()));
1852  }
1853
1854  #[test]
1855  fn test_northstar_state_routing_and_exclusions() {
1856    let eco = eco();
1857    let profile = profile(&eco, "northstar");
1858
1859    let pkg = tempdir().unwrap();
1860    let root = pkg.path();
1861
1862    write(&root.join("mods/Author.Mod/mod.json"), b"{}");
1863    write(&root.join("manifest.json"), b"{}");
1864
1865    let plan = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
1866    let dests = dest_set(&plan);
1867
1868    // mods/ matches the R2Northstar/mods route (state, structure preserved).
1869    assert!(dests.contains(&"R2Northstar/mods/Author.Mod/mod.json".to_string()));
1870    // manifest.json is excluded.
1871    assert!(!dests.iter().any(|d| d.contains("manifest.json")));
1872  }
1873
1874  #[test]
1875  fn test_godotml_package_zip() {
1876    let eco = eco();
1877    let profile = profile(&eco, "brotato");
1878
1879    let pkg = tempdir().unwrap();
1880    let root = pkg.path();
1881    write(&root.join("something.pck"), b"pck");
1882
1883    let archive = pkg.path().join("Author-Mod-1.0.0.zip");
1884    write(&archive, b"zipbytes");
1885
1886    let plan = plan_install(&eco, &profile, "Author-Mod", root, Some(&archive)).unwrap();
1887    let dests = dest_set(&plan);
1888
1889    assert_eq!(dests, vec!["mods/Author-Mod.zip".to_string()]);
1890    assert_eq!(plan.entries[0].tracking, TrackingMethod::PackageZip);
1891
1892    // Without the archive, the package-zip install is skipped rather than wrong.
1893    let empty = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
1894    assert!(empty.entries.is_empty());
1895  }
1896
1897  #[test]
1898  fn test_plan_install_rejects_traversal_in_package_name() {
1899    let eco = eco();
1900    let profile = profile(&eco, "valheim");
1901
1902    let pkg = tempdir().unwrap();
1903    let root = pkg.path();
1904
1905    write(&root.join("MyMod.dll"), b"dll");
1906
1907    let result = plan_install(&eco, &profile, "../../etc/Author-Evil", root, None);
1908
1909    assert!(result.is_err());
1910  }
1911
1912  #[test]
1913  fn test_ensure_safe_relative_rejects_unsafe_paths() {
1914    assert!(ensure_safe_relative(Path::new("BepInEx/plugins/Author-Mod/x.dll")).is_ok());
1915    assert!(ensure_safe_relative(Path::new("../escape")).is_err());
1916    assert!(ensure_safe_relative(Path::new("a/../../escape")).is_err());
1917    assert!(ensure_safe_relative(Path::new("/abs/path")).is_err());
1918  }
1919
1920  #[cfg(unix)]
1921  #[test]
1922  fn test_walk_files_skips_symlinks() {
1923    use std::os::unix::fs::symlink;
1924
1925    let dir = tempdir().unwrap();
1926    let root = dir.path();
1927
1928    write(&root.join("real.txt"), b"real");
1929    symlink("/etc/passwd", root.join("link.txt")).unwrap();
1930
1931    let files = walk_files(root).unwrap();
1932
1933    assert!(files.iter().any(|p| p.ends_with("real.txt")));
1934    assert!(!files.iter().any(|p| p.ends_with("link.txt")));
1935  }
1936
1937  #[test]
1938  fn state_tracker_collects_only_state_entries() {
1939    let plan = InstallPlan {
1940      entries: vec![
1941        InstallEntry {
1942          source: PathBuf::from("/cache/Author-Mod/1.0.0/loader.dll"),
1943          dest_relative: PathBuf::from("BepInEx/core/loader.dll"),
1944          tracking: TrackingMethod::State,
1945        },
1946        InstallEntry {
1947          source: PathBuf::from("/cache/Author-Mod/1.0.0/plugin.dll"),
1948          dest_relative: PathBuf::from("BepInEx/plugins/Author-Mod/plugin.dll"),
1949          tracking: TrackingMethod::Subdir,
1950        },
1951      ],
1952    };
1953
1954    let tracker = state_tracker_from_plan(&plan, "Author-Mod").unwrap();
1955
1956    assert_eq!(tracker.mod_name, "Author-Mod");
1957    assert_eq!(tracker.files.len(), 1);
1958    assert_eq!(tracker.files[0].1, "BepInEx/core/loader.dll");
1959  }
1960
1961  #[test]
1962  fn state_tracker_none_when_no_state_entries() {
1963    let plan = InstallPlan {
1964      entries: vec![InstallEntry {
1965        source: PathBuf::from("/cache/x/plugin.dll"),
1966        dest_relative: PathBuf::from("BepInEx/plugins/Author-Mod/plugin.dll"),
1967        tracking: TrackingMethod::Subdir,
1968      }],
1969    };
1970
1971    assert!(state_tracker_from_plan(&plan, "Author-Mod").is_none());
1972  }
1973
1974  #[test]
1975  fn remove_state_payload_deletes_files_then_tracker() {
1976    let dir = tempdir().unwrap();
1977    let root = dir.path();
1978
1979    write(&root.join("BepInEx/core/loader.dll"), b"loader");
1980
1981    let tracker = ModFileTracker {
1982      mod_name: "Author-Loader".to_string(),
1983      files: vec![
1984        (
1985          "/cache/loader.dll".to_string(),
1986          "BepInEx/core/loader.dll".to_string(),
1987        ),
1988        // An already-removed payload (e.g. the mod was disabled first) is skipped.
1989        (
1990          "/cache/gone.dll".to_string(),
1991          "BepInEx/core/gone.dll".to_string(),
1992        ),
1993      ],
1994    };
1995
1996    write_state_file(root, &tracker).unwrap();
1997    remove_state_payload(root, "Author-Loader").unwrap();
1998
1999    assert!(!root.join("BepInEx/core/loader.dll").exists());
2000    assert!(!state_file_path(root, "Author-Loader").exists());
2001
2002    // A mod that tracked no `State` files has no `_state` file: a no-op, not an error.
2003    assert!(remove_state_payload(root, "Author-Untracked").is_ok());
2004  }
2005
2006  #[test]
2007  fn remove_state_payload_rejects_traversal_in_tracker() {
2008    let dir = tempdir().unwrap();
2009    let root = dir.path().join("profile");
2010
2011    let outside = dir.path().join("secret.txt");
2012    write(&outside, b"secret");
2013
2014    let tracker = ModFileTracker {
2015      mod_name: "Author-Evil".to_string(),
2016      files: vec![("/cache/x".to_string(), "../secret.txt".to_string())],
2017    };
2018
2019    write_state_file(&root, &tracker).unwrap();
2020
2021    // A hand-edited tracker cannot delete a file outside the profile root.
2022    assert!(remove_state_payload(&root, "Author-Evil").is_err());
2023    assert!(outside.exists());
2024  }
2025
2026  #[test]
2027  fn state_file_round_trips() {
2028    let dir = tempdir().unwrap();
2029    let tracker = ModFileTracker {
2030      mod_name: "Author-Mod".to_string(),
2031      files: vec![("/cache/a".to_string(), "BepInEx/core/a.dll".to_string())],
2032    };
2033
2034    write_state_file(dir.path(), &tracker).unwrap();
2035
2036    assert!(dir.path().join("_state/Author-Mod-state.yml").exists());
2037
2038    let back = read_state_file(dir.path(), "Author-Mod").unwrap().unwrap();
2039
2040    assert_eq!(back.mod_name, "Author-Mod");
2041    assert_eq!(back.files, tracker.files);
2042    assert!(read_state_file(dir.path(), "Missing").unwrap().is_none());
2043  }
2044
2045  #[test]
2046  fn plan_suffix_renames_adds_and_strips_old() {
2047    let files = vec![
2048      PathBuf::from("a/b/plugin.dll"),
2049      PathBuf::from("a/b/data.cfg.old"),
2050    ];
2051
2052    let disable = plan_suffix_renames(&files, true);
2053
2054    // plugin.dll -> plugin.dll.old ; the already-.old file is left alone (omitted).
2055    assert_eq!(
2056      disable,
2057      vec![(
2058        PathBuf::from("a/b/plugin.dll"),
2059        PathBuf::from("a/b/plugin.dll.old"),
2060      )]
2061    );
2062
2063    let enable = plan_suffix_renames(&files, false);
2064
2065    // data.cfg.old -> data.cfg ; the non-.old file is left alone.
2066    assert_eq!(
2067      enable,
2068      vec![(
2069        PathBuf::from("a/b/data.cfg.old"),
2070        PathBuf::from("a/b/data.cfg"),
2071      )]
2072    );
2073  }
2074
2075  #[test]
2076  fn disable_enable_subdir_renames_files() {
2077    let eco = eco();
2078    let profile = profile(&eco, "valheim");
2079
2080    let profile_dir = tempdir().unwrap();
2081    let root = profile_dir.path();
2082    write(&root.join("BepInEx/plugins/Author-Mod/plugin.dll"), b"x");
2083
2084    disable_mod(&profile, root, "Author-Mod").unwrap();
2085
2086    assert!(!root.join("BepInEx/plugins/Author-Mod/plugin.dll").exists());
2087    assert!(
2088      root
2089        .join("BepInEx/plugins/Author-Mod/plugin.dll.old")
2090        .exists()
2091    );
2092
2093    enable_mod(&profile, root, "Author-Mod").unwrap();
2094
2095    assert!(root.join("BepInEx/plugins/Author-Mod/plugin.dll").exists());
2096  }
2097
2098  #[test]
2099  fn disable_enable_state_removes_and_restores_from_cache() {
2100    let eco = eco();
2101    let profile = profile(&eco, "valheim");
2102
2103    let profile_dir = tempdir().unwrap();
2104    let root = profile_dir.path();
2105
2106    // A cached source file and its installed copy under a State route.
2107    let cache = tempdir().unwrap();
2108    let src = cache.path().join("loader.dll");
2109    write(&src, b"loader");
2110    write(&root.join("BepInEx/core/loader.dll"), b"loader");
2111
2112    let tracker = ModFileTracker {
2113      mod_name: "Author-Loader".to_string(),
2114      files: vec![(
2115        src.to_string_lossy().to_string(),
2116        "BepInEx/core/loader.dll".to_string(),
2117      )],
2118    };
2119    write_state_file(root, &tracker).unwrap();
2120
2121    disable_mod(&profile, root, "Author-Loader").unwrap();
2122
2123    assert!(!root.join("BepInEx/core/loader.dll").exists());
2124
2125    enable_mod(&profile, root, "Author-Loader").unwrap();
2126
2127    assert!(root.join("BepInEx/core/loader.dll").exists());
2128  }
2129}