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::{BTreeMap, 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 plan_is_on_disk(plan: &InstallPlan, root: &Path, mod_name: &str, enabled: bool) -> bool {
83 let has_tracker = state_file_path(root, mod_name).exists();
84
85 plan.entries.iter().all(|entry| {
86 let dest = root.join(&entry.dest_relative);
87
88 match entry.tracking {
89 TrackingMethod::None => true,
90 TrackingMethod::State => has_tracker && (!enabled || dest.exists()),
91 tracking if tracking.is_namespaced() => {
92 let already_old = dest.to_string_lossy().to_lowercase().ends_with(".old");
93
94 if enabled || already_old {
95 return dest.exists();
96 }
97
98 let mut disabled = dest.clone().into_os_string();
99
100 disabled.push(".old");
101
102 Path::new(&disabled).exists()
103 }
104 _ => dest.exists(),
105 }
106 })
107}
108
109pub fn state_file_path(profile_dir: &Path, mod_name: &str) -> PathBuf {
111 profile_dir
112 .join("_state")
113 .join(format!("{mod_name}-state.yml"))
114}
115
116pub fn write_state_file(profile_dir: &Path, tracker: &ModFileTracker) -> Result<()> {
118 let path = state_file_path(profile_dir, &tracker.mod_name);
119
120 if let Some(parent) = path.parent() {
121 fs::create_dir_all(parent)
122 .map_err(|e| io_context(format!("creating {}", parent.display()), e))?;
123 }
124
125 let text = serde_yaml_ng::to_string(tracker)
126 .map_err(|e| Error::Install(format!("serializing state file: {e}")))?;
127
128 fs::write(&path, text).map_err(|e| io_context(format!("writing {}", path.display()), e))
129}
130
131pub fn read_state_file(profile_dir: &Path, mod_name: &str) -> Result<Option<ModFileTracker>> {
133 let path = state_file_path(profile_dir, mod_name);
134
135 if !path.exists() {
136 return Ok(None);
137 }
138
139 let text =
140 fs::read_to_string(&path).map_err(|e| io_context(format!("reading {}", path.display()), e))?;
141 let tracker = serde_yaml_ng::from_str(&text)
142 .map_err(|e| Error::Install(format!("parsing {}: {e}", path.display())))?;
143
144 Ok(Some(tracker))
145}
146
147pub fn remove_state_payload(profile_dir: &Path, mod_name: &str) -> Result<()> {
156 clear_state_files(profile_dir, mod_name)?;
157
158 remove_state_file(profile_dir, mod_name)
159}
160
161pub fn clear_state_files(profile_dir: &Path, mod_name: &str) -> Result<()> {
166 match read_state_file(profile_dir, mod_name)? {
167 Some(tracker) => remove_state_files(profile_dir, &tracker),
168 None => Ok(()),
169 }
170}
171
172pub fn remove_state_file(profile_dir: &Path, mod_name: &str) -> Result<()> {
175 let path = state_file_path(profile_dir, mod_name);
176
177 if !path.exists() {
178 return Ok(());
179 }
180
181 fs::remove_file(&path).map_err(|e| io_context(format!("removing {}", path.display()), e))
182}
183
184fn ensure_safe_relative(dest: &Path) -> Result<()> {
191 for component in dest.components() {
192 match component {
193 Component::Normal(_) | Component::CurDir => {}
194 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
195 return Err(Error::Install(format!(
196 "unsafe install path escapes the install root: {}",
197 dest.display()
198 )));
199 }
200 }
201 }
202
203 Ok(())
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
208#[non_exhaustive]
209pub struct InstallEntry {
210 pub source: PathBuf,
213 pub dest_relative: PathBuf,
215 pub tracking: TrackingMethod,
218}
219
220#[derive(Debug, Clone, Default)]
222#[non_exhaustive]
223pub struct InstallPlan {
224 pub entries: Vec<InstallEntry>,
226}
227
228#[derive(Debug, Clone, Default)]
230#[non_exhaustive]
231pub struct FileManifest {
232 pub written: Vec<PathBuf>,
234}
235
236pub fn plan_install(
243 ecosystem: &Ecosystem,
244 profile: &GameProfile,
245 package_full_name: &str,
246 extracted_root: &Path,
247 archive_path: Option<&Path>,
248) -> Result<InstallPlan> {
249 if let Some(pack) = ecosystem.modloader_package(package_full_name) {
250 return plan_loader_pack(pack, profile, extracted_root);
251 }
252
253 plan_regular_mod(profile, package_full_name, extracted_root, archive_path)
254}
255
256fn plan_loader_pack(
265 pack: &ModloaderPackage,
266 profile: &GameProfile,
267 extracted_root: &Path,
268) -> Result<InstallPlan> {
269 let base = match extracted_root.join(&pack.root_folder) {
270 nested if !pack.root_folder.is_empty() && nested.is_dir() => nested,
271 _ => extracted_root.to_path_buf(),
272 };
273
274 let mut untracked = Vec::new();
275
276 collect_routes(
277 &profile.install_rules,
278 Path::new(""),
279 &|rule| rule.tracking_method == TrackingMethod::None,
280 &mut untracked,
281 );
282
283 let mut entries = Vec::new();
284
285 for file in walk_files(&base)? {
286 let rel = file.strip_prefix(&base).unwrap_or(&file).to_path_buf();
287
288 if let Some(tracking) =
289 classify_loader_file(&rel, &untracked, &profile.relative_file_exclusions)?
290 {
291 entries.push(InstallEntry {
292 source: file,
293 dest_relative: rel,
294 tracking,
295 });
296 }
297 }
298
299 Ok(InstallPlan { entries })
300}
301
302fn classify_loader_file(
312 rel: &Path,
313 untracked: &[PathBuf],
314 exclusions: &[String],
315) -> Result<Option<TrackingMethod>> {
316 if is_package_metadata(rel) {
317 return Ok(None);
318 }
319
320 if let Some(name) = rel.file_name().and_then(|n| n.to_str())
321 && exclusions.iter().any(|e| e == name)
322 {
323 return Ok(None);
324 }
325
326 ensure_safe_relative(rel)?;
327
328 let tracking = if untracked.iter().any(|route| rel.starts_with(route)) {
329 TrackingMethod::None
330 } else {
331 TrackingMethod::State
332 };
333
334 Ok(Some(tracking))
335}
336
337fn plan_regular_mod(
339 profile: &GameProfile,
340 ident: &str,
341 extracted_root: &Path,
342 archive_path: Option<&Path>,
343) -> Result<InstallPlan> {
344 let mut flat = Vec::new();
345
346 flatten_rules(&profile.install_rules, Path::new(""), &mut flat);
347
348 if let Some(rule) = flat
351 .iter()
352 .find(|r| r.rule.is_default_location && r.rule.tracking_method == TrackingMethod::PackageZip)
353 {
354 return plan_package_zip(&rule.full_route, ident, archive_path);
355 }
356
357 let mut entries = Vec::new();
358 let mut claimed: BTreeMap<PathBuf, PathBuf> = BTreeMap::new();
359
360 for file in walk_files(extracted_root)? {
361 let rel = file
362 .strip_prefix(extracted_root)
363 .unwrap_or(&file)
364 .to_path_buf();
365
366 match route_file(profile, &flat, ident, &rel)? {
367 RouteOutcome::Route {
368 dest_relative,
369 tracking,
370 } => {
371 if let Some(earlier) = claimed.insert(dest_relative.clone(), rel.clone()) {
372 return Err(colliding_files(&earlier, &rel, &dest_relative));
373 }
374
375 entries.push(InstallEntry {
376 source: file,
377 dest_relative,
378 tracking,
379 });
380 }
381 RouteOutcome::Skip(reason) => log_route_skip(&rel, reason),
382 }
383 }
384
385 Ok(InstallPlan { entries })
386}
387
388fn colliding_files(first: &Path, second: &Path, dest: &Path) -> Error {
396 let show = |path: &Path| path.to_string_lossy().replace('\\', "/");
397
398 Error::Install(format!(
399 "package files {} and {} would both install to {}; files outside an override \
400 folder such as plugins/ are flattened to their name, so the package must ship \
401 them under one to keep them apart",
402 show(first),
403 show(second),
404 show(dest)
405 ))
406}
407
408#[derive(Debug, Clone, PartialEq, Eq)]
410enum RouteOutcome {
411 Route {
414 dest_relative: PathBuf,
415 tracking: TrackingMethod,
416 },
417 Skip(SkipReason),
419}
420
421#[derive(Debug, Clone, PartialEq, Eq)]
423enum SkipReason {
424 NoName,
426 Excluded,
428 NoRoute,
430 UnsupportedTracking(TrackingMethod),
433}
434
435fn route_file(
445 profile: &GameProfile,
446 flat: &[FlatRule<'_>],
447 ident: &str,
448 rel: &Path,
449) -> Result<RouteOutcome> {
450 let name = match rel.file_name().and_then(|n| n.to_str()) {
451 Some(n) => n,
452 None => return Ok(RouteOutcome::Skip(SkipReason::NoName)),
453 };
454
455 if profile.relative_file_exclusions.iter().any(|e| e == name) {
456 return Ok(RouteOutcome::Skip(SkipReason::Excluded));
457 }
458
459 let resolution = resolve_override(&profile.install_rules, rel)
460 .or_else(|| loose_file_route(flat, name))
461 .or_else(|| default_route(flat, rel, name));
462
463 let RouteResolution {
464 route,
465 tracking,
466 remainder,
467 } = match resolution {
468 Some(resolved) => resolved,
469 None => return Ok(RouteOutcome::Skip(SkipReason::NoRoute)),
470 };
471
472 let dest = match build_dest(&route, tracking, ident, &remainder) {
473 Some(dest) => dest,
474 None => {
475 return Ok(RouteOutcome::Skip(SkipReason::UnsupportedTracking(
476 tracking,
477 )));
478 }
479 };
480
481 ensure_safe_relative(&dest)?;
482
483 Ok(RouteOutcome::Route {
484 dest_relative: dest,
485 tracking,
486 })
487}
488
489fn log_route_skip(rel: &Path, reason: SkipReason) {
492 match reason {
493 SkipReason::NoRoute => tracing::warn!("no install route for {}; skipping", rel.display()),
494 SkipReason::UnsupportedTracking(tracking) => tracing::warn!(
495 "unsupported tracking {:?} for {}; skipping",
496 tracking,
497 rel.display()
498 ),
499 SkipReason::Excluded | SkipReason::NoName => {}
500 }
501}
502
503fn plan_package_zip(route: &Path, ident: &str, archive_path: Option<&Path>) -> Result<InstallPlan> {
505 let archive = match archive_path {
506 Some(archive) => archive,
507 None => {
508 tracing::warn!("package-zip loader requires the original archive; skipping install");
509
510 return Ok(InstallPlan::default());
511 }
512 };
513
514 let dest = route.join(format!("{ident}.zip"));
515
516 ensure_safe_relative(&dest)?;
517
518 Ok(InstallPlan {
519 entries: vec![InstallEntry {
520 source: archive.to_path_buf(),
521 dest_relative: dest,
522 tracking: TrackingMethod::PackageZip,
523 }],
524 })
525}
526
527struct FlatRule<'a> {
529 full_route: PathBuf,
530 rule: &'a InstallRule,
531}
532
533#[derive(Debug, Clone, PartialEq, Eq)]
536struct RouteResolution {
537 route: PathBuf,
538 tracking: TrackingMethod,
539 remainder: PathBuf,
540}
541
542fn flatten_rules<'a>(rules: &'a [InstallRule], parent: &Path, out: &mut Vec<FlatRule<'a>>) {
545 for rule in rules {
546 let full = join_route(parent, &rule.route);
547
548 out.push(FlatRule {
549 full_route: full.clone(),
550 rule,
551 });
552
553 if !rule.sub_routes.is_empty() {
554 flatten_rules(&rule.sub_routes, &full, out);
555 }
556 }
557}
558
559fn resolve_override(rules: &[InstallRule], rel: &Path) -> Option<RouteResolution> {
565 let mut current = rel;
566
567 loop {
568 if let Some(found) = resolve_dir(rules, Path::new(""), current) {
569 return Some(found);
570 }
571
572 let mut comps = current.components();
573
574 comps.next()?;
575 current = comps.as_path();
576 }
577}
578
579fn resolve_dir(rules: &[InstallRule], parent_full: &Path, rel: &Path) -> Option<RouteResolution> {
587 let mut comps = rel.components();
588 let first = comps.next()?.as_os_str().to_str()?;
589 let rest = comps.as_path();
590
591 rest.components().next()?;
593
594 for rule in rules {
595 if !route_leaf(&rule.route).eq_ignore_ascii_case(first) {
596 continue;
597 }
598
599 let full = join_route(parent_full, &rule.route);
600
601 if !rule.sub_routes.is_empty() {
602 if let Some(found) = resolve_dir(&rule.sub_routes, &full, rest) {
603 return Some(found);
604 }
605
606 if rest.components().count() == 1
608 && let Some(name) = rest.file_name().and_then(|n| n.to_str())
609 {
610 let mut sub_flat = Vec::new();
611
612 flatten_rules(&rule.sub_routes, &full, &mut sub_flat);
613
614 if let Some(matched) = best_extension(&sub_flat, name) {
615 return Some(RouteResolution {
616 route: matched.full_route.clone(),
617 tracking: matched.rule.tracking_method,
618 remainder: PathBuf::from(name),
619 });
620 }
621 }
622 }
623
624 return Some(RouteResolution {
625 route: full,
626 tracking: rule.tracking_method,
627 remainder: rest.to_path_buf(),
628 });
629 }
630
631 None
632}
633
634fn loose_file_route(flat: &[FlatRule<'_>], name: &str) -> Option<RouteResolution> {
641 let matched = best_extension(flat, name)?;
642
643 Some(RouteResolution {
644 route: matched.full_route.clone(),
645 tracking: matched.rule.tracking_method,
646 remainder: PathBuf::from(name),
647 })
648}
649
650fn default_route(flat: &[FlatRule<'_>], rel: &Path, name: &str) -> Option<RouteResolution> {
656 let matched = flat.iter().find(|r| r.rule.is_default_location)?;
657 let tracking = matched.rule.tracking_method;
658
659 let remainder = match tracking {
660 TrackingMethod::Subdir => PathBuf::from(name),
661 _ => rel.to_path_buf(),
662 };
663
664 Some(RouteResolution {
665 route: matched.full_route.clone(),
666 tracking,
667 remainder,
668 })
669}
670
671fn best_extension<'a>(flat: &'a [FlatRule<'a>], name: &str) -> Option<&'a FlatRule<'a>> {
674 flat
675 .iter()
676 .filter_map(|fr| {
677 fr.rule
678 .default_file_extensions
679 .iter()
680 .filter(|ext| name.ends_with(ext.as_str()))
681 .map(|ext| ext.len())
682 .max()
683 .map(|len| (len, fr))
684 })
685 .max_by_key(|(len, _)| *len)
686 .map(|(_, fr)| fr)
687}
688
689fn build_dest(
693 route: &Path,
694 tracking: TrackingMethod,
695 ident: &str,
696 remainder: &Path,
697) -> Option<PathBuf> {
698 match tracking {
699 TrackingMethod::Subdir | TrackingMethod::SubdirNoFlatten => {
703 Some(route.join(ident).join(remainder))
704 }
705 TrackingMethod::None | TrackingMethod::State => Some(route.join(remainder)),
706 TrackingMethod::PackageZip | TrackingMethod::Other => None,
707 }
708}
709
710fn route_leaf(route: &str) -> &str {
713 route.rsplit(['/', '\\']).next().unwrap_or(route)
714}
715
716fn join_route(parent_full: &Path, child_route: &str) -> PathBuf {
718 let child = PathBuf::from(child_route);
719
720 if parent_full.as_os_str().is_empty() {
721 child
722 } else {
723 parent_full.join(child)
724 }
725}
726
727fn is_package_metadata(rel: &Path) -> bool {
729 if rel.components().count() != 1 {
730 return false;
731 }
732
733 match rel.file_name().and_then(|n| n.to_str()) {
734 Some(name) => {
735 let lower = name.to_ascii_lowercase();
736
737 lower == "manifest.json" || lower == "icon.png" || lower == "readme.md"
738 }
739 None => false,
740 }
741}
742
743fn collect_routes<F: Fn(&InstallRule) -> bool>(
749 rules: &[InstallRule],
750 parent: &Path,
751 pred: &F,
752 out: &mut Vec<PathBuf>,
753) {
754 for rule in rules {
755 let full = join_route(parent, &rule.route);
756
757 if pred(rule) {
758 out.push(full.clone());
759 }
760
761 if !rule.sub_routes.is_empty() {
762 collect_routes(&rule.sub_routes, &full, pred, out);
763 }
764 }
765}
766
767pub fn apply_install(plan: &InstallPlan, root: &Path) -> Result<FileManifest> {
790 let mut written = Vec::with_capacity(plan.entries.len());
791 let mut created: Vec<PathBuf> = Vec::new();
792 let mut created_dirs: Vec<PathBuf> = Vec::new();
793
794 for entry in &plan.entries {
795 if let Err(err) = ensure_safe_relative(&entry.dest_relative) {
796 rollback_partial_install(&created, &created_dirs);
797
798 return Err(err);
799 }
800
801 let dest = root.join(&entry.dest_relative);
802
803 let dest_is_symlink = fs::symlink_metadata(&dest)
804 .map(|metadata| metadata.file_type().is_symlink())
805 .unwrap_or(false);
806
807 match classify_entry(entry.tracking, dest.exists(), dest_is_symlink) {
808 ApplyAction::SkipExistingConfig => continue,
811 ApplyAction::RefuseSymlink => {
814 rollback_partial_install(&created, &created_dirs);
815
816 return Err(Error::Install(format!(
817 "refusing to install through a symlink at {}",
818 dest.display()
819 )));
820 }
821 ApplyAction::Write { record } => {
822 if let Err(err) = copy_entry(entry, &dest, root, &mut created_dirs) {
823 rollback_partial_install(&created, &created_dirs);
824
825 return Err(err);
826 }
827
828 created.push(dest);
829
830 if record {
831 written.push(entry.dest_relative.clone());
832 }
833 }
834 }
835 }
836
837 Ok(FileManifest { written })
838}
839
840#[derive(Debug, Clone, Copy, PartialEq, Eq)]
843enum ApplyAction {
844 Write { record: bool },
847 SkipExistingConfig,
849 RefuseSymlink,
851}
852
853fn classify_entry(tracking: TrackingMethod, exists: bool, is_symlink: bool) -> ApplyAction {
857 if tracking == TrackingMethod::None && (exists || is_symlink) {
858 return ApplyAction::SkipExistingConfig;
859 }
860
861 if is_symlink {
862 return ApplyAction::RefuseSymlink;
863 }
864
865 ApplyAction::Write {
866 record: tracking != TrackingMethod::None,
867 }
868}
869
870fn copy_entry(
873 entry: &InstallEntry,
874 dest: &Path,
875 root: &Path,
876 created_dirs: &mut Vec<PathBuf>,
877) -> Result<()> {
878 if let Some(parent) = dest.parent() {
879 record_created_dirs(parent, root, created_dirs);
880
881 fs::create_dir_all(parent).map_err(|e| {
882 Error::Install(format!(
883 "failed to create install directory {}: {}",
884 parent.display(),
885 e
886 ))
887 })?;
888 }
889
890 fs::copy(&entry.source, dest).map_err(|e| {
891 Error::Install(format!(
892 "failed to copy {} to {}: {}",
893 entry.source.display(),
894 dest.display(),
895 e
896 ))
897 })?;
898
899 Ok(())
900}
901
902fn record_created_dirs(dir: &Path, root: &Path, created_dirs: &mut Vec<PathBuf>) {
910 let mut missing = Vec::new();
911 let mut current = dir;
912
913 while current != root && current.starts_with(root) && !current.exists() {
914 missing.push(current.to_path_buf());
915
916 match current.parent() {
917 Some(parent) => current = parent,
918 None => break,
919 }
920 }
921
922 for path in missing.into_iter().rev() {
923 created_dirs.push(path);
924 }
925}
926
927fn rollback_partial_install(created: &[PathBuf], created_dirs: &[PathBuf]) {
934 for path in created.iter().rev() {
935 if let Err(err) = fs::remove_file(path) {
936 tracing::warn!(
937 "failed to roll back partially-installed file {}: {}",
938 path.display(),
939 err
940 );
941 }
942 }
943
944 for dir in created_dirs.iter().rev() {
945 if let Err(err) = fs::remove_dir(dir) {
946 tracing::warn!(
947 "failed to roll back partially-created directory {}: {}",
948 dir.display(),
949 err
950 );
951 }
952 }
953}
954
955pub fn plan_delisted(
973 profile: &GameProfile,
974 root: &Path,
975 keep: &BTreeSet<String>,
976) -> Result<Vec<PathBuf>> {
977 let mut routes = Vec::new();
978
979 collect_routes(
980 &profile.install_rules,
981 Path::new(""),
982 &|rule| rule.tracking_method.is_namespaced(),
983 &mut routes,
984 );
985
986 let mut planned = BTreeSet::new();
987
988 for route in routes {
989 ensure_safe_relative(&route)?;
990
991 let dir = root.join(&route);
992
993 if !dir.is_dir() {
994 continue;
995 }
996
997 let mut listing = Vec::new();
998
999 for entry in fs::read_dir(&dir)
1000 .map_err(|e| io_context(format!("reading install route {}", dir.display()), e))?
1001 {
1002 let path = entry
1003 .map_err(|e| io_context(format!("reading an entry under {}", dir.display()), e))?
1004 .path();
1005 let metadata = fs::symlink_metadata(&path)
1006 .map_err(|e| io_context(format!("inspecting {}", path.display()), e))?;
1007
1008 listing.push((path, metadata.is_dir(), metadata.file_type().is_symlink()));
1009 }
1010
1011 for path in plan_reconcile(&listing, keep) {
1012 planned.insert(path.strip_prefix(root).unwrap_or(&path).to_path_buf());
1013 }
1014 }
1015
1016 Ok(planned.into_iter().collect())
1017}
1018
1019pub fn remove_delisted(
1026 profile: &GameProfile,
1027 root: &Path,
1028 keep: &BTreeSet<String>,
1029) -> Result<Vec<PathBuf>> {
1030 let planned = plan_delisted(profile, root, keep)?;
1031
1032 for relative in &planned {
1033 let path = root.join(relative);
1034
1035 fs::remove_dir_all(&path)
1036 .map_err(|e| io_context(format!("removing delisted mod {}", path.display()), e))?;
1037 }
1038
1039 Ok(planned)
1040}
1041
1042pub fn remove_mod_folders(profile: &GameProfile, root: &Path, ident: &str) -> Result<()> {
1050 let mut components = Path::new(ident).components();
1051
1052 if !matches!(
1053 (components.next(), components.next()),
1054 (Some(Component::Normal(_)), None)
1055 ) {
1056 return Err(Error::Install(format!(
1057 "refusing to remove folders for unsafe mod name {ident:?}"
1058 )));
1059 }
1060
1061 let mut routes = Vec::new();
1062
1063 collect_routes(
1064 &profile.install_rules,
1065 Path::new(""),
1066 &|rule| rule.tracking_method.is_namespaced(),
1067 &mut routes,
1068 );
1069
1070 for route in routes {
1071 let relative = route.join(ident);
1072
1073 ensure_safe_relative(&relative)?;
1074
1075 let path = root.join(&relative);
1076
1077 let is_real_dir = match fs::symlink_metadata(&path) {
1078 Ok(metadata) => metadata.is_dir(),
1079 Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
1080 Err(e) => return Err(io_context(format!("inspecting {}", path.display()), e)),
1081 };
1082
1083 if is_real_dir {
1084 fs::remove_dir_all(&path)
1085 .map_err(|e| io_context(format!("removing mod folder {}", path.display()), e))?;
1086 }
1087 }
1088
1089 Ok(())
1090}
1091
1092fn plan_reconcile(listing: &[(PathBuf, bool, bool)], keep: &BTreeSet<String>) -> Vec<PathBuf> {
1103 listing
1104 .iter()
1105 .filter(|(path, is_dir, is_symlink)| {
1106 if *is_symlink || !*is_dir {
1107 return false;
1108 }
1109
1110 match path.file_name().and_then(|n| n.to_str()) {
1111 Some(ident) => looks_like_mod_ident(ident) && !keep.contains(ident),
1112 None => false,
1113 }
1114 })
1115 .map(|(path, _, _)| path.clone())
1116 .collect()
1117}
1118
1119fn looks_like_mod_ident(name: &str) -> bool {
1124 name.contains('-')
1125 && name
1126 .chars()
1127 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1128}
1129
1130fn walk_files(dir: &Path) -> Result<Vec<PathBuf>> {
1132 let mut files = Vec::new();
1133 let mut stack = vec![dir.to_path_buf()];
1134
1135 while let Some(current) = stack.pop() {
1136 for entry in fs::read_dir(¤t)
1137 .map_err(|e| io_context(format!("reading package dir {}", current.display()), e))?
1138 {
1139 let entry = entry
1140 .map_err(|e| io_context(format!("reading an entry under {}", current.display()), e))?;
1141 let path = entry.path();
1142 let metadata = fs::symlink_metadata(&path)
1143 .map_err(|e| io_context(format!("inspecting {}", path.display()), e))?;
1144
1145 if metadata.file_type().is_symlink() {
1146 tracing::warn!("Skipping symlink in package contents: {}", path.display());
1149
1150 continue;
1151 }
1152
1153 if metadata.is_dir() {
1154 stack.push(path);
1155 } else {
1156 files.push(path);
1157 }
1158 }
1159 }
1160
1161 files.sort();
1162
1163 Ok(files)
1164}
1165
1166pub fn plan_suffix_renames(files: &[PathBuf], disable: bool) -> Vec<(PathBuf, PathBuf)> {
1171 let mut renames = Vec::new();
1172
1173 for file in files {
1174 let name = file.to_string_lossy();
1175 let is_old = name.to_lowercase().ends_with(".old");
1176
1177 if disable && !is_old {
1178 renames.push((file.clone(), PathBuf::from(format!("{name}.old"))));
1179 } else if !disable && is_old {
1180 renames.push((file.clone(), PathBuf::from(&name[..name.len() - 4])));
1181 }
1182 }
1183
1184 renames
1185}
1186
1187pub fn disable_mod(profile: &GameProfile, profile_dir: &Path, mod_name: &str) -> Result<()> {
1191 let tracker = read_state_file(profile_dir, mod_name)?;
1192
1193 toggle_subdir_files(profile, profile_dir, mod_name, true)?;
1194
1195 toggle_state_files(profile_dir, tracker, true)
1196}
1197
1198pub fn enable_mod(profile: &GameProfile, profile_dir: &Path, mod_name: &str) -> Result<()> {
1202 let tracker = read_state_file(profile_dir, mod_name)?;
1203
1204 toggle_subdir_files(profile, profile_dir, mod_name, false)?;
1205
1206 toggle_state_files(profile_dir, tracker, false)
1207}
1208
1209fn toggle_subdir_files(
1212 profile: &GameProfile,
1213 profile_dir: &Path,
1214 mod_name: &str,
1215 disable: bool,
1216) -> Result<()> {
1217 let mut routes = Vec::new();
1218
1219 collect_routes(
1220 &profile.install_rules,
1221 Path::new(""),
1222 &|rule| rule.tracking_method.is_namespaced(),
1223 &mut routes,
1224 );
1225
1226 for route in routes {
1227 let mod_dir = profile_dir.join(&route).join(mod_name);
1228
1229 if !mod_dir.is_dir() {
1230 continue;
1231 }
1232
1233 let files = walk_files(&mod_dir)?;
1234
1235 for (from, to) in plan_suffix_renames(&files, disable) {
1236 fs::rename(&from, &to).map_err(|e| io_context(format!("toggling {}", from.display()), e))?;
1237 }
1238 }
1239
1240 Ok(())
1241}
1242
1243fn toggle_state_files(
1252 profile_dir: &Path,
1253 tracker: Option<ModFileTracker>,
1254 disable: bool,
1255) -> Result<()> {
1256 let Some(tracker) = tracker else {
1257 return Ok(());
1258 };
1259
1260 if disable {
1261 return remove_state_files(profile_dir, &tracker);
1262 }
1263
1264 restore_state_files(profile_dir, &tracker)
1265}
1266
1267fn remove_state_files(profile_dir: &Path, tracker: &ModFileTracker) -> Result<()> {
1274 for (_, install_rel) in &tracker.files {
1275 ensure_safe_relative(Path::new(install_rel))?;
1276
1277 let dest = profile_dir.join(install_rel);
1278
1279 if !dest.exists() {
1280 continue;
1281 }
1282
1283 fs::remove_file(&dest).map_err(|e| io_context(format!("removing {}", dest.display()), e))?;
1284 }
1285
1286 Ok(())
1287}
1288
1289fn restore_state_files(profile_dir: &Path, tracker: &ModFileTracker) -> Result<()> {
1292 for (source, install_rel) in &tracker.files {
1293 let dest = profile_dir.join(install_rel);
1294
1295 if let Some(parent) = dest.parent() {
1296 fs::create_dir_all(parent)
1297 .map_err(|e| io_context(format!("creating {}", parent.display()), e))?;
1298 }
1299
1300 fs::copy(source, &dest)
1301 .map_err(|e| io_context(format!("restoring {} from {}", dest.display(), source), e))?;
1302 }
1303
1304 Ok(())
1305}
1306
1307#[cfg(test)]
1308mod tests {
1309 use super::*;
1310 use crate::ecosystem::{Ecosystem, GameProfile};
1311 use std::fs::File;
1312 use std::io::Write;
1313 use tempfile::tempdir;
1314
1315 fn eco() -> Ecosystem {
1316 Ecosystem::bundled()
1317 }
1318
1319 fn profile(eco: &Ecosystem, slug: &str) -> GameProfile {
1320 eco.game(slug).unwrap().profile().unwrap().clone()
1321 }
1322
1323 fn write(path: &Path, contents: &[u8]) {
1324 if let Some(parent) = path.parent() {
1325 fs::create_dir_all(parent).unwrap();
1326 }
1327
1328 let mut f = File::create(path).unwrap();
1329 f.write_all(contents).unwrap();
1330 }
1331
1332 fn dest_set(plan: &InstallPlan) -> Vec<String> {
1333 let mut v: Vec<String> = plan
1334 .entries
1335 .iter()
1336 .map(|e| e.dest_relative.to_string_lossy().replace('\\', "/"))
1337 .collect();
1338 v.sort();
1339
1340 v
1341 }
1342
1343 #[test]
1344 fn test_bepinex_regular_mod_routing() {
1345 let eco = eco();
1346 let profile = profile(&eco, "valheim");
1347
1348 let pkg = tempdir().unwrap();
1349 let root = pkg.path();
1350
1351 write(&root.join("MyMod.dll"), b"dll");
1352 write(&root.join("manifest.json"), b"{}");
1353 write(&root.join("config/MyMod.cfg"), b"cfg");
1354 write(&root.join("patchers/MyPatcher.dll"), b"patch");
1355 write(&root.join("MyHook.mm.dll"), b"hook");
1356
1357 let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
1358 let dests = dest_set(&plan);
1359
1360 assert!(dests.contains(&"BepInEx/plugins/Author-MyMod/MyMod.dll".to_string()));
1361 assert!(dests.contains(&"BepInEx/plugins/Author-MyMod/manifest.json".to_string()));
1363 assert!(dests.contains(&"BepInEx/config/MyMod.cfg".to_string()));
1365 assert!(dests.contains(&"BepInEx/patchers/Author-MyMod/MyPatcher.dll".to_string()));
1367 assert!(dests.contains(&"BepInEx/monomod/Author-MyMod/MyHook.mm.dll".to_string()));
1369 }
1370
1371 #[test]
1372 fn route_file_routes_by_table_without_disk() {
1373 let eco = eco();
1374 let profile = profile(&eco, "valheim");
1375
1376 let mut flat = Vec::new();
1377
1378 flatten_rules(&profile.install_rules, Path::new(""), &mut flat);
1379
1380 let ident = "Author-MyMod";
1381
1382 match route_file(&profile, &flat, ident, Path::new("MyMod.dll")).unwrap() {
1384 RouteOutcome::Route {
1385 dest_relative,
1386 tracking,
1387 } => {
1388 assert_eq!(
1389 dest_relative,
1390 PathBuf::from("BepInEx/plugins/Author-MyMod/MyMod.dll")
1391 );
1392 assert_eq!(tracking, TrackingMethod::Subdir);
1393 }
1394 other => panic!("expected a route, got {other:?}"),
1395 }
1396
1397 match route_file(&profile, &flat, ident, Path::new("MyHook.mm.dll")).unwrap() {
1399 RouteOutcome::Route { dest_relative, .. } => assert_eq!(
1400 dest_relative,
1401 PathBuf::from("BepInEx/monomod/Author-MyMod/MyHook.mm.dll")
1402 ),
1403 other => panic!("expected a route, got {other:?}"),
1404 }
1405
1406 match route_file(&profile, &flat, ident, Path::new("config/MyMod.cfg")).unwrap() {
1408 RouteOutcome::Route {
1409 dest_relative,
1410 tracking,
1411 } => {
1412 assert_eq!(dest_relative, PathBuf::from("BepInEx/config/MyMod.cfg"));
1413 assert_eq!(tracking, TrackingMethod::None);
1414 }
1415 other => panic!("expected a route, got {other:?}"),
1416 }
1417
1418 assert_eq!(
1420 route_file(&profile, &flat, ident, Path::new("")).unwrap(),
1421 RouteOutcome::Skip(SkipReason::NoName)
1422 );
1423 }
1424
1425 #[test]
1426 fn route_leaf_handles_both_separators() {
1427 assert_eq!(route_leaf("BepInEx/plugins"), "plugins");
1428 assert_eq!(route_leaf("BepInEx\\plugins"), "plugins");
1429 assert_eq!(route_leaf("plugins"), "plugins");
1430 }
1431
1432 #[test]
1433 fn classify_entry_truth_table() {
1434 assert_eq!(
1437 classify_entry(TrackingMethod::None, true, false),
1438 ApplyAction::SkipExistingConfig
1439 );
1440 assert_eq!(
1441 classify_entry(TrackingMethod::None, false, true),
1442 ApplyAction::SkipExistingConfig
1443 );
1444 assert_eq!(
1445 classify_entry(TrackingMethod::None, false, false),
1446 ApplyAction::Write { record: false }
1447 );
1448
1449 assert_eq!(
1451 classify_entry(TrackingMethod::Subdir, false, true),
1452 ApplyAction::RefuseSymlink
1453 );
1454 assert_eq!(
1455 classify_entry(TrackingMethod::State, true, false),
1456 ApplyAction::Write { record: true }
1457 );
1458 assert_eq!(
1459 classify_entry(TrackingMethod::SubdirNoFlatten, false, false),
1460 ApplyAction::Write { record: true }
1461 );
1462 }
1463
1464 #[test]
1465 fn plan_reconcile_selects_only_unwanted_mod_dirs() {
1466 let keep: BTreeSet<String> = ["Author-Keep".to_string()].into_iter().collect();
1467
1468 let listing = vec![
1470 (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), ];
1476
1477 let removable = plan_reconcile(&listing, &keep);
1478
1479 assert_eq!(removable, vec![PathBuf::from("Author-Drop")]);
1480 }
1481
1482 #[test]
1483 fn classify_loader_file_drops_metadata_excluded_and_routes_rest() {
1484 let untracked = vec![PathBuf::from("BepInEx/config")];
1485 let exclusions = vec!["LICENCE".to_string()];
1486
1487 assert_eq!(
1489 classify_loader_file(Path::new("manifest.json"), &untracked, &exclusions).unwrap(),
1490 None
1491 );
1492
1493 assert_eq!(
1495 classify_loader_file(Path::new("LICENCE"), &untracked, &exclusions).unwrap(),
1496 None
1497 );
1498
1499 assert_eq!(
1501 classify_loader_file(Path::new("BepInEx/config/x.cfg"), &untracked, &exclusions).unwrap(),
1502 Some(TrackingMethod::None)
1503 );
1504
1505 assert_eq!(
1507 classify_loader_file(
1508 Path::new("BepInEx/core/loader.dll"),
1509 &untracked,
1510 &exclusions
1511 )
1512 .unwrap(),
1513 Some(TrackingMethod::State)
1514 );
1515
1516 assert!(classify_loader_file(Path::new("../evil"), &untracked, &exclusions).is_err());
1518 }
1519
1520 #[test]
1521 fn apply_install_empty_plan_writes_nothing() {
1522 let root = tempdir().unwrap();
1523
1524 let manifest = apply_install(&InstallPlan::default(), root.path()).unwrap();
1525
1526 assert!(manifest.written.is_empty());
1527 }
1528
1529 #[test]
1530 fn test_subdir_override_folder_preserves_subpath() {
1531 let eco = eco();
1532 let profile = profile(&eco, "valheim");
1533
1534 let pkg = tempdir().unwrap();
1535 let root = pkg.path();
1536
1537 write(&root.join("plugins/nested/Deep.dll"), b"deep");
1540 write(&root.join("plugins/Translations/German/german.json"), b"de");
1541
1542 let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
1543 let dests = dest_set(&plan);
1544
1545 assert!(dests.contains(&"BepInEx/plugins/Author-MyMod/nested/Deep.dll".to_string()));
1546 assert!(
1547 dests.contains(&"BepInEx/plugins/Author-MyMod/Translations/German/german.json".to_string())
1548 );
1549 }
1550
1551 #[test]
1552 fn test_subdir_override_folder_keeps_same_named_files_apart() {
1553 let eco = eco();
1554 let profile = profile(&eco, "valheim");
1555
1556 let pkg = tempdir().unwrap();
1557 let root = pkg.path();
1558
1559 write(&root.join("plugins/English/strings.json"), b"en");
1560 write(&root.join("plugins/German/strings.json"), b"de");
1561
1562 let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
1563 let dests = dest_set(&plan);
1564
1565 assert_eq!(
1566 dests,
1567 vec![
1568 "BepInEx/plugins/Author-MyMod/English/strings.json".to_string(),
1569 "BepInEx/plugins/Author-MyMod/German/strings.json".to_string(),
1570 ]
1571 );
1572 }
1573
1574 #[test]
1575 fn test_override_folder_inside_a_wrapper_folder_is_matched() {
1576 let eco = eco();
1577 let profile = profile(&eco, "valheim");
1578
1579 let pkg = tempdir().unwrap();
1580 let root = pkg.path();
1581
1582 write(&root.join("BepInEx/plugins/Mod.dll"), b"dll");
1585 write(&root.join("BepInEx/config/Mod.cfg"), b"cfg");
1586 write(
1587 &root.join("ZenUI/plugins/Translations/German/strings.json"),
1588 b"de",
1589 );
1590 write(
1591 &root.join("ZenUI/plugins/Translations/English/strings.json"),
1592 b"en",
1593 );
1594
1595 let plan = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
1596 let dests = dest_set(&plan);
1597
1598 assert_eq!(
1599 dests,
1600 vec![
1601 "BepInEx/config/Mod.cfg".to_string(),
1602 "BepInEx/plugins/Author-Mod/Mod.dll".to_string(),
1603 "BepInEx/plugins/Author-Mod/Translations/English/strings.json".to_string(),
1604 "BepInEx/plugins/Author-Mod/Translations/German/strings.json".to_string(),
1605 ]
1606 );
1607
1608 let cfg = plan
1609 .entries
1610 .iter()
1611 .find(|e| e.dest_relative == Path::new("BepInEx/config/Mod.cfg"))
1612 .unwrap();
1613
1614 assert_eq!(cfg.tracking, TrackingMethod::None);
1615 }
1616
1617 #[test]
1618 fn test_wrapper_folder_without_an_override_folder_still_flattens() {
1619 let eco = eco();
1620 let profile = profile(&eco, "valheim");
1621
1622 let pkg = tempdir().unwrap();
1623 let root = pkg.path();
1624
1625 write(&root.join("Wrapper/assets/bundle.dat"), b"data");
1626 write(&root.join("Wrapper/Hook.mm.dll"), b"hook");
1627
1628 let plan = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
1629 let dests = dest_set(&plan);
1630
1631 assert_eq!(
1632 dests,
1633 vec![
1634 "BepInEx/monomod/Author-Mod/Hook.mm.dll".to_string(),
1635 "BepInEx/plugins/Author-Mod/bundle.dat".to_string(),
1636 ]
1637 );
1638 }
1639
1640 #[test]
1641 fn test_subdir_default_route_flattens_nested_file() {
1642 let eco = eco();
1643 let profile = profile(&eco, "valheim");
1644
1645 let pkg = tempdir().unwrap();
1646 let root = pkg.path();
1647
1648 write(&root.join("web/js/card.js"), b"card");
1652
1653 let plan = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
1654 let dests = dest_set(&plan);
1655
1656 assert_eq!(
1657 dests,
1658 vec!["BepInEx/plugins/Author-Mod/card.js".to_string()]
1659 );
1660 }
1661
1662 #[test]
1663 fn test_plan_refuses_files_flattened_onto_the_same_destination() {
1664 let eco = eco();
1665 let profile = profile(&eco, "valheim");
1666
1667 let pkg = tempdir().unwrap();
1668 let root = pkg.path();
1669
1670 write(&root.join("web/a/index.js"), b"a");
1671 write(&root.join("web/b/index.js"), b"b");
1672
1673 let error = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap_err();
1674 let message = error.to_string();
1675
1676 assert!(
1677 message.contains("BepInEx/plugins/Author-Mod/index.js"),
1678 "{message}"
1679 );
1680 assert!(message.contains("web/a/index.js"), "{message}");
1681 assert!(message.contains("web/b/index.js"), "{message}");
1682 }
1683
1684 #[test]
1685 fn test_root_file_named_like_override_folder_is_not_a_folder_match() {
1686 let eco = eco();
1687 let profile = profile(&eco, "valheim");
1688
1689 let pkg = tempdir().unwrap();
1690 let root = pkg.path();
1691
1692 write(&root.join("plugins"), b"not a folder");
1696
1697 let plan = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
1698 let dests = dest_set(&plan);
1699
1700 assert_eq!(
1701 dests,
1702 vec!["BepInEx/plugins/Author-Mod/plugins".to_string()]
1703 );
1704 }
1705
1706 #[test]
1707 fn test_toggle_and_uninstall_handle_nested_subdir_files() {
1708 let eco = eco();
1709 let profile = profile(&eco, "valheim");
1710
1711 let pkg = tempdir().unwrap();
1712 write(&pkg.path().join("plugins/Mod.dll"), b"dll");
1713 write(
1714 &pkg.path().join("plugins/Translations/German/german.json"),
1715 b"de",
1716 );
1717
1718 let plan = plan_install(&eco, &profile, "Author-Mod", pkg.path(), None).unwrap();
1719
1720 let profile_dir = tempdir().unwrap();
1721 let root = profile_dir.path();
1722
1723 apply_install(&plan, root).unwrap();
1724
1725 let mod_dir = root.join("BepInEx/plugins/Author-Mod");
1726 let nested = mod_dir.join("Translations/German/german.json");
1727
1728 assert!(nested.exists());
1729
1730 disable_mod(&profile, root, "Author-Mod").unwrap();
1731
1732 assert!(!nested.exists());
1733 assert!(mod_dir.join("Translations/German/german.json.old").exists());
1734 assert!(mod_dir.join("Mod.dll.old").exists());
1735
1736 enable_mod(&profile, root, "Author-Mod").unwrap();
1737
1738 assert!(nested.exists());
1739 assert!(!mod_dir.join("Translations/German/german.json.old").exists());
1740 assert!(mod_dir.join("Mod.dll").exists());
1741
1742 remove_delisted(&profile, root, &BTreeSet::new()).unwrap();
1743
1744 assert!(!mod_dir.exists());
1745 }
1746
1747 #[test]
1748 fn test_subdir_no_flatten_preserves_subpath() {
1749 let eco = eco();
1750 let profile = profile(&eco, "rumble");
1751
1752 let pkg = tempdir().unwrap();
1753 let root = pkg.path();
1754
1755 write(&root.join("data/nested/asset.bin"), b"asset");
1758
1759 let plan = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
1760 let dests = dest_set(&plan);
1761
1762 assert!(dests.contains(&"UserData/ModManager/Author-Mod/data/nested/asset.bin".to_string()));
1763 }
1764
1765 #[test]
1766 fn test_extension_routing_applies_to_nested_files() {
1767 let eco = eco();
1768 let profile = profile(&eco, "valheim");
1769
1770 let pkg = tempdir().unwrap();
1771 let root = pkg.path();
1772
1773 write(&root.join("Wrapper/Hook.mm.dll"), b"hook");
1777
1778 let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
1779 let dests = dest_set(&plan);
1780
1781 assert!(dests.contains(&"BepInEx/monomod/Author-MyMod/Hook.mm.dll".to_string()));
1782 assert!(!dests.iter().any(|d| d.contains("Wrapper")));
1783 }
1784
1785 #[test]
1786 fn test_loader_pack_unpacks_to_root_stripping_nested_subfolder() {
1787 let eco = eco();
1788 let profile = profile(&eco, "valheim");
1789
1790 let pkg = tempdir().unwrap();
1791 let root = pkg.path();
1792
1793 write(&root.join("BepInExPack_Valheim/winhttp.dll"), b"proxy");
1794 write(
1795 &root.join("BepInExPack_Valheim/BepInEx/core/BepInEx.dll"),
1796 b"core",
1797 );
1798 write(&root.join("manifest.json"), b"{}");
1799 write(&root.join("icon.png"), b"png");
1800
1801 let plan = plan_install(&eco, &profile, "denikson-BepInExPack_Valheim", root, None).unwrap();
1802 let dests = dest_set(&plan);
1803
1804 assert!(dests.contains(&"winhttp.dll".to_string()));
1805 assert!(dests.contains(&"BepInEx/core/BepInEx.dll".to_string()));
1806 assert!(!dests.iter().any(|d| d.contains("manifest.json")));
1807 assert!(!dests.iter().any(|d| d.contains("BepInExPack_Valheim")));
1808 assert!(!dests.iter().any(|d| d.contains("icon.png")));
1809 }
1810
1811 #[test]
1812 fn test_loader_pack_preserves_config_on_reinstall() {
1813 let eco = eco();
1814 let profile = profile(&eco, "valheim");
1815
1816 let pkg = tempdir().unwrap();
1817 let pkg_root = pkg.path();
1818 write(&pkg_root.join("BepInExPack_Valheim/winhttp.dll"), b"proxy");
1819 write(
1820 &pkg_root.join("BepInExPack_Valheim/BepInEx/config/BepInEx.cfg"),
1821 b"default",
1822 );
1823
1824 let profile_dir = tempdir().unwrap();
1825 let root = profile_dir.path();
1826
1827 let plan = plan_install(
1828 &eco,
1829 &profile,
1830 "denikson-BepInExPack_Valheim",
1831 pkg_root,
1832 None,
1833 )
1834 .unwrap();
1835 apply_install(&plan, root).unwrap();
1836
1837 let cfg = root.join("BepInEx/config/BepInEx.cfg");
1838 write(&cfg, b"user-edited");
1839
1840 let plan = plan_install(
1841 &eco,
1842 &profile,
1843 "denikson-BepInExPack_Valheim",
1844 pkg_root,
1845 None,
1846 )
1847 .unwrap();
1848 apply_install(&plan, root).unwrap();
1849
1850 assert_eq!(fs::read_to_string(&cfg).unwrap(), "user-edited");
1851 assert!(root.join("winhttp.dll").exists());
1852 }
1853
1854 #[test]
1855 fn test_loader_pack_without_nesting_falls_back_to_root() {
1856 let eco = eco();
1857 let profile = profile(&eco, "valheim");
1858
1859 let pkg = tempdir().unwrap();
1860 let root = pkg.path();
1861
1862 write(&root.join("winhttp.dll"), b"proxy");
1865
1866 let plan = plan_install(&eco, &profile, "denikson-BepInExPack_Valheim", root, None).unwrap();
1867 let dests = dest_set(&plan);
1868
1869 assert!(dests.contains(&"winhttp.dll".to_string()));
1870 }
1871
1872 #[test]
1873 fn test_loader_pack_skips_relative_file_exclusions() {
1874 let eco = eco();
1875 let profile = profile(&eco, "boneworks");
1877
1878 let pkg = tempdir().unwrap();
1879 let root = pkg.path();
1880
1881 write(&root.join("winhttp.dll"), b"proxy");
1884 write(&root.join("LICENCE"), b"license text");
1885
1886 let plan = plan_install(&eco, &profile, "denikson-BepInExPack_Valheim", root, None).unwrap();
1887 let dests = dest_set(&plan);
1888
1889 assert!(dests.contains(&"winhttp.dll".to_string()));
1890 assert!(!dests.iter().any(|d| d.contains("LICENCE")));
1893 }
1894
1895 #[test]
1896 fn test_config_is_preserved_across_reinstall() {
1897 let eco = eco();
1898 let profile = profile(&eco, "valheim");
1899
1900 let pkg = tempdir().unwrap();
1901 let pkg_root = pkg.path();
1902 write(&pkg_root.join("MyMod.dll"), b"dll");
1903 write(&pkg_root.join("config/MyMod.cfg"), b"default-config");
1904
1905 let profile_dir = tempdir().unwrap();
1906 let root = profile_dir.path();
1907
1908 let plan = plan_install(&eco, &profile, "Author-MyMod", pkg_root, None).unwrap();
1909 let first = apply_install(&plan, root).unwrap();
1910
1911 assert_eq!(first.written.len(), 1);
1912 assert!(root.join("BepInEx/config/MyMod.cfg").exists());
1913
1914 let cfg = root.join("BepInEx/config/MyMod.cfg");
1915 write(&cfg, b"user-edited");
1916
1917 let plan = plan_install(&eco, &profile, "Author-MyMod", pkg_root, None).unwrap();
1918 let second = apply_install(&plan, root).unwrap();
1919
1920 assert_eq!(fs::read_to_string(&cfg).unwrap(), "user-edited");
1921 assert!(second.written.iter().any(|p| p.ends_with("MyMod.dll")));
1922 assert!(
1923 !second
1924 .written
1925 .iter()
1926 .any(|p| p.to_string_lossy().contains("MyMod.cfg"))
1927 );
1928 }
1929
1930 #[test]
1931 fn test_apply_and_manifest_roundtrip() {
1932 let eco = eco();
1933 let profile = profile(&eco, "valheim");
1934
1935 let pkg = tempdir().unwrap();
1936 let pkg_root = pkg.path();
1937 write(&pkg_root.join("MyMod.dll"), b"dll");
1938 write(&pkg_root.join("config/MyMod.cfg"), b"cfg");
1939
1940 let plan = plan_install(&eco, &profile, "Author-MyMod", pkg_root, None).unwrap();
1941
1942 let profile_dir = tempdir().unwrap();
1943 let root = profile_dir.path();
1944 let manifest = apply_install(&plan, root).unwrap();
1945
1946 assert_eq!(manifest.written.len(), 1);
1947 assert!(root.join("BepInEx/plugins/Author-MyMod/MyMod.dll").exists());
1948 assert!(root.join("BepInEx/config/MyMod.cfg").exists());
1949
1950 for rel in &manifest.written {
1951 fs::remove_file(root.join(rel)).unwrap();
1952 }
1953
1954 assert!(!root.join("BepInEx/plugins/Author-MyMod/MyMod.dll").exists());
1955 }
1956
1957 #[test]
1958 fn test_apply_install_rolls_back_on_failure() {
1959 let src = tempdir().unwrap();
1960
1961 let good = src.path().join("good.dll");
1962 write(&good, b"good");
1963
1964 let plan = InstallPlan {
1965 entries: vec![
1966 InstallEntry {
1967 source: good,
1968 dest_relative: PathBuf::from("BepInEx/plugins/Author-Mod/good.dll"),
1969 tracking: TrackingMethod::State,
1970 },
1971 InstallEntry {
1972 source: src.path().join("does_not_exist.dll"),
1973 dest_relative: PathBuf::from("BepInEx/plugins/Author-Mod/missing.dll"),
1974 tracking: TrackingMethod::State,
1975 },
1976 ],
1977 };
1978
1979 let profile_dir = tempdir().unwrap();
1980 let root = profile_dir.path();
1981
1982 let result = apply_install(&plan, root);
1983
1984 assert!(result.is_err());
1987 assert!(!root.join("BepInEx/plugins/Author-Mod/good.dll").exists());
1988 assert!(!root.join("BepInEx/plugins/Author-Mod").exists());
1991 assert!(!root.join("BepInEx/plugins").exists());
1992 assert!(!root.join("BepInEx").exists());
1993 assert!(root.exists());
1995 }
1996
1997 #[test]
1998 #[cfg(unix)]
1999 fn test_apply_install_preserves_symlinked_shared_config() {
2000 use std::os::unix::fs::symlink;
2001
2002 let src = tempdir().unwrap();
2003 let source = src.path().join("mod.cfg");
2004 write(&source, b"packaged-default");
2005
2006 let outside = tempdir().unwrap();
2007 let user_config = outside.path().join("user.cfg");
2008 write(&user_config, b"user-edited");
2009
2010 let profile_dir = tempdir().unwrap();
2011 let root = profile_dir.path();
2012
2013 let dest_relative = PathBuf::from("BepInEx/config/mod.cfg");
2015 let dest = root.join(&dest_relative);
2016 fs::create_dir_all(dest.parent().unwrap()).unwrap();
2017 symlink(&user_config, &dest).unwrap();
2018
2019 let plan = InstallPlan {
2020 entries: vec![InstallEntry {
2021 source,
2022 dest_relative,
2023 tracking: TrackingMethod::None,
2024 }],
2025 };
2026
2027 let manifest = apply_install(&plan, root).unwrap();
2028
2029 assert!(manifest.written.is_empty());
2032 assert_eq!(fs::read_to_string(&user_config).unwrap(), "user-edited");
2033 }
2034
2035 #[test]
2036 #[cfg(unix)]
2037 fn test_apply_install_refuses_to_write_through_symlink() {
2038 use std::os::unix::fs::symlink;
2039
2040 let src = tempdir().unwrap();
2041 let source = src.path().join("mod.cfg");
2042 write(&source, b"new-content");
2043
2044 let outside = tempdir().unwrap();
2045 let secret = outside.path().join("secret.txt");
2046 write(&secret, b"original");
2047
2048 let profile_dir = tempdir().unwrap();
2049 let root = profile_dir.path();
2050
2051 let dest_relative = PathBuf::from("BepInEx/config/mod.cfg");
2053 let dest = root.join(&dest_relative);
2054 fs::create_dir_all(dest.parent().unwrap()).unwrap();
2055 symlink(&secret, &dest).unwrap();
2056
2057 let plan = InstallPlan {
2058 entries: vec![InstallEntry {
2059 source,
2060 dest_relative,
2061 tracking: TrackingMethod::State,
2062 }],
2063 };
2064
2065 let result = apply_install(&plan, root);
2066
2067 assert!(result.is_err());
2069 assert_eq!(fs::read_to_string(&secret).unwrap(), "original");
2070 }
2071
2072 #[test]
2073 fn test_remove_delisted_removes_only_unwanted_mod_folders() {
2074 let eco = eco();
2075 let profile = profile(&eco, "valheim");
2076
2077 let profile_dir = tempdir().unwrap();
2078 let root = profile_dir.path();
2079
2080 write(&root.join("BepInEx/plugins/Owner-ModA/A.dll"), b"a");
2081 write(&root.join("BepInEx/plugins/Owner-ModB/B.dll"), b"b");
2082 write(&root.join("BepInEx/core/BepInEx.dll"), b"loader");
2083 write(&root.join("BepInEx/config/BepInEx.cfg"), b"cfg");
2084
2085 let keep: BTreeSet<String> = ["Owner-ModA".to_string()].into_iter().collect();
2086 let removed = remove_delisted(&profile, root, &keep).unwrap();
2087
2088 assert!(root.join("BepInEx/plugins/Owner-ModA/A.dll").exists());
2089 assert!(!root.join("BepInEx/plugins/Owner-ModB").exists());
2090 assert!(root.join("BepInEx/core/BepInEx.dll").exists());
2092 assert!(root.join("BepInEx/config/BepInEx.cfg").exists());
2093
2094 assert_eq!(removed.len(), 1);
2095 }
2096
2097 #[test]
2098 fn plan_delisted_lists_what_remove_delisted_would_take_without_taking_it() {
2099 let eco = eco();
2100 let profile = profile(&eco, "valheim");
2101
2102 let profile_dir = tempdir().unwrap();
2103 let root = profile_dir.path();
2104
2105 write(&root.join("BepInEx/plugins/Owner-ModA/A.dll"), b"a");
2106 write(&root.join("BepInEx/plugins/Owner-ModB/B.dll"), b"b");
2107 write(&root.join("BepInEx/plugins/Owner-ModC/C.dll"), b"c");
2108 write(&root.join("BepInEx/plugins/MMHOOK/hook.dll"), b"hook");
2110
2111 let keep: BTreeSet<String> = ["Owner-ModA".to_string()].into_iter().collect();
2112 let planned = plan_delisted(&profile, root, &keep).unwrap();
2113
2114 assert_eq!(
2117 planned,
2118 vec![
2119 PathBuf::from("BepInEx/plugins/Owner-ModB"),
2120 PathBuf::from("BepInEx/plugins/Owner-ModC"),
2121 ]
2122 );
2123 assert!(root.join("BepInEx/plugins/Owner-ModB/B.dll").exists());
2125 assert!(root.join("BepInEx/plugins/Owner-ModC/C.dll").exists());
2126
2127 let removed = remove_delisted(&profile, root, &keep).unwrap();
2130
2131 assert_eq!(removed, planned);
2132 assert!(!root.join("BepInEx/plugins/Owner-ModB").exists());
2133 assert!(!root.join("BepInEx/plugins/Owner-ModC").exists());
2134 assert!(root.join("BepInEx/plugins/Owner-ModA/A.dll").exists());
2135 assert!(root.join("BepInEx/plugins/MMHOOK/hook.dll").exists());
2136 }
2137
2138 #[test]
2139 fn test_remove_delisted_ignores_non_mod_directories() {
2140 let eco = eco();
2141 let profile = profile(&eco, "valheim");
2142
2143 let profile_dir = tempdir().unwrap();
2144 let root = profile_dir.path();
2145
2146 write(&root.join("BepInEx/plugins/Owner-Delete/x.dll"), b"x");
2149 write(&root.join("BepInEx/plugins/MMHOOK/hook.dll"), b"hook");
2150
2151 let keep: BTreeSet<String> = BTreeSet::new();
2152 let removed = remove_delisted(&profile, root, &keep).unwrap();
2153
2154 assert!(!root.join("BepInEx/plugins/Owner-Delete").exists());
2156 assert!(root.join("BepInEx/plugins/MMHOOK/hook.dll").exists());
2157 assert_eq!(removed.len(), 1);
2158 }
2159
2160 #[test]
2161 fn test_melonloader_flat_routing() {
2162 let eco = eco();
2163 let profile = profile(&eco, "boneworks");
2164
2165 let pkg = tempdir().unwrap();
2166 let root = pkg.path();
2167 write(&root.join("MyMod.dll"), b"dll");
2168 write(&root.join("manifest.json"), b"{}");
2169
2170 let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
2171 let dests = dest_set(&plan);
2172
2173 assert!(dests.contains(&"Mods/MyMod.dll".to_string()));
2175 assert!(!dests.iter().any(|d| d.contains("manifest.json")));
2177 }
2178
2179 #[test]
2180 fn test_melonloader_specific_extension_wins_over_generic() {
2181 let eco = eco();
2182 let profile = profile(&eco, "boneworks");
2183
2184 let pkg = tempdir().unwrap();
2185 let root = pkg.path();
2186
2187 write(&root.join("MyLib.lib.dll"), b"lib");
2188 write(&root.join("MyMod.dll"), b"dll");
2189
2190 let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
2191 let dests = dest_set(&plan);
2192
2193 assert!(dests.contains(&"UserLibs/MyLib.lib.dll".to_string()));
2194 assert!(dests.contains(&"Mods/MyMod.dll".to_string()));
2195 }
2196
2197 #[test]
2198 fn test_melonloader_subroute_extension_routing() {
2199 let eco = eco();
2200 let profile = profile(&eco, "bonelab");
2201
2202 let pkg = tempdir().unwrap();
2203 let root = pkg.path();
2204
2205 write(&root.join("cool.melon"), b"item");
2207 write(&root.join("UserData/other.melon"), b"item2");
2209
2210 let plan = plan_install(&eco, &profile, "Author-MyMod", root, None).unwrap();
2211 let dests = dest_set(&plan);
2212
2213 assert!(dests.contains(&"UserData/CustomItems/cool.melon".to_string()));
2214 assert!(dests.contains(&"UserData/CustomItems/other.melon".to_string()));
2215 }
2216
2217 #[test]
2218 fn test_northstar_state_routing_and_exclusions() {
2219 let eco = eco();
2220 let profile = profile(&eco, "northstar");
2221
2222 let pkg = tempdir().unwrap();
2223 let root = pkg.path();
2224
2225 write(&root.join("mods/Author.Mod/mod.json"), b"{}");
2226 write(&root.join("manifest.json"), b"{}");
2227
2228 let plan = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
2229 let dests = dest_set(&plan);
2230
2231 assert!(dests.contains(&"R2Northstar/mods/Author.Mod/mod.json".to_string()));
2233 assert!(!dests.iter().any(|d| d.contains("manifest.json")));
2235 }
2236
2237 #[test]
2238 fn test_godotml_package_zip() {
2239 let eco = eco();
2240 let profile = profile(&eco, "brotato");
2241
2242 let pkg = tempdir().unwrap();
2243 let root = pkg.path();
2244 write(&root.join("something.pck"), b"pck");
2245
2246 let archive = pkg.path().join("Author-Mod-1.0.0.zip");
2247 write(&archive, b"zipbytes");
2248
2249 let plan = plan_install(&eco, &profile, "Author-Mod", root, Some(&archive)).unwrap();
2250 let dests = dest_set(&plan);
2251
2252 assert_eq!(dests, vec!["mods/Author-Mod.zip".to_string()]);
2253 assert_eq!(plan.entries[0].tracking, TrackingMethod::PackageZip);
2254
2255 let empty = plan_install(&eco, &profile, "Author-Mod", root, None).unwrap();
2257 assert!(empty.entries.is_empty());
2258 }
2259
2260 #[test]
2261 fn test_plan_install_rejects_traversal_in_package_name() {
2262 let eco = eco();
2263 let profile = profile(&eco, "valheim");
2264
2265 let pkg = tempdir().unwrap();
2266 let root = pkg.path();
2267
2268 write(&root.join("MyMod.dll"), b"dll");
2269
2270 let result = plan_install(&eco, &profile, "../../etc/Author-Evil", root, None);
2271
2272 assert!(result.is_err());
2273 }
2274
2275 #[test]
2276 fn test_ensure_safe_relative_rejects_unsafe_paths() {
2277 assert!(ensure_safe_relative(Path::new("BepInEx/plugins/Author-Mod/x.dll")).is_ok());
2278 assert!(ensure_safe_relative(Path::new("../escape")).is_err());
2279 assert!(ensure_safe_relative(Path::new("a/../../escape")).is_err());
2280 assert!(ensure_safe_relative(Path::new("/abs/path")).is_err());
2281 }
2282
2283 #[cfg(unix)]
2284 #[test]
2285 fn test_walk_files_skips_symlinks() {
2286 use std::os::unix::fs::symlink;
2287
2288 let dir = tempdir().unwrap();
2289 let root = dir.path();
2290
2291 write(&root.join("real.txt"), b"real");
2292 symlink("/etc/passwd", root.join("link.txt")).unwrap();
2293
2294 let files = walk_files(root).unwrap();
2295
2296 assert!(files.iter().any(|p| p.ends_with("real.txt")));
2297 assert!(!files.iter().any(|p| p.ends_with("link.txt")));
2298 }
2299
2300 #[test]
2301 fn state_tracker_collects_only_state_entries() {
2302 let plan = InstallPlan {
2303 entries: vec![
2304 InstallEntry {
2305 source: PathBuf::from("/cache/Author-Mod/1.0.0/loader.dll"),
2306 dest_relative: PathBuf::from("BepInEx/core/loader.dll"),
2307 tracking: TrackingMethod::State,
2308 },
2309 InstallEntry {
2310 source: PathBuf::from("/cache/Author-Mod/1.0.0/plugin.dll"),
2311 dest_relative: PathBuf::from("BepInEx/plugins/Author-Mod/plugin.dll"),
2312 tracking: TrackingMethod::Subdir,
2313 },
2314 ],
2315 };
2316
2317 let tracker = state_tracker_from_plan(&plan, "Author-Mod").unwrap();
2318
2319 assert_eq!(tracker.mod_name, "Author-Mod");
2320 assert_eq!(tracker.files.len(), 1);
2321 assert_eq!(tracker.files[0].1, "BepInEx/core/loader.dll");
2322 }
2323
2324 #[test]
2325 fn state_tracker_none_when_no_state_entries() {
2326 let plan = InstallPlan {
2327 entries: vec![InstallEntry {
2328 source: PathBuf::from("/cache/x/plugin.dll"),
2329 dest_relative: PathBuf::from("BepInEx/plugins/Author-Mod/plugin.dll"),
2330 tracking: TrackingMethod::Subdir,
2331 }],
2332 };
2333
2334 assert!(state_tracker_from_plan(&plan, "Author-Mod").is_none());
2335 }
2336
2337 #[test]
2338 fn remove_state_payload_deletes_files_then_tracker() {
2339 let dir = tempdir().unwrap();
2340 let root = dir.path();
2341
2342 write(&root.join("BepInEx/core/loader.dll"), b"loader");
2343
2344 let tracker = ModFileTracker {
2345 mod_name: "Author-Loader".to_string(),
2346 files: vec![
2347 (
2348 "/cache/loader.dll".to_string(),
2349 "BepInEx/core/loader.dll".to_string(),
2350 ),
2351 (
2353 "/cache/gone.dll".to_string(),
2354 "BepInEx/core/gone.dll".to_string(),
2355 ),
2356 ],
2357 };
2358
2359 write_state_file(root, &tracker).unwrap();
2360 remove_state_payload(root, "Author-Loader").unwrap();
2361
2362 assert!(!root.join("BepInEx/core/loader.dll").exists());
2363 assert!(!state_file_path(root, "Author-Loader").exists());
2364
2365 assert!(remove_state_payload(root, "Author-Untracked").is_ok());
2367 }
2368
2369 #[test]
2370 fn remove_state_payload_rejects_traversal_in_tracker() {
2371 let dir = tempdir().unwrap();
2372 let root = dir.path().join("profile");
2373
2374 let outside = dir.path().join("secret.txt");
2375 write(&outside, b"secret");
2376
2377 let tracker = ModFileTracker {
2378 mod_name: "Author-Evil".to_string(),
2379 files: vec![("/cache/x".to_string(), "../secret.txt".to_string())],
2380 };
2381
2382 write_state_file(&root, &tracker).unwrap();
2383
2384 assert!(remove_state_payload(&root, "Author-Evil").is_err());
2386 assert!(outside.exists());
2387 }
2388
2389 #[test]
2390 fn state_file_round_trips() {
2391 let dir = tempdir().unwrap();
2392 let tracker = ModFileTracker {
2393 mod_name: "Author-Mod".to_string(),
2394 files: vec![("/cache/a".to_string(), "BepInEx/core/a.dll".to_string())],
2395 };
2396
2397 write_state_file(dir.path(), &tracker).unwrap();
2398
2399 assert!(dir.path().join("_state/Author-Mod-state.yml").exists());
2400
2401 let back = read_state_file(dir.path(), "Author-Mod").unwrap().unwrap();
2402
2403 assert_eq!(back.mod_name, "Author-Mod");
2404 assert_eq!(back.files, tracker.files);
2405 assert!(read_state_file(dir.path(), "Missing").unwrap().is_none());
2406 }
2407
2408 #[test]
2409 fn plan_suffix_renames_adds_and_strips_old() {
2410 let files = vec![
2411 PathBuf::from("a/b/plugin.dll"),
2412 PathBuf::from("a/b/data.cfg.old"),
2413 ];
2414
2415 let disable = plan_suffix_renames(&files, true);
2416
2417 assert_eq!(
2419 disable,
2420 vec![(
2421 PathBuf::from("a/b/plugin.dll"),
2422 PathBuf::from("a/b/plugin.dll.old"),
2423 )]
2424 );
2425
2426 let enable = plan_suffix_renames(&files, false);
2427
2428 assert_eq!(
2430 enable,
2431 vec![(
2432 PathBuf::from("a/b/data.cfg.old"),
2433 PathBuf::from("a/b/data.cfg"),
2434 )]
2435 );
2436 }
2437
2438 #[test]
2439 fn disable_enable_subdir_renames_files() {
2440 let eco = eco();
2441 let profile = profile(&eco, "valheim");
2442
2443 let profile_dir = tempdir().unwrap();
2444 let root = profile_dir.path();
2445 write(&root.join("BepInEx/plugins/Author-Mod/plugin.dll"), b"x");
2446
2447 disable_mod(&profile, root, "Author-Mod").unwrap();
2448
2449 assert!(!root.join("BepInEx/plugins/Author-Mod/plugin.dll").exists());
2450 assert!(
2451 root
2452 .join("BepInEx/plugins/Author-Mod/plugin.dll.old")
2453 .exists()
2454 );
2455
2456 enable_mod(&profile, root, "Author-Mod").unwrap();
2457
2458 assert!(root.join("BepInEx/plugins/Author-Mod/plugin.dll").exists());
2459 }
2460
2461 #[test]
2462 fn disable_enable_state_removes_and_restores_from_cache() {
2463 let eco = eco();
2464 let profile = profile(&eco, "valheim");
2465
2466 let profile_dir = tempdir().unwrap();
2467 let root = profile_dir.path();
2468
2469 let cache = tempdir().unwrap();
2471 let src = cache.path().join("loader.dll");
2472 write(&src, b"loader");
2473 write(&root.join("BepInEx/core/loader.dll"), b"loader");
2474
2475 let tracker = ModFileTracker {
2476 mod_name: "Author-Loader".to_string(),
2477 files: vec![(
2478 src.to_string_lossy().to_string(),
2479 "BepInEx/core/loader.dll".to_string(),
2480 )],
2481 };
2482 write_state_file(root, &tracker).unwrap();
2483
2484 disable_mod(&profile, root, "Author-Loader").unwrap();
2485
2486 assert!(!root.join("BepInEx/core/loader.dll").exists());
2487
2488 enable_mod(&profile, root, "Author-Loader").unwrap();
2489
2490 assert!(root.join("BepInEx/core/loader.dll").exists());
2491 }
2492}