1#![deny(missing_docs)]
22
23use crate::client::ThunderstoreClient;
24use crate::error::{Error, Result};
25use crate::profile::modlist::{ProfileMod, SemVer};
26use crate::util::{canonicalize_missing, same_file};
27use base64::Engine as _;
28use base64::engine::general_purpose::STANDARD;
29use serde::{Deserialize, Serialize};
30use std::fs;
31use std::io::{Cursor, Read, Write};
32use std::path::{Component, Path, PathBuf};
33use zip::write::{FileOptions, ZipWriter};
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[non_exhaustive]
40pub struct ExportMod {
41 pub name: String,
43 pub version: SemVer,
45 #[serde(default = "default_true")]
47 pub enabled: bool,
48}
49
50fn default_true() -> bool {
53 true
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61#[non_exhaustive]
62pub struct ExportFormat {
63 pub profile_name: String,
65 pub mods: Vec<ExportMod>,
67}
68
69pub fn export_format_from_mods(profile_name: &str, mods: &[ProfileMod]) -> ExportFormat {
71 let mods = mods
72 .iter()
73 .map(|entry| ExportMod {
74 name: entry.name.clone(),
75 version: entry.version_number,
76 enabled: entry.enabled,
77 })
78 .collect();
79
80 ExportFormat {
81 profile_name: profile_name.to_string(),
82 mods,
83 }
84}
85
86pub fn export_r2x_to_string(export: &ExportFormat) -> Result<String> {
88 serde_yaml_ng::to_string(export)
89 .map_err(|e| Error::Profile(format!("serializing export.r2x: {e}")))
90}
91
92pub fn parse_export_r2x(yaml: &str) -> Result<ExportFormat> {
94 serde_yaml_ng::from_str(yaml).map_err(|e| Error::Profile(format!("parsing export.r2x: {e}")))
95}
96
97const CONFIG_EXTENSIONS: [&str; 6] = [".cfg", ".txt", ".json", ".yml", ".yaml", ".ini"];
99
100fn is_pruned(rel: &Path) -> bool {
107 let normalized = rel.to_string_lossy().replace('\\', "/");
108
109 let prefixes = [
110 "dotnet/",
111 "_state/",
112 "MelonLoader/",
113 "BepInEx/config/",
114 "GDWeave/core/",
115 "GDWeave/mods/",
116 ];
117
118 if prefixes.iter().any(|p| normalized.starts_with(p)) {
119 return true;
120 }
121
122 if normalized == "mods.yml" || normalized == "GDWeave/GDWeave.log" {
123 return true;
124 }
125
126 is_plugin_manifest(&normalized)
127}
128
129fn is_plugin_manifest(normalized: &str) -> bool {
132 let parts: Vec<&str> = normalized.split('/').collect();
133
134 parts.len() == 4
135 && parts[0] == "BepInEx"
136 && parts[1] == "plugins"
137 && parts[3].eq_ignore_ascii_case("manifest.json")
138}
139
140fn is_config_file(rel: &Path) -> bool {
143 let name = match rel.file_name().and_then(|n| n.to_str()) {
144 Some(name) => name.to_ascii_lowercase(),
145 None => return false,
146 };
147
148 CONFIG_EXTENSIONS.iter().any(|ext| name.ends_with(ext))
149}
150
151fn collect_export_paths(target_dir: &Path) -> Result<Vec<PathBuf>> {
154 let mut found = Vec::new();
155
156 collect_files(target_dir, target_dir, &mut found)?;
157
158 let mut kept: Vec<PathBuf> = found
159 .into_iter()
160 .filter(|rel| is_config_file(rel) && !is_pruned(rel))
161 .collect();
162
163 kept.sort();
164
165 Ok(kept)
166}
167
168fn collect_files(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
171 if !dir.is_dir() {
172 return Ok(());
173 }
174
175 for entry in
176 fs::read_dir(dir).map_err(|e| Error::Profile(format!("reading {}: {}", dir.display(), e)))?
177 {
178 let entry = entry.map_err(|e| Error::Profile(format!("reading an entry: {e}")))?;
179 let path = entry.path();
180 let metadata = fs::symlink_metadata(&path)
181 .map_err(|e| Error::Profile(format!("inspecting {}: {}", path.display(), e)))?;
182
183 if metadata.file_type().is_symlink() {
184 continue;
185 }
186
187 if metadata.is_dir() {
188 collect_files(root, &path, out)?;
189 } else if let Ok(rel) = path.strip_prefix(root) {
190 out.push(rel.to_path_buf());
191 }
192 }
193
194 Ok(())
195}
196
197const EXPORT_MANIFEST: &str = "export.r2x";
199
200const ARCHIVE_CONFIG_DIR: &str = "config";
202
203const BEPINEX_CONFIG_DIR: &str = "BepInEx/config";
205
206pub fn build_r2z_in(target_dir: &Path, profile_name: &str) -> Result<Vec<u8>> {
235 let mods = crate::profile::modlist::read(target_dir)?;
236 let manifest = export_r2x_to_string(&export_format_from_mods(profile_name, &mods))?;
237
238 let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
239 let options: FileOptions<'_, ()> =
240 FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
241
242 zip
243 .start_file(EXPORT_MANIFEST, options)
244 .map_err(|e| Error::Profile(format!("writing {EXPORT_MANIFEST}: {e}")))?;
245 zip
246 .write_all(manifest.as_bytes())
247 .map_err(|e| Error::Profile(format!("writing {EXPORT_MANIFEST}: {e}")))?;
248
249 let config_root = target_dir.join(BEPINEX_CONFIG_DIR);
250 let mut shared = Vec::new();
251
252 collect_files(&config_root, &config_root, &mut shared)?;
253 shared.sort();
254
255 for rel in shared {
256 let archive_path = format!(
257 "{ARCHIVE_CONFIG_DIR}/{}",
258 rel.to_string_lossy().replace('\\', "/")
259 );
260
261 add_zip_file(&mut zip, options, &archive_path, &config_root.join(&rel))?;
262 }
263
264 for rel in collect_export_paths(target_dir)? {
265 let archive_path = rel.to_string_lossy().replace('\\', "/");
266
267 add_zip_file(&mut zip, options, &archive_path, &target_dir.join(&rel))?;
268 }
269
270 let cursor = zip
271 .finish()
272 .map_err(|e| Error::Profile(format!("finishing export archive: {e}")))?;
273
274 Ok(cursor.into_inner())
275}
276
277fn add_zip_file(
279 zip: &mut ZipWriter<Cursor<Vec<u8>>>,
280 options: FileOptions<'_, ()>,
281 archive_path: &str,
282 source: &Path,
283) -> Result<()> {
284 let bytes =
285 fs::read(source).map_err(|e| Error::Profile(format!("reading {}: {}", source.display(), e)))?;
286
287 zip
288 .start_file(archive_path, options)
289 .map_err(|e| Error::Profile(format!("writing {archive_path}: {e}")))?;
290 zip
291 .write_all(&bytes)
292 .map_err(|e| Error::Profile(format!("writing {archive_path}: {e}")))?;
293
294 Ok(())
295}
296
297pub fn export_to_file_in(
303 target_dir: &Path,
304 profile_name: &str,
305 out_dir: &Path,
306 timestamp_millis: u64,
307) -> Result<PathBuf> {
308 crate::profile::layout::validate_name(profile_name)?;
309
310 let bytes = build_r2z_in(target_dir, profile_name)?;
311
312 fs::create_dir_all(out_dir)
313 .map_err(|e| Error::Profile(format!("creating {}: {}", out_dir.display(), e)))?;
314
315 let path = out_dir.join(format!("{profile_name}_{timestamp_millis}.r2z"));
316
317 fs::write(&path, bytes)
318 .map_err(|e| Error::Profile(format!("writing {}: {}", path.display(), e)))?;
319
320 Ok(path)
321}
322
323pub fn export_to_file(
326 base: &Path,
327 game: &str,
328 profile_name: &str,
329 timestamp_millis: u64,
330) -> Result<PathBuf> {
331 export_to_file_in(
332 &crate::profile::layout::profile_dir(base, game, profile_name),
333 profile_name,
334 &base.join(game).join("exports"),
335 timestamp_millis,
336 )
337}
338
339const MAX_EXPORT_CODE_BYTES: usize = 20_000_000;
341
342const PROFILE_DATA_PREFIX: &str = "#r2modman";
344
345fn encode_profile_payload(zip_bytes: &[u8]) -> Result<Vec<u8>> {
349 if zip_bytes.len() > MAX_EXPORT_CODE_BYTES {
350 return Err(Error::SizeLimitExceeded {
351 context: "profile export".to_string(),
352 limit: MAX_EXPORT_CODE_BYTES as u64,
353 actual: Some(zip_bytes.len() as u64),
354 });
355 }
356
357 let mut payload = format!("{PROFILE_DATA_PREFIX}\n").into_bytes();
358
359 payload.extend_from_slice(STANDARD.encode(zip_bytes).as_bytes());
360
361 Ok(payload)
362}
363
364pub async fn export_code_in(
370 target_dir: &Path,
371 profile_name: &str,
372 client: &ThunderstoreClient,
373) -> Result<String> {
374 let payload = encode_profile_payload(&build_r2z_in(target_dir, profile_name)?)?;
375
376 client.create_profile_code(&payload).await
377}
378
379pub async fn export_code(
381 base: &Path,
382 game: &str,
383 profile_name: &str,
384 client: &ThunderstoreClient,
385) -> Result<String> {
386 export_code_in(
387 &crate::profile::layout::profile_dir(base, game, profile_name),
388 profile_name,
389 client,
390 )
391 .await
392}
393
394const BLOCKED_IMPORT_EXTENSIONS: [&str; 12] = [
397 ".dll", ".exe", ".bat", ".cmd", ".com", ".ps1", ".sh", ".js", ".vbs", ".msi", ".scr", ".jar",
398];
399
400fn decode_profile_payload(body: &[u8]) -> Result<Vec<u8>> {
403 let text = std::str::from_utf8(body)
404 .map_err(|e| Error::Profile(format!("profile code is not valid UTF-8: {e}")))?;
405
406 let encoded = text
407 .strip_prefix(PROFILE_DATA_PREFIX)
408 .ok_or_else(|| Error::Profile("profile code is missing its #r2modman prefix".to_string()))?;
409
410 STANDARD
411 .decode(encoded.trim())
412 .map_err(|e| Error::Profile(format!("decoding profile code: {e}")))
413}
414
415const MAX_IMPORT_ENTRY_BYTES: u64 = 16 * 1024 * 1024;
420
421const MAX_IMPORT_TOTAL_BYTES: u64 = 64 * 1024 * 1024;
423
424fn read_capped_entry(entry: &mut impl Read, label: &str) -> Result<Vec<u8>> {
431 let mut bytes = Vec::new();
432
433 entry
434 .take(MAX_IMPORT_ENTRY_BYTES + 1)
435 .read_to_end(&mut bytes)
436 .map_err(|e| Error::Profile(format!("reading {label}: {e}")))?;
437
438 if bytes.len() as u64 > MAX_IMPORT_ENTRY_BYTES {
439 return Err(Error::ExtractLimitExceeded(format!(
440 "{label} exceeds the {MAX_IMPORT_ENTRY_BYTES}-byte per-entry import cap"
441 )));
442 }
443
444 Ok(bytes)
445}
446
447pub fn read_export(bytes: &[u8]) -> Result<ExportFormat> {
449 match zip::ZipArchive::new(Cursor::new(bytes.to_vec())) {
450 Ok(mut archive) => {
451 let mut entry = archive
452 .by_name(EXPORT_MANIFEST)
453 .map_err(|e| Error::Profile(format!("archive has no {EXPORT_MANIFEST}: {e}")))?;
454
455 let manifest_bytes = read_capped_entry(&mut entry, EXPORT_MANIFEST)?;
456 let manifest = std::str::from_utf8(&manifest_bytes)
457 .map_err(|e| Error::Profile(format!("{EXPORT_MANIFEST} is not valid UTF-8: {e}")))?;
458
459 parse_export_r2x(manifest)
460 }
461 Err(_) => {
462 let text = std::str::from_utf8(bytes)
463 .map_err(|e| Error::Profile(format!("export is neither a zip nor UTF-8 YAML: {e}")))?;
464
465 parse_export_r2x(text)
466 }
467 }
468}
469
470fn import_config_key(name: &str) -> Option<PathBuf> {
482 let normalized = name.replace('\\', "/");
483
484 if normalized.is_empty() || normalized.ends_with('/') {
485 return None;
486 }
487
488 if normalized == EXPORT_MANIFEST || normalized == "mods.yml" {
489 return None;
490 }
491
492 let lower = normalized.to_ascii_lowercase();
493 let probe = lower.trim_end_matches(['.', ' ']);
494
495 if BLOCKED_IMPORT_EXTENSIONS
496 .iter()
497 .any(|ext| probe.ends_with(ext))
498 {
499 return None;
500 }
501
502 let remapped = match normalized.strip_prefix("config/") {
503 Some(rest) => format!("{BEPINEX_CONFIG_DIR}/{rest}"),
504 None => normalized,
505 };
506
507 let path = PathBuf::from(remapped);
508
509 for component in path.components() {
510 match component {
511 Component::Normal(_) | Component::CurDir => {}
512 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
513 }
514 }
515
516 Some(path)
517}
518
519fn looks_like_zip(bytes: &[u8]) -> bool {
528 bytes.len() >= 2 && &bytes[0..2] == b"PK"
529}
530
531fn refuse_symlink_dest(dest: &Path) -> Result<()> {
540 let is_symlink = fs::symlink_metadata(dest)
541 .map(|metadata| metadata.file_type().is_symlink())
542 .unwrap_or(false);
543
544 if is_symlink {
545 return Err(Error::Profile(format!(
546 "refusing to write through a symlink at {}",
547 dest.display()
548 )));
549 }
550
551 Ok(())
552}
553
554pub fn extract_configs_in(target_dir: &Path, zip_bytes: &[u8]) -> Result<Vec<PathBuf>> {
572 if !looks_like_zip(zip_bytes) {
573 return Ok(Vec::new());
574 }
575
576 let mut archive = zip::ZipArchive::new(Cursor::new(zip_bytes.to_vec()))
577 .map_err(|e| Error::Profile(format!("reading export archive: {e}")))?;
578
579 let mut written = Vec::new();
580 let mut total_written: u64 = 0;
581
582 for index in 0..archive.len() {
583 let mut entry = archive
584 .by_index(index)
585 .map_err(|e| Error::Profile(format!("reading archive entry {index}: {e}")))?;
586
587 let rel = match import_config_key(entry.name()) {
588 Some(rel) => rel,
589 None => continue,
590 };
591
592 let bytes = read_capped_entry(&mut entry, &rel.display().to_string())?;
593
594 total_written += bytes.len() as u64;
595
596 if total_written > MAX_IMPORT_TOTAL_BYTES {
597 return Err(Error::ExtractLimitExceeded(format!(
598 "import exceeds the {MAX_IMPORT_TOTAL_BYTES}-byte cumulative import cap"
599 )));
600 }
601
602 let dest = target_dir.join(&rel);
603
604 if let Some(parent) = dest.parent() {
605 fs::create_dir_all(parent)
606 .map_err(|e| Error::Profile(format!("creating {}: {}", parent.display(), e)))?;
607 }
608
609 refuse_symlink_dest(&dest)?;
610 fs::write(&dest, &bytes)
611 .map_err(|e| Error::Profile(format!("writing {}: {}", dest.display(), e)))?;
612
613 written.push(rel);
614 }
615
616 Ok(written)
617}
618
619#[allow(clippy::too_many_arguments)]
657pub async fn import_r2z_in(
658 target_dir: &Path,
659 cache_base: &Path,
660 eco: &crate::ecosystem::Ecosystem,
661 index: &crate::models::PackageIndex,
662 client: &ThunderstoreClient,
663 game: &str,
664 zip_bytes: &[u8],
665 installed_at_time: u64,
666) -> Result<ImportedMods> {
667 let export = read_export(zip_bytes)?;
668
669 let desired: Vec<String> = export.mods.iter().map(|entry| entry.name.clone()).collect();
670 let protect: Vec<String> = export
671 .mods
672 .iter()
673 .filter(|entry| !entry.enabled)
674 .map(|entry| entry.name.clone())
675 .collect();
676 let mut batch = crate::profile::InstallBatch::new(desired, protect);
677
678 batch.enable_requested = export
682 .mods
683 .iter()
684 .filter(|entry| entry.enabled)
685 .map(|entry| entry.name.clone())
686 .collect();
687
688 let outcome = crate::profile::install_batch(
689 target_dir,
690 cache_base,
691 eco,
692 index,
693 client,
694 game,
695 &batch,
696 installed_at_time,
697 )
698 .await?;
699
700 if let Some((name, error)) = outcome.failed.into_iter().next() {
703 return Err(Error::Profile(format!(
704 "importing {name}: {error}; the import is unfinished (its config files were \
705 not restored), retry the import to finish it"
706 )));
707 }
708
709 extract_configs_in(target_dir, zip_bytes)?;
712
713 Ok(ImportedMods {
715 installed: outcome.succeeded,
716 unchanged: outcome.unchanged,
717 })
718}
719
720#[derive(Debug, Clone, Default, PartialEq, Eq)]
726#[non_exhaustive]
727pub struct ImportedMods {
728 pub installed: Vec<String>,
730 pub unchanged: Vec<String>,
737}
738
739#[allow(clippy::too_many_arguments)]
741pub async fn import_code_in(
742 target_dir: &Path,
743 cache_base: &Path,
744 eco: &crate::ecosystem::Ecosystem,
745 index: &crate::models::PackageIndex,
746 client: &ThunderstoreClient,
747 game: &str,
748 code: &str,
749 installed_at_time: u64,
750) -> Result<ImportedMods> {
751 let body = client.fetch_profile_code(code).await?;
752 let zip_bytes = decode_profile_payload(&body)?;
753
754 import_r2z_in(
755 target_dir,
756 cache_base,
757 eco,
758 index,
759 client,
760 game,
761 &zip_bytes,
762 installed_at_time,
763 )
764 .await
765}
766
767pub fn import_r2modman_dir(source_profile_dir: &Path, target_dir: &Path) -> Result<()> {
794 if !source_profile_dir.is_dir() {
795 return Err(Error::Profile(format!(
796 "{} is not a profile directory",
797 source_profile_dir.display()
798 )));
799 }
800
801 ensure_disjoint_trees(source_profile_dir, target_dir)?;
802
803 copy_tree(source_profile_dir, target_dir)
804}
805
806#[derive(Debug, Clone, Default)]
809#[non_exhaustive]
810pub struct AdoptedProfile {
811 pub adopted: Vec<String>,
813 pub reinstalled: Vec<String>,
820}
821
822#[allow(clippy::too_many_arguments)]
855pub async fn adopt_r2modman_dir_in(
856 source_profile_dir: &Path,
857 target_dir: &Path,
858 cache_base: &Path,
859 eco: &crate::ecosystem::Ecosystem,
860 client: &ThunderstoreClient,
861 game: &str,
862 installed_at_time: u64,
863) -> Result<AdoptedProfile> {
864 import_r2modman_dir(source_profile_dir, target_dir)?;
865
866 let adopted: Vec<String> = crate::profile::modlist::read(target_dir)?
867 .iter()
868 .map(|entry| entry.name.clone())
869 .collect();
870
871 let unrecorded: Vec<String> = adopted
879 .iter()
880 .filter(|name| eco.modloader_package(name).is_some())
881 .filter(|name| !crate::install::state_file_path(target_dir, name).exists())
882 .cloned()
883 .collect();
884
885 if unrecorded.is_empty() {
886 return Ok(AdoptedProfile {
887 adopted,
888 reinstalled: unrecorded,
889 });
890 }
891
892 let index = client.get_manifest().await?;
893
894 for full_name in &unrecorded {
898 crate::profile::install_named_in(
899 target_dir,
900 cache_base,
901 eco,
902 &index,
903 client,
904 game,
905 full_name,
906 true,
907 installed_at_time,
908 )
909 .await?;
910 }
911
912 Ok(AdoptedProfile {
913 adopted,
914 reinstalled: unrecorded,
915 })
916}
917
918#[derive(Debug, Default)]
924#[non_exhaustive]
925pub struct AdoptOutcome {
926 pub adopted: Vec<String>,
928 pub unchanged: Vec<String>,
932 pub remaining: Vec<String>,
940 pub swept: Vec<PathBuf>,
943 pub failed: Vec<(String, Error)>,
951}
952
953pub fn needs_adoption(target_dir: &Path, desired: &[String]) -> Result<bool> {
961 if desired.is_empty() {
962 return Ok(false);
963 }
964
965 let recorded = crate::profile::modlist::read(target_dir).unwrap_or_default();
966
967 Ok(
968 desired
969 .iter()
970 .any(|name| crate::profile::modlist::find(&recorded, name).is_none()),
971 )
972}
973
974#[allow(clippy::too_many_arguments)]
990pub async fn adopt_names_in(
991 target_dir: &Path,
992 cache_base: &Path,
993 eco: &crate::ecosystem::Ecosystem,
994 index: &crate::models::PackageIndex,
995 client: &ThunderstoreClient,
996 game: &str,
997 desired: &[String],
998 installed_at_time: u64,
999) -> Result<AdoptOutcome> {
1000 let batch = crate::profile::plan_install_batch(target_dir, desired, &[])?;
1001
1002 let batch_outcome = crate::profile::install_batch(
1003 target_dir,
1004 cache_base,
1005 eco,
1006 index,
1007 client,
1008 game,
1009 &batch,
1010 installed_at_time,
1011 )
1012 .await?;
1013
1014 let failed = batch_outcome.failed;
1015 let failed_names: std::collections::BTreeSet<&str> =
1016 failed.iter().map(|(name, _)| name.as_str()).collect();
1017
1018 let recorded = crate::profile::modlist::read(target_dir).unwrap_or_default();
1025
1026 let mut adopted: Vec<String> = Vec::new();
1027 let mut unchanged: Vec<String> = Vec::new();
1028 let mut remaining: Vec<String> = Vec::new();
1029
1030 for name in desired {
1031 let Some(entry) = crate::profile::modlist::find(&recorded, name) else {
1032 remaining.push(name.clone());
1033
1034 continue;
1035 };
1036
1037 if failed_names.contains(name.as_str()) {
1038 continue;
1041 }
1042
1043 if entry.installed_at_time == installed_at_time {
1044 adopted.push(name.clone());
1045 } else {
1046 unchanged.push(name.clone());
1047 }
1048 }
1049
1050 adopted.sort();
1051 adopted.dedup();
1052 unchanged.sort();
1053 unchanged.dedup();
1054 remaining.sort();
1055 remaining.dedup();
1056
1057 if !remaining.is_empty() {
1058 return Ok(AdoptOutcome {
1059 adopted,
1060 unchanged,
1061 remaining,
1062 swept: Vec::new(),
1063 failed,
1064 });
1065 }
1066
1067 let game_profile = eco
1068 .game(game)
1069 .and_then(|g| g.profile())
1070 .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
1071 let keep: std::collections::BTreeSet<String> =
1072 recorded.iter().map(|entry| entry.name.clone()).collect();
1073 let swept = crate::install::remove_delisted(game_profile, target_dir, &keep)?;
1074
1075 Ok(AdoptOutcome {
1076 adopted,
1077 unchanged,
1078 remaining,
1079 swept,
1080 failed,
1081 })
1082}
1083
1084pub fn ensure_disjoint_trees(source: &Path, target: &Path) -> Result<()> {
1103 let source_at = resolve_as_far_as_it_exists(source);
1104 let target_at = resolve_as_far_as_it_exists(target);
1105
1106 if source_at == target_at {
1107 return Err(Error::Profile(format!(
1108 "refusing to copy {} onto itself: every file would be truncated to zero \
1109 bytes",
1110 source.display()
1111 )));
1112 }
1113
1114 if source_at.starts_with(&target_at) {
1115 return Err(Error::Profile(format!(
1116 "refusing to copy {} into {}: the source lives inside the destination, so \
1117 the copy would overwrite the tree it is reading",
1118 source.display(),
1119 target.display()
1120 )));
1121 }
1122
1123 if target_at.starts_with(&source_at) {
1124 return Err(Error::Profile(format!(
1125 "refusing to copy {} into {}: the destination lives inside the source, so \
1126 the copy would recurse into itself",
1127 source.display(),
1128 target.display()
1129 )));
1130 }
1131
1132 Ok(())
1133}
1134
1135fn resolve_as_far_as_it_exists(path: &Path) -> PathBuf {
1157 if let Ok(canonical) = path.canonicalize() {
1158 return canonical;
1159 }
1160
1161 if let Ok(canonical) = canonicalize_missing(path) {
1162 return canonical;
1163 }
1164
1165 for ancestor in path.ancestors().skip(1) {
1166 let Ok(canonical_ancestor) = ancestor.canonicalize() else {
1167 continue;
1168 };
1169 let Ok(remainder) = path.strip_prefix(ancestor) else {
1170 continue;
1171 };
1172
1173 return canonical_ancestor.join(remainder);
1174 }
1175
1176 path.to_path_buf()
1177}
1178
1179fn copy_tree(src: &Path, dst: &Path) -> Result<()> {
1185 fs::create_dir_all(dst)
1186 .map_err(|e| Error::Profile(format!("creating {}: {}", dst.display(), e)))?;
1187
1188 for entry in
1189 fs::read_dir(src).map_err(|e| Error::Profile(format!("reading {}: {}", src.display(), e)))?
1190 {
1191 let entry = entry.map_err(|e| Error::Profile(format!("reading an entry: {e}")))?;
1192 let path = entry.path();
1193 let metadata = fs::symlink_metadata(&path)
1194 .map_err(|e| Error::Profile(format!("inspecting {}: {}", path.display(), e)))?;
1195
1196 if metadata.file_type().is_symlink() {
1197 continue;
1198 }
1199
1200 let target = dst.join(entry.file_name());
1201
1202 if metadata.is_dir() {
1203 copy_tree(&path, &target)?;
1204 } else {
1205 refuse_symlink_dest(&target)?;
1206
1207 if same_file(&path, &target) {
1214 continue;
1215 }
1216
1217 fs::copy(&path, &target)
1218 .map_err(|e| Error::Profile(format!("copying to {}: {}", target.display(), e)))?;
1219 }
1220 }
1221
1222 Ok(())
1223}
1224
1225#[derive(Debug, Clone, PartialEq, Eq)]
1238pub enum ImportSource {
1239 R2modmanDir(PathBuf),
1241 Archive(PathBuf),
1243 Code(String),
1245}
1246
1247impl ImportSource {
1248 pub fn classify(source: &str) -> Self {
1255 let path = Path::new(source);
1256
1257 if path.is_dir() {
1258 return Self::R2modmanDir(path.to_path_buf());
1259 }
1260
1261 if path.is_file() {
1262 return Self::Archive(path.to_path_buf());
1263 }
1264
1265 Self::Code(source.to_string())
1266 }
1267
1268 pub fn preserves_versions(&self) -> bool {
1280 matches!(self, Self::R2modmanDir(_))
1281 }
1282}
1283
1284#[derive(Debug)]
1290#[non_exhaustive]
1291pub struct ImportOutcome {
1292 pub source: ImportSource,
1294 pub installed: Vec<String>,
1299 pub unchanged: Vec<String>,
1304 pub reinstalled: Vec<String>,
1308}
1309
1310pub async fn import_in(
1327 target_dir: &Path,
1328 cache_base: &Path,
1329 eco: &crate::ecosystem::Ecosystem,
1330 client: &ThunderstoreClient,
1331 game: &str,
1332 source: &str,
1333 installed_at_time: u64,
1334) -> Result<ImportOutcome> {
1335 let source = ImportSource::classify(source);
1336
1337 if let ImportSource::R2modmanDir(dir) = &source {
1338 let adopted = adopt_r2modman_dir_in(
1339 dir,
1340 target_dir,
1341 cache_base,
1342 eco,
1343 client,
1344 game,
1345 installed_at_time,
1346 )
1347 .await?;
1348 let mut installed = adopted.adopted;
1349
1350 installed.sort();
1351 installed.dedup();
1352
1353 return Ok(ImportOutcome {
1354 source,
1355 installed,
1356 unchanged: Vec::new(),
1357 reinstalled: adopted.reinstalled,
1358 });
1359 }
1360
1361 let index = client.get_manifest().await?;
1362
1363 let imported = match &source {
1364 ImportSource::Archive(path) => {
1365 let bytes =
1366 fs::read(path).map_err(|e| Error::Profile(format!("reading {}: {}", path.display(), e)))?;
1367
1368 import_r2z_in(
1369 target_dir,
1370 cache_base,
1371 eco,
1372 &index,
1373 client,
1374 game,
1375 &bytes,
1376 installed_at_time,
1377 )
1378 .await?
1379 }
1380 ImportSource::Code(code) => {
1381 import_code_in(
1382 target_dir,
1383 cache_base,
1384 eco,
1385 &index,
1386 client,
1387 game,
1388 code,
1389 installed_at_time,
1390 )
1391 .await?
1392 }
1393 ImportSource::R2modmanDir(_) => {
1398 unreachable!("R2modmanDir returns from the early branch above this match")
1399 }
1400 };
1401
1402 Ok(ImportOutcome {
1403 source,
1404 installed: imported.installed,
1405 unchanged: imported.unchanged,
1406 reinstalled: Vec::new(),
1407 })
1408}
1409
1410#[allow(clippy::too_many_arguments)]
1412pub async fn import_r2z(
1413 base: &Path,
1414 game: &str,
1415 profile_name: &str,
1416 eco: &crate::ecosystem::Ecosystem,
1417 index: &crate::models::PackageIndex,
1418 client: &ThunderstoreClient,
1419 zip_bytes: &[u8],
1420 installed_at_time: u64,
1421) -> Result<ImportedMods> {
1422 import_r2z_in(
1423 &crate::profile::layout::profile_dir(base, game, profile_name),
1424 base,
1425 eco,
1426 index,
1427 client,
1428 game,
1429 zip_bytes,
1430 installed_at_time,
1431 )
1432 .await
1433}
1434
1435#[allow(clippy::too_many_arguments)]
1438pub async fn import_code(
1439 base: &Path,
1440 game: &str,
1441 profile_name: &str,
1442 eco: &crate::ecosystem::Ecosystem,
1443 index: &crate::models::PackageIndex,
1444 client: &ThunderstoreClient,
1445 code: &str,
1446 installed_at_time: u64,
1447) -> Result<ImportedMods> {
1448 import_code_in(
1449 &crate::profile::layout::profile_dir(base, game, profile_name),
1450 base,
1451 eco,
1452 index,
1453 client,
1454 game,
1455 code,
1456 installed_at_time,
1457 )
1458 .await
1459}
1460
1461#[cfg(test)]
1462mod tests {
1463 use super::*;
1464
1465 fn profile_mod(name: &str, enabled: bool) -> ProfileMod {
1466 let mut entry = crate::profile::mod_entry_from_version(name, "Author", &sample_version(), 1);
1467 entry.enabled = enabled;
1468
1469 entry
1470 }
1471
1472 fn sample_version() -> crate::models::Version {
1473 let json = r#"{"name":"Mod","full_name":"Author-Mod","description":"d","icon":"i",
1475 "version_number":"1.2.3","dependencies":[],"download_url":"u","downloads":1,
1476 "date_created":"2024-01-01T12:00:00Z","website_url":"w","is_active":true,
1477 "uuid4":"u","file_size":1}"#;
1478
1479 serde_json::from_str(json).unwrap()
1480 }
1481
1482 #[test]
1483 fn import_source_classifies_a_directory_a_file_and_a_code() {
1484 let dir = tempfile::tempdir().unwrap();
1485 let profile = dir.path().join("SomeProfile");
1486 let archive = dir.path().join("export.r2z");
1487
1488 std::fs::create_dir_all(&profile).unwrap();
1489 write_test_file(&archive, b"not really a zip");
1490
1491 assert!(matches!(
1492 ImportSource::classify(profile.to_str().unwrap()),
1493 ImportSource::R2modmanDir(path) if path == profile
1494 ));
1495 assert!(matches!(
1496 ImportSource::classify(archive.to_str().unwrap()),
1497 ImportSource::Archive(path) if path == archive
1498 ));
1499
1500 assert!(matches!(
1504 ImportSource::classify("abc123-def456"),
1505 ImportSource::Code(code) if code == "abc123-def456"
1506 ));
1507 assert!(matches!(
1508 ImportSource::classify("/no/such/path.r2z"),
1509 ImportSource::Code(_)
1510 ));
1511 }
1512
1513 #[test]
1514 fn import_source_reports_whether_versions_survive() {
1515 let dir = tempfile::tempdir().unwrap();
1516
1517 assert!(ImportSource::R2modmanDir(dir.path().to_path_buf()).preserves_versions());
1521 assert!(!ImportSource::Archive(dir.path().join("e.r2z")).preserves_versions());
1522 assert!(!ImportSource::Code("abc123".to_string()).preserves_versions());
1523 }
1524
1525 #[test]
1526 fn export_format_maps_mods_and_preserves_enabled() {
1527 let mods = vec![
1528 profile_mod("Author-A", true),
1529 profile_mod("Author-B", false),
1530 ];
1531
1532 let export = export_format_from_mods("MyProfile", &mods);
1533
1534 assert_eq!(export.profile_name, "MyProfile");
1535 assert_eq!(export.mods.len(), 2);
1536 assert_eq!(export.mods[0].name, "Author-A");
1537 assert_eq!(
1538 export.mods[0].version,
1539 SemVer {
1540 major: 1,
1541 minor: 2,
1542 patch: 3
1543 }
1544 );
1545 assert!(export.mods[0].enabled);
1546 assert!(!export.mods[1].enabled);
1547 }
1548
1549 #[test]
1550 fn serialized_r2x_uses_version_not_version_number() {
1551 let mods = vec![profile_mod("Author-A", true)];
1552 let yaml = export_r2x_to_string(&export_format_from_mods("MyProfile", &mods)).unwrap();
1553
1554 assert!(yaml.contains("profileName: MyProfile"));
1555 assert!(yaml.contains("name: Author-A"));
1556 assert!(yaml.contains("version:"));
1557 assert!(yaml.contains("major: 1"));
1558 assert!(!yaml.contains("versionNumber"));
1560 }
1561
1562 #[test]
1563 fn parses_real_r2x_and_defaults_missing_enabled_to_true() {
1564 let yaml = r#"profileName: Shared
1565mods:
1566 - name: Author-A
1567 version:
1568 major: 2
1569 minor: 0
1570 patch: 1
1571 enabled: false
1572 - name: Author-B
1573 version:
1574 major: 1
1575 minor: 0
1576 patch: 0
1577"#;
1578
1579 let export = parse_export_r2x(yaml).unwrap();
1580
1581 assert_eq!(export.profile_name, "Shared");
1582 assert_eq!(export.mods.len(), 2);
1583 assert!(!export.mods[0].enabled);
1584 assert!(export.mods[1].enabled);
1586 }
1587
1588 #[test]
1589 fn parse_export_r2x_rejects_malformed() {
1590 assert!(parse_export_r2x("not: a valid export").is_err());
1591 }
1592
1593 fn write_test_file(path: &Path, bytes: &[u8]) {
1594 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1595 std::fs::write(path, bytes).unwrap();
1596 }
1597
1598 #[test]
1599 fn is_pruned_matches_r2modman_rules() {
1600 assert!(is_pruned(Path::new("dotnet/x.json")));
1602 assert!(is_pruned(Path::new("_state/Author-Mod-state.yml")));
1603 assert!(is_pruned(Path::new("BepInEx/config/BepInEx.cfg")));
1604 assert!(is_pruned(Path::new(
1605 "BepInEx/plugins/Author-Mod/manifest.json"
1606 )));
1607 assert!(is_pruned(Path::new("MelonLoader/x.cfg")));
1608 assert!(is_pruned(Path::new("GDWeave/core/a.json")));
1609 assert!(is_pruned(Path::new("GDWeave/mods/b.json")));
1610 assert!(is_pruned(Path::new("GDWeave/GDWeave.log")));
1611 assert!(is_pruned(Path::new("mods.yml")));
1612
1613 assert!(!is_pruned(Path::new(
1615 "BepInEx/plugins/Author-Mod/settings.cfg"
1616 )));
1617 assert!(!is_pruned(Path::new("GDWeave/other.json")));
1618 assert!(!is_pruned(Path::new("notes.txt")));
1619 }
1620
1621 #[test]
1622 fn is_config_file_matches_supported_extensions() {
1623 assert!(is_config_file(Path::new("a/b.cfg")));
1624 assert!(is_config_file(Path::new("a/b.YAML")));
1625 assert!(is_config_file(Path::new("b.ini")));
1626 assert!(!is_config_file(Path::new("a/b.dll")));
1627 assert!(!is_config_file(Path::new("a/b")));
1628 }
1629
1630 #[test]
1631 fn collect_export_paths_selects_configs_and_skips_pruned() {
1632 let dir = tempfile::tempdir().unwrap();
1633 let root = dir.path();
1634
1635 write_test_file(&root.join("mods.yml"), b"[]");
1636 write_test_file(&root.join("BepInEx/config/BepInEx.cfg"), b"cfg");
1637 write_test_file(&root.join("BepInEx/plugins/Author-Mod/settings.cfg"), b"s");
1638 write_test_file(
1639 &root.join("BepInEx/plugins/Author-Mod/manifest.json"),
1640 b"{}",
1641 );
1642 write_test_file(&root.join("BepInEx/plugins/Author-Mod/Mod.dll"), b"dll");
1643 write_test_file(&root.join("_state/Author-Mod-state.yml"), b"x");
1644
1645 let paths = collect_export_paths(root).unwrap();
1646 let as_str: Vec<String> = paths
1647 .iter()
1648 .map(|p| p.to_string_lossy().replace('\\', "/"))
1649 .collect();
1650
1651 assert_eq!(
1652 as_str,
1653 vec!["BepInEx/plugins/Author-Mod/settings.cfg".to_string()]
1654 );
1655 }
1656
1657 #[test]
1658 fn build_r2z_contains_manifest_and_configs() {
1659 let dir = tempfile::tempdir().unwrap();
1660 let root = dir.path();
1661
1662 crate::profile::modlist::write(root, &[profile_mod("Author-A", true)]).unwrap();
1663 write_test_file(&root.join("BepInEx/config/BepInEx.cfg"), b"shared");
1664 write_test_file(&root.join("BepInEx/plugins/Author-A/settings.cfg"), b"mine");
1665 write_test_file(&root.join("BepInEx/plugins/Author-A/Mod.dll"), b"dll");
1666
1667 let bytes = build_r2z_in(root, "MyProfile").unwrap();
1668
1669 let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
1670 let names: Vec<String> = (0..zip.len())
1671 .map(|i| zip.by_index(i).unwrap().name().replace('\\', "/"))
1672 .collect();
1673
1674 assert!(names.contains(&"export.r2x".to_string()));
1675 assert!(names.contains(&"config/BepInEx.cfg".to_string()));
1677 assert!(names.contains(&"BepInEx/plugins/Author-A/settings.cfg".to_string()));
1679 assert!(!names.iter().any(|n| n.ends_with("Mod.dll")));
1681 assert!(!names.contains(&"mods.yml".to_string()));
1682
1683 let mut manifest = String::new();
1684 std::io::Read::read_to_string(&mut zip.by_name("export.r2x").unwrap(), &mut manifest).unwrap();
1685
1686 let export = parse_export_r2x(&manifest).unwrap();
1687
1688 assert_eq!(export.profile_name, "MyProfile");
1689 assert_eq!(export.mods[0].name, "Author-A");
1690 }
1691
1692 #[test]
1693 fn export_to_file_uses_name_and_timestamp() {
1694 let dir = tempfile::tempdir().unwrap();
1695 let root = dir.path().join("profile");
1696 let out = dir.path().join("exports");
1697
1698 crate::profile::modlist::write(&root, &[profile_mod("Author-A", true)]).unwrap();
1699
1700 let path = export_to_file_in(&root, "MyProfile", &out, 1_700_000_000_000).unwrap();
1701
1702 assert_eq!(
1703 path.file_name().unwrap().to_string_lossy(),
1704 "MyProfile_1700000000000.r2z"
1705 );
1706 assert!(path.exists());
1707 }
1708
1709 #[test]
1710 fn export_to_file_rejects_unsafe_profile_name() {
1711 let dir = tempfile::tempdir().unwrap();
1712 let root = dir.path().join("profile");
1713 let out = dir.path().join("exports");
1714
1715 crate::profile::modlist::write(&root, &[profile_mod("Author-A", true)]).unwrap();
1716
1717 assert!(export_to_file_in(&root, "../evil", &out, 1).is_err());
1718 assert!(!dir.path().join("evil_1.r2z").exists());
1719 }
1720
1721 #[test]
1722 fn encode_profile_payload_prefixes_and_base64s() {
1723 let payload = encode_profile_payload(b"ABC").unwrap();
1724
1725 assert_eq!(payload, b"#r2modman\nQUJD");
1726 }
1727
1728 #[test]
1729 fn encode_profile_payload_rejects_oversize() {
1730 let big = vec![0u8; MAX_EXPORT_CODE_BYTES + 1];
1731
1732 assert!(matches!(
1733 encode_profile_payload(&big),
1734 Err(Error::SizeLimitExceeded { .. })
1735 ));
1736 }
1737
1738 #[test]
1739 fn decode_profile_payload_round_trips_and_rejects_bad_prefix() {
1740 let payload = encode_profile_payload(b"ABC").unwrap();
1741
1742 assert_eq!(decode_profile_payload(&payload).unwrap(), b"ABC");
1743 assert!(decode_profile_payload(b"nope\nQUJD").is_err());
1744 }
1745
1746 #[test]
1747 fn import_config_key_remaps_config_and_blocks_unsafe() {
1748 assert_eq!(
1750 import_config_key("config/BepInEx.cfg"),
1751 Some(PathBuf::from("BepInEx/config/BepInEx.cfg"))
1752 );
1753 assert_eq!(
1755 import_config_key("BepInEx/plugins/Author-A/settings.cfg"),
1756 Some(PathBuf::from("BepInEx/plugins/Author-A/settings.cfg"))
1757 );
1758 assert_eq!(import_config_key("export.r2x"), None);
1760 assert_eq!(import_config_key("mods.yml"), None);
1761 assert_eq!(import_config_key("BepInEx/plugins/evil.dll"), None);
1763 assert_eq!(import_config_key("../escape.cfg"), None);
1765 }
1766
1767 #[test]
1768 fn import_config_key_blocks_trailing_dot_and_space_bypass() {
1769 let blocked = [
1773 "evil.dll.",
1774 "evil.dll ",
1775 "evil.dll. . ",
1776 "evil.DLL",
1777 "evil.cfg.dll",
1778 ];
1779
1780 for name in blocked {
1781 assert_eq!(
1782 import_config_key(name),
1783 None,
1784 "{name} should have been blocked"
1785 );
1786 }
1787
1788 assert_eq!(import_config_key("a.cfg"), Some(PathBuf::from("a.cfg")));
1790 }
1791
1792 #[test]
1793 fn read_export_accepts_r2z_and_bare_r2x() {
1794 let dir = tempfile::tempdir().unwrap();
1795 let root = dir.path();
1796
1797 crate::profile::modlist::write(root, &[profile_mod("Author-A", true)]).unwrap();
1798
1799 let zip_bytes = build_r2z_in(root, "MyProfile").unwrap();
1800
1801 assert_eq!(read_export(&zip_bytes).unwrap().profile_name, "MyProfile");
1802
1803 let yaml = export_r2x_to_string(&export_format_from_mods("Bare", &[])).unwrap();
1804
1805 assert_eq!(read_export(yaml.as_bytes()).unwrap().profile_name, "Bare");
1806 }
1807
1808 #[test]
1809 fn extract_configs_writes_remapped_files_only() {
1810 let dir = tempfile::tempdir().unwrap();
1811 let source = dir.path().join("source");
1812 let target = dir.path().join("target");
1813
1814 crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
1815 write_test_file(&source.join("BepInEx/config/BepInEx.cfg"), b"shared");
1816 write_test_file(
1817 &source.join("BepInEx/plugins/Author-A/settings.cfg"),
1818 b"mine",
1819 );
1820
1821 let zip_bytes = build_r2z_in(&source, "MyProfile").unwrap();
1822 let written = extract_configs_in(&target, &zip_bytes).unwrap();
1823
1824 assert!(target.join("BepInEx/config/BepInEx.cfg").exists());
1825 assert!(
1826 target
1827 .join("BepInEx/plugins/Author-A/settings.cfg")
1828 .exists()
1829 );
1830 assert!(!target.join("export.r2x").exists());
1832 assert_eq!(written.len(), 2);
1833 }
1834
1835 #[test]
1836 fn extract_configs_treats_non_zip_input_as_no_configs() {
1837 let dir = tempfile::tempdir().unwrap();
1838 let target = dir.path().join("target");
1839
1840 let yaml = export_r2x_to_string(&export_format_from_mods("Bare", &[])).unwrap();
1842
1843 let written = extract_configs_in(&target, yaml.as_bytes()).unwrap();
1844
1845 assert!(written.is_empty());
1846 }
1847
1848 #[test]
1849 fn extract_configs_still_rejects_a_genuinely_corrupt_zip() {
1850 let dir = tempfile::tempdir().unwrap();
1851 let target = dir.path().join("target");
1852
1853 let corrupt = b"PK\x03\x04not a real zip".to_vec();
1855
1856 assert!(extract_configs_in(&target, &corrupt).is_err());
1857 }
1858
1859 #[test]
1860 #[cfg(unix)]
1861 fn extract_configs_refuses_to_write_through_a_symlink() {
1862 use std::os::unix::fs::symlink;
1863
1864 let outside = tempfile::tempdir().unwrap();
1865 let secret = outside.path().join("secret.cfg");
1866 std::fs::write(&secret, b"original").unwrap();
1867
1868 let source = tempfile::tempdir().unwrap();
1869
1870 crate::profile::modlist::write(source.path(), &[profile_mod("Author-A", true)]).unwrap();
1871 write_test_file(
1872 &source.path().join("BepInEx/config/BepInEx.cfg"),
1873 b"new-content",
1874 );
1875
1876 let zip_bytes = build_r2z_in(source.path(), "MyProfile").unwrap();
1877
1878 let target = tempfile::tempdir().unwrap();
1881 let dest = target.path().join("BepInEx/config/BepInEx.cfg");
1882
1883 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1884 symlink(&secret, &dest).unwrap();
1885
1886 let err = extract_configs_in(target.path(), &zip_bytes).unwrap_err();
1887
1888 assert!(matches!(err, Error::Profile(_)));
1889 assert_eq!(std::fs::read_to_string(&secret).unwrap(), "original");
1891 }
1892
1893 fn zip_with_one_zero_filled_entry(name: &str, inflated_len: usize) -> Vec<u8> {
1896 let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
1897 let options: FileOptions<'_, ()> =
1898 FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
1899
1900 zip.start_file(name, options).unwrap();
1901 zip.write_all(&vec![0u8; inflated_len]).unwrap();
1902
1903 zip.finish().unwrap().into_inner()
1904 }
1905
1906 #[test]
1907 fn extract_configs_rejects_entry_exceeding_the_per_entry_cap() {
1908 let bytes = zip_with_one_zero_filled_entry(
1909 "BepInEx/config/Big.cfg",
1910 (MAX_IMPORT_ENTRY_BYTES + 1) as usize,
1911 );
1912
1913 let dir = tempfile::tempdir().unwrap();
1914 let err = extract_configs_in(dir.path(), &bytes).unwrap_err();
1915
1916 assert!(matches!(err, Error::ExtractLimitExceeded(_)));
1917 }
1918
1919 #[test]
1920 fn extract_configs_rejects_import_exceeding_the_cumulative_cap() {
1921 let per_entry_len = (MAX_IMPORT_ENTRY_BYTES - 1024) as usize;
1924 let entries_needed = (MAX_IMPORT_TOTAL_BYTES / per_entry_len as u64) + 1;
1925
1926 let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
1927 let options: FileOptions<'_, ()> =
1928 FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
1929 let chunk = vec![0u8; per_entry_len];
1930
1931 for i in 0..entries_needed {
1932 zip
1933 .start_file(format!("BepInEx/config/{i}.cfg"), options)
1934 .unwrap();
1935 zip.write_all(&chunk).unwrap();
1936 }
1937
1938 let bytes = zip.finish().unwrap().into_inner();
1939
1940 let dir = tempfile::tempdir().unwrap();
1941 let err = extract_configs_in(dir.path(), &bytes).unwrap_err();
1942
1943 assert!(matches!(err, Error::ExtractLimitExceeded(_)));
1944 }
1945
1946 #[test]
1947 fn import_r2modman_dir_adopts_an_existing_profile() {
1948 let dir = tempfile::tempdir().unwrap();
1949 let source = dir.path().join("source");
1950 let target = dir.path().join("target");
1951
1952 crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
1953 write_test_file(&source.join("BepInEx/plugins/Author-A/Mod.dll"), b"dll");
1954
1955 import_r2modman_dir(&source, &target).unwrap();
1956
1957 assert!(target.join("BepInEx/plugins/Author-A/Mod.dll").exists());
1958 assert_eq!(crate::profile::modlist::read(&target).unwrap().len(), 1);
1959 assert!(import_r2modman_dir(&dir.path().join("missing"), &target).is_err());
1960 }
1961
1962 #[test]
1963 #[cfg(unix)]
1964 fn import_r2modman_dir_refuses_to_copy_through_a_symlink() {
1965 use std::os::unix::fs::symlink;
1966
1967 let outside = tempfile::tempdir().unwrap();
1968 let secret = outside.path().join("secret.cfg");
1969 std::fs::write(&secret, b"original").unwrap();
1970
1971 let dir = tempfile::tempdir().unwrap();
1972 let source = dir.path().join("source");
1973 let target = dir.path().join("target");
1974
1975 crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
1976 write_test_file(
1977 &source.join("BepInEx/plugins/Author-A/settings.cfg"),
1978 b"new-content",
1979 );
1980
1981 let dest = target.join("BepInEx/plugins/Author-A/settings.cfg");
1983
1984 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1985 symlink(&secret, &dest).unwrap();
1986
1987 let err = import_r2modman_dir(&source, &target).unwrap_err();
1988
1989 assert!(matches!(err, Error::Profile(_)));
1990 assert_eq!(std::fs::read_to_string(&secret).unwrap(), "original");
1992 }
1993
1994 #[test]
1995 fn copy_tree_skips_a_same_file_copy_instead_of_truncating_it() {
1996 let dir = tempfile::tempdir().unwrap();
1997 let root = dir.path();
1998
1999 write_test_file(&root.join("sub/file.txt"), b"original bytes");
2000
2001 copy_tree(root, root).unwrap();
2008
2009 let bytes = std::fs::read(root.join("sub/file.txt")).unwrap();
2010
2011 assert_eq!(
2012 bytes, b"original bytes",
2013 "a same-file copy must be skipped, not truncate the file to zero bytes"
2014 );
2015 }
2016}