1#![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#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(rename_all = "camelCase")]
38#[non_exhaustive]
39pub struct ModFileTracker {
40 pub mod_name: String,
42 pub files: Vec<(String, String)>,
44}
45
46pub 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
71pub 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
78pub 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
93pub 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
109pub 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
130fn 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#[derive(Debug, Clone, PartialEq, Eq)]
154#[non_exhaustive]
155pub struct InstallEntry {
156 pub source: PathBuf,
159 pub dest_relative: PathBuf,
161 pub tracking: TrackingMethod,
164}
165
166#[derive(Debug, Clone, Default)]
168#[non_exhaustive]
169pub struct InstallPlan {
170 pub entries: Vec<InstallEntry>,
172}
173
174#[derive(Debug, Clone, Default)]
176#[non_exhaustive]
177pub struct FileManifest {
178 pub written: Vec<PathBuf>,
180}
181
182pub 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
202fn 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
248fn 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
283fn 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 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#[derive(Debug, Clone, PartialEq, Eq)]
329enum RouteOutcome {
330 Route {
333 dest_relative: PathBuf,
334 tracking: TrackingMethod,
335 },
336 Skip(SkipReason),
338}
339
340#[derive(Debug, Clone, PartialEq, Eq)]
342enum SkipReason {
343 NoName,
345 Excluded,
347 NoRoute,
349 UnsupportedTracking(TrackingMethod),
352}
353
354fn 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
408fn 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
422fn 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
446struct FlatRule<'a> {
448 full_route: PathBuf,
449 rule: &'a InstallRule,
450}
451
452#[derive(Debug, Clone, PartialEq, Eq)]
455struct RouteResolution {
456 route: PathBuf,
457 tracking: TrackingMethod,
458 remainder: PathBuf,
459}
460
461fn 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
478fn 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 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
526fn 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
542fn 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
554fn 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
572fn build_dest(
577 route: &Path,
578 tracking: TrackingMethod,
579 ident: &str,
580 remainder: &Path,
581) -> Option<PathBuf> {
582 match tracking {
583 TrackingMethod::Subdir => remainder
587 .file_name()
588 .map(|name| route.join(ident).join(name)),
589 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
596fn route_leaf(route: &str) -> &str {
599 route.rsplit(['/', '\\']).next().unwrap_or(route)
600}
601
602fn 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
613fn 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
629fn 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
653pub 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 ApplyAction::SkipExistingConfig => continue,
697 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
729enum ApplyAction {
730 Write { record: bool },
733 SkipExistingConfig,
735 RefuseSymlink,
737}
738
739fn 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
756fn 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
788fn 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
813fn 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
841pub 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
905pub 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
928fn 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
955fn 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
966fn 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(¤t)
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 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
1002pub 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
1023pub 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
1034pub 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
1045fn 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
1079fn 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
1103fn 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
1125fn 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 assert!(dests.contains(&"BepInEx/plugins/Author-MyMod/manifest.json".to_string()));
1199 assert!(dests.contains(&"BepInEx/config/MyMod.cfg".to_string()));
1201 assert!(dests.contains(&"BepInEx/patchers/Author-MyMod/MyPatcher.dll".to_string()));
1203 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 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 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 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 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 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 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 let listing = vec![
1306 (PathBuf::from("Author-Keep"), true, false), (PathBuf::from("Author-Drop"), true, false), (PathBuf::from("MMHOOK"), true, false), (PathBuf::from("Author-Link"), true, true), (PathBuf::from("Author-File"), false, false), ];
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 assert_eq!(
1325 classify_loader_file(Path::new("manifest.json"), &untracked, &exclusions).unwrap(),
1326 None
1327 );
1328
1329 assert_eq!(
1331 classify_loader_file(Path::new("LICENCE"), &untracked, &exclusions).unwrap(),
1332 None
1333 );
1334
1335 assert_eq!(
1337 classify_loader_file(Path::new("BepInEx/config/x.cfg"), &untracked, &exclusions).unwrap(),
1338 Some(TrackingMethod::None)
1339 );
1340
1341 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 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 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 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 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 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 let profile = profile(&eco, "boneworks");
1514
1515 let pkg = tempdir().unwrap();
1516 let root = pkg.path();
1517
1518 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 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 assert!(result.is_err());
1624 assert!(!root.join("BepInEx/plugins/Author-Mod/good.dll").exists());
1625 assert!(!root.join("BepInEx/plugins/Author-Mod").exists());
1628 assert!(!root.join("BepInEx/plugins").exists());
1629 assert!(!root.join("BepInEx").exists());
1630 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 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 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 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 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 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 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 assert_eq!(
1754 planned,
1755 vec![
1756 PathBuf::from("BepInEx/plugins/Owner-ModB"),
1757 PathBuf::from("BepInEx/plugins/Owner-ModC"),
1758 ]
1759 );
1760 assert!(root.join("BepInEx/plugins/Owner-ModB/B.dll").exists());
1762 assert!(root.join("BepInEx/plugins/Owner-ModC/C.dll").exists());
1763
1764 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 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 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 assert!(dests.contains(&"Mods/MyMod.dll".to_string()));
1812 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 write(&root.join("cool.melon"), b"item");
1844 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 assert!(dests.contains(&"R2Northstar/mods/Author.Mod/mod.json".to_string()));
1870 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 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 (
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 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 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 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 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 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}