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)]
650pub async fn import_r2z_in(
651 target_dir: &Path,
652 cache_base: &Path,
653 eco: &crate::ecosystem::Ecosystem,
654 index: &crate::models::PackageIndex,
655 client: &ThunderstoreClient,
656 game: &str,
657 zip_bytes: &[u8],
658 installed_at_time: u64,
659) -> Result<Vec<String>> {
660 let export = read_export(zip_bytes)?;
661
662 let desired: Vec<String> = export.mods.iter().map(|entry| entry.name.clone()).collect();
663 let protect: Vec<String> = export
664 .mods
665 .iter()
666 .filter(|entry| !entry.enabled)
667 .map(|entry| entry.name.clone())
668 .collect();
669 let batch = crate::profile::InstallBatch::new(desired, protect);
670
671 let outcome = crate::profile::install_batch(
672 target_dir,
673 cache_base,
674 eco,
675 index,
676 client,
677 game,
678 &batch,
679 installed_at_time,
680 )
681 .await?;
682
683 if let Some((name, error)) = outcome.failed.into_iter().next() {
684 return Err(Error::Profile(format!("importing {name}: {error}")));
685 }
686
687 extract_configs_in(target_dir, zip_bytes)?;
690
691 let mut installed = outcome.succeeded;
692
693 installed.sort();
694 installed.dedup();
695
696 Ok(installed)
697}
698
699#[allow(clippy::too_many_arguments)]
701pub async fn import_code_in(
702 target_dir: &Path,
703 cache_base: &Path,
704 eco: &crate::ecosystem::Ecosystem,
705 index: &crate::models::PackageIndex,
706 client: &ThunderstoreClient,
707 game: &str,
708 code: &str,
709 installed_at_time: u64,
710) -> Result<Vec<String>> {
711 let body = client.fetch_profile_code(code).await?;
712 let zip_bytes = decode_profile_payload(&body)?;
713
714 import_r2z_in(
715 target_dir,
716 cache_base,
717 eco,
718 index,
719 client,
720 game,
721 &zip_bytes,
722 installed_at_time,
723 )
724 .await
725}
726
727pub fn import_r2modman_dir(source_profile_dir: &Path, target_dir: &Path) -> Result<()> {
754 if !source_profile_dir.is_dir() {
755 return Err(Error::Profile(format!(
756 "{} is not a profile directory",
757 source_profile_dir.display()
758 )));
759 }
760
761 ensure_disjoint_trees(source_profile_dir, target_dir)?;
762
763 copy_tree(source_profile_dir, target_dir)
764}
765
766#[derive(Debug, Clone, Default)]
769#[non_exhaustive]
770pub struct AdoptedProfile {
771 pub adopted: Vec<String>,
773 pub reinstalled: Vec<String>,
780}
781
782#[allow(clippy::too_many_arguments)]
815pub async fn adopt_r2modman_dir_in(
816 source_profile_dir: &Path,
817 target_dir: &Path,
818 cache_base: &Path,
819 eco: &crate::ecosystem::Ecosystem,
820 client: &ThunderstoreClient,
821 game: &str,
822 installed_at_time: u64,
823) -> Result<AdoptedProfile> {
824 import_r2modman_dir(source_profile_dir, target_dir)?;
825
826 let adopted: Vec<String> = crate::profile::modlist::read(target_dir)?
827 .iter()
828 .map(|entry| entry.name.clone())
829 .collect();
830
831 let unrecorded: Vec<String> = adopted
839 .iter()
840 .filter(|name| eco.modloader_package(name).is_some())
841 .filter(|name| !crate::install::state_file_path(target_dir, name).exists())
842 .cloned()
843 .collect();
844
845 if unrecorded.is_empty() {
846 return Ok(AdoptedProfile {
847 adopted,
848 reinstalled: unrecorded,
849 });
850 }
851
852 let index = client.get_manifest().await?;
853
854 for full_name in &unrecorded {
855 crate::profile::install_mod_in(
856 target_dir,
857 cache_base,
858 eco,
859 &index,
860 client,
861 game,
862 full_name,
863 installed_at_time,
864 )
865 .await?;
866 }
867
868 Ok(AdoptedProfile {
869 adopted,
870 reinstalled: unrecorded,
871 })
872}
873
874#[derive(Debug, Default)]
880#[non_exhaustive]
881pub struct AdoptOutcome {
882 pub adopted: Vec<String>,
884 pub remaining: Vec<String>,
888 pub swept: Vec<PathBuf>,
891 pub failed: Vec<(String, Error)>,
899}
900
901pub fn needs_adoption(target_dir: &Path, desired: &[String]) -> Result<bool> {
909 if desired.is_empty() {
910 return Ok(false);
911 }
912
913 let recorded = crate::profile::modlist::read(target_dir).unwrap_or_default();
914
915 Ok(
916 desired
917 .iter()
918 .any(|name| crate::profile::modlist::find(&recorded, name).is_none()),
919 )
920}
921
922#[allow(clippy::too_many_arguments)]
932pub async fn adopt_names_in(
933 target_dir: &Path,
934 cache_base: &Path,
935 eco: &crate::ecosystem::Ecosystem,
936 index: &crate::models::PackageIndex,
937 client: &ThunderstoreClient,
938 game: &str,
939 desired: &[String],
940 installed_at_time: u64,
941) -> Result<AdoptOutcome> {
942 let batch = crate::profile::plan_install_batch(target_dir, desired, &[])?;
943
944 let batch_outcome = crate::profile::install_batch(
945 target_dir,
946 cache_base,
947 eco,
948 index,
949 client,
950 game,
951 &batch,
952 installed_at_time,
953 )
954 .await?;
955
956 let failed = batch_outcome.failed;
957
958 let recorded = crate::profile::modlist::read(target_dir).unwrap_or_default();
962
963 let mut adopted: Vec<String> = Vec::new();
964 let mut remaining: Vec<String> = Vec::new();
965
966 for name in desired {
967 match crate::profile::modlist::find(&recorded, name) {
968 Some(_) => adopted.push(name.clone()),
969 None => remaining.push(name.clone()),
970 }
971 }
972
973 adopted.sort();
974 remaining.sort();
975
976 if !remaining.is_empty() {
977 return Ok(AdoptOutcome {
978 adopted,
979 remaining,
980 swept: Vec::new(),
981 failed,
982 });
983 }
984
985 let game_profile = eco
986 .game(game)
987 .and_then(|g| g.profile())
988 .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
989 let keep: std::collections::BTreeSet<String> =
990 recorded.iter().map(|entry| entry.name.clone()).collect();
991 let swept = crate::install::remove_delisted(game_profile, target_dir, &keep)?;
992
993 Ok(AdoptOutcome {
994 adopted,
995 remaining,
996 swept,
997 failed,
998 })
999}
1000
1001pub fn ensure_disjoint_trees(source: &Path, target: &Path) -> Result<()> {
1020 let source_at = resolve_as_far_as_it_exists(source);
1021 let target_at = resolve_as_far_as_it_exists(target);
1022
1023 if source_at == target_at {
1024 return Err(Error::Profile(format!(
1025 "refusing to copy {} onto itself: every file would be truncated to zero \
1026 bytes",
1027 source.display()
1028 )));
1029 }
1030
1031 if source_at.starts_with(&target_at) {
1032 return Err(Error::Profile(format!(
1033 "refusing to copy {} into {}: the source lives inside the destination, so \
1034 the copy would overwrite the tree it is reading",
1035 source.display(),
1036 target.display()
1037 )));
1038 }
1039
1040 if target_at.starts_with(&source_at) {
1041 return Err(Error::Profile(format!(
1042 "refusing to copy {} into {}: the destination lives inside the source, so \
1043 the copy would recurse into itself",
1044 source.display(),
1045 target.display()
1046 )));
1047 }
1048
1049 Ok(())
1050}
1051
1052fn resolve_as_far_as_it_exists(path: &Path) -> PathBuf {
1074 if let Ok(canonical) = path.canonicalize() {
1075 return canonical;
1076 }
1077
1078 if let Ok(canonical) = canonicalize_missing(path) {
1079 return canonical;
1080 }
1081
1082 for ancestor in path.ancestors().skip(1) {
1083 let Ok(canonical_ancestor) = ancestor.canonicalize() else {
1084 continue;
1085 };
1086 let Ok(remainder) = path.strip_prefix(ancestor) else {
1087 continue;
1088 };
1089
1090 return canonical_ancestor.join(remainder);
1091 }
1092
1093 path.to_path_buf()
1094}
1095
1096fn copy_tree(src: &Path, dst: &Path) -> Result<()> {
1102 fs::create_dir_all(dst)
1103 .map_err(|e| Error::Profile(format!("creating {}: {}", dst.display(), e)))?;
1104
1105 for entry in
1106 fs::read_dir(src).map_err(|e| Error::Profile(format!("reading {}: {}", src.display(), e)))?
1107 {
1108 let entry = entry.map_err(|e| Error::Profile(format!("reading an entry: {e}")))?;
1109 let path = entry.path();
1110 let metadata = fs::symlink_metadata(&path)
1111 .map_err(|e| Error::Profile(format!("inspecting {}: {}", path.display(), e)))?;
1112
1113 if metadata.file_type().is_symlink() {
1114 continue;
1115 }
1116
1117 let target = dst.join(entry.file_name());
1118
1119 if metadata.is_dir() {
1120 copy_tree(&path, &target)?;
1121 } else {
1122 refuse_symlink_dest(&target)?;
1123
1124 if same_file(&path, &target) {
1131 continue;
1132 }
1133
1134 fs::copy(&path, &target)
1135 .map_err(|e| Error::Profile(format!("copying to {}: {}", target.display(), e)))?;
1136 }
1137 }
1138
1139 Ok(())
1140}
1141
1142#[derive(Debug, Clone, PartialEq, Eq)]
1155pub enum ImportSource {
1156 R2modmanDir(PathBuf),
1158 Archive(PathBuf),
1160 Code(String),
1162}
1163
1164impl ImportSource {
1165 pub fn classify(source: &str) -> Self {
1172 let path = Path::new(source);
1173
1174 if path.is_dir() {
1175 return Self::R2modmanDir(path.to_path_buf());
1176 }
1177
1178 if path.is_file() {
1179 return Self::Archive(path.to_path_buf());
1180 }
1181
1182 Self::Code(source.to_string())
1183 }
1184
1185 pub fn preserves_versions(&self) -> bool {
1197 matches!(self, Self::R2modmanDir(_))
1198 }
1199}
1200
1201#[derive(Debug)]
1207#[non_exhaustive]
1208pub struct ImportOutcome {
1209 pub source: ImportSource,
1211 pub installed: Vec<String>,
1213 pub reinstalled: Vec<String>,
1217}
1218
1219pub async fn import_in(
1236 target_dir: &Path,
1237 cache_base: &Path,
1238 eco: &crate::ecosystem::Ecosystem,
1239 client: &ThunderstoreClient,
1240 game: &str,
1241 source: &str,
1242 installed_at_time: u64,
1243) -> Result<ImportOutcome> {
1244 let source = ImportSource::classify(source);
1245
1246 if let ImportSource::R2modmanDir(dir) = &source {
1247 let adopted = adopt_r2modman_dir_in(
1248 dir,
1249 target_dir,
1250 cache_base,
1251 eco,
1252 client,
1253 game,
1254 installed_at_time,
1255 )
1256 .await?;
1257
1258 return Ok(ImportOutcome {
1259 source,
1260 installed: adopted.adopted,
1261 reinstalled: adopted.reinstalled,
1262 });
1263 }
1264
1265 let index = client.get_manifest().await?;
1266
1267 let installed = match &source {
1268 ImportSource::Archive(path) => {
1269 let bytes =
1270 fs::read(path).map_err(|e| Error::Profile(format!("reading {}: {}", path.display(), e)))?;
1271
1272 import_r2z_in(
1273 target_dir,
1274 cache_base,
1275 eco,
1276 &index,
1277 client,
1278 game,
1279 &bytes,
1280 installed_at_time,
1281 )
1282 .await?
1283 }
1284 ImportSource::Code(code) => {
1285 import_code_in(
1286 target_dir,
1287 cache_base,
1288 eco,
1289 &index,
1290 client,
1291 game,
1292 code,
1293 installed_at_time,
1294 )
1295 .await?
1296 }
1297 ImportSource::R2modmanDir(_) => {
1302 unreachable!("R2modmanDir returns from the early branch above this match")
1303 }
1304 };
1305
1306 Ok(ImportOutcome {
1307 source,
1308 installed,
1309 reinstalled: Vec::new(),
1310 })
1311}
1312
1313#[allow(clippy::too_many_arguments)]
1315pub async fn import_r2z(
1316 base: &Path,
1317 game: &str,
1318 profile_name: &str,
1319 eco: &crate::ecosystem::Ecosystem,
1320 index: &crate::models::PackageIndex,
1321 client: &ThunderstoreClient,
1322 zip_bytes: &[u8],
1323 installed_at_time: u64,
1324) -> Result<Vec<String>> {
1325 import_r2z_in(
1326 &crate::profile::layout::profile_dir(base, game, profile_name),
1327 base,
1328 eco,
1329 index,
1330 client,
1331 game,
1332 zip_bytes,
1333 installed_at_time,
1334 )
1335 .await
1336}
1337
1338#[allow(clippy::too_many_arguments)]
1341pub async fn import_code(
1342 base: &Path,
1343 game: &str,
1344 profile_name: &str,
1345 eco: &crate::ecosystem::Ecosystem,
1346 index: &crate::models::PackageIndex,
1347 client: &ThunderstoreClient,
1348 code: &str,
1349 installed_at_time: u64,
1350) -> Result<Vec<String>> {
1351 import_code_in(
1352 &crate::profile::layout::profile_dir(base, game, profile_name),
1353 base,
1354 eco,
1355 index,
1356 client,
1357 game,
1358 code,
1359 installed_at_time,
1360 )
1361 .await
1362}
1363
1364#[cfg(test)]
1365mod tests {
1366 use super::*;
1367
1368 fn profile_mod(name: &str, enabled: bool) -> ProfileMod {
1369 let mut entry = crate::profile::mod_entry_from_version(name, "Author", &sample_version(), 1);
1370 entry.enabled = enabled;
1371
1372 entry
1373 }
1374
1375 fn sample_version() -> crate::models::Version {
1376 let json = r#"{"name":"Mod","full_name":"Author-Mod","description":"d","icon":"i",
1378 "version_number":"1.2.3","dependencies":[],"download_url":"u","downloads":1,
1379 "date_created":"2024-01-01T12:00:00Z","website_url":"w","is_active":true,
1380 "uuid4":"u","file_size":1}"#;
1381
1382 serde_json::from_str(json).unwrap()
1383 }
1384
1385 #[test]
1386 fn import_source_classifies_a_directory_a_file_and_a_code() {
1387 let dir = tempfile::tempdir().unwrap();
1388 let profile = dir.path().join("SomeProfile");
1389 let archive = dir.path().join("export.r2z");
1390
1391 std::fs::create_dir_all(&profile).unwrap();
1392 write_test_file(&archive, b"not really a zip");
1393
1394 assert!(matches!(
1395 ImportSource::classify(profile.to_str().unwrap()),
1396 ImportSource::R2modmanDir(path) if path == profile
1397 ));
1398 assert!(matches!(
1399 ImportSource::classify(archive.to_str().unwrap()),
1400 ImportSource::Archive(path) if path == archive
1401 ));
1402
1403 assert!(matches!(
1407 ImportSource::classify("abc123-def456"),
1408 ImportSource::Code(code) if code == "abc123-def456"
1409 ));
1410 assert!(matches!(
1411 ImportSource::classify("/no/such/path.r2z"),
1412 ImportSource::Code(_)
1413 ));
1414 }
1415
1416 #[test]
1417 fn import_source_reports_whether_versions_survive() {
1418 let dir = tempfile::tempdir().unwrap();
1419
1420 assert!(ImportSource::R2modmanDir(dir.path().to_path_buf()).preserves_versions());
1424 assert!(!ImportSource::Archive(dir.path().join("e.r2z")).preserves_versions());
1425 assert!(!ImportSource::Code("abc123".to_string()).preserves_versions());
1426 }
1427
1428 #[test]
1429 fn export_format_maps_mods_and_preserves_enabled() {
1430 let mods = vec![
1431 profile_mod("Author-A", true),
1432 profile_mod("Author-B", false),
1433 ];
1434
1435 let export = export_format_from_mods("MyProfile", &mods);
1436
1437 assert_eq!(export.profile_name, "MyProfile");
1438 assert_eq!(export.mods.len(), 2);
1439 assert_eq!(export.mods[0].name, "Author-A");
1440 assert_eq!(
1441 export.mods[0].version,
1442 SemVer {
1443 major: 1,
1444 minor: 2,
1445 patch: 3
1446 }
1447 );
1448 assert!(export.mods[0].enabled);
1449 assert!(!export.mods[1].enabled);
1450 }
1451
1452 #[test]
1453 fn serialized_r2x_uses_version_not_version_number() {
1454 let mods = vec![profile_mod("Author-A", true)];
1455 let yaml = export_r2x_to_string(&export_format_from_mods("MyProfile", &mods)).unwrap();
1456
1457 assert!(yaml.contains("profileName: MyProfile"));
1458 assert!(yaml.contains("name: Author-A"));
1459 assert!(yaml.contains("version:"));
1460 assert!(yaml.contains("major: 1"));
1461 assert!(!yaml.contains("versionNumber"));
1463 }
1464
1465 #[test]
1466 fn parses_real_r2x_and_defaults_missing_enabled_to_true() {
1467 let yaml = r#"profileName: Shared
1468mods:
1469 - name: Author-A
1470 version:
1471 major: 2
1472 minor: 0
1473 patch: 1
1474 enabled: false
1475 - name: Author-B
1476 version:
1477 major: 1
1478 minor: 0
1479 patch: 0
1480"#;
1481
1482 let export = parse_export_r2x(yaml).unwrap();
1483
1484 assert_eq!(export.profile_name, "Shared");
1485 assert_eq!(export.mods.len(), 2);
1486 assert!(!export.mods[0].enabled);
1487 assert!(export.mods[1].enabled);
1489 }
1490
1491 #[test]
1492 fn parse_export_r2x_rejects_malformed() {
1493 assert!(parse_export_r2x("not: a valid export").is_err());
1494 }
1495
1496 fn write_test_file(path: &Path, bytes: &[u8]) {
1497 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1498 std::fs::write(path, bytes).unwrap();
1499 }
1500
1501 #[test]
1502 fn is_pruned_matches_r2modman_rules() {
1503 assert!(is_pruned(Path::new("dotnet/x.json")));
1505 assert!(is_pruned(Path::new("_state/Author-Mod-state.yml")));
1506 assert!(is_pruned(Path::new("BepInEx/config/BepInEx.cfg")));
1507 assert!(is_pruned(Path::new(
1508 "BepInEx/plugins/Author-Mod/manifest.json"
1509 )));
1510 assert!(is_pruned(Path::new("MelonLoader/x.cfg")));
1511 assert!(is_pruned(Path::new("GDWeave/core/a.json")));
1512 assert!(is_pruned(Path::new("GDWeave/mods/b.json")));
1513 assert!(is_pruned(Path::new("GDWeave/GDWeave.log")));
1514 assert!(is_pruned(Path::new("mods.yml")));
1515
1516 assert!(!is_pruned(Path::new(
1518 "BepInEx/plugins/Author-Mod/settings.cfg"
1519 )));
1520 assert!(!is_pruned(Path::new("GDWeave/other.json")));
1521 assert!(!is_pruned(Path::new("notes.txt")));
1522 }
1523
1524 #[test]
1525 fn is_config_file_matches_supported_extensions() {
1526 assert!(is_config_file(Path::new("a/b.cfg")));
1527 assert!(is_config_file(Path::new("a/b.YAML")));
1528 assert!(is_config_file(Path::new("b.ini")));
1529 assert!(!is_config_file(Path::new("a/b.dll")));
1530 assert!(!is_config_file(Path::new("a/b")));
1531 }
1532
1533 #[test]
1534 fn collect_export_paths_selects_configs_and_skips_pruned() {
1535 let dir = tempfile::tempdir().unwrap();
1536 let root = dir.path();
1537
1538 write_test_file(&root.join("mods.yml"), b"[]");
1539 write_test_file(&root.join("BepInEx/config/BepInEx.cfg"), b"cfg");
1540 write_test_file(&root.join("BepInEx/plugins/Author-Mod/settings.cfg"), b"s");
1541 write_test_file(
1542 &root.join("BepInEx/plugins/Author-Mod/manifest.json"),
1543 b"{}",
1544 );
1545 write_test_file(&root.join("BepInEx/plugins/Author-Mod/Mod.dll"), b"dll");
1546 write_test_file(&root.join("_state/Author-Mod-state.yml"), b"x");
1547
1548 let paths = collect_export_paths(root).unwrap();
1549 let as_str: Vec<String> = paths
1550 .iter()
1551 .map(|p| p.to_string_lossy().replace('\\', "/"))
1552 .collect();
1553
1554 assert_eq!(
1555 as_str,
1556 vec!["BepInEx/plugins/Author-Mod/settings.cfg".to_string()]
1557 );
1558 }
1559
1560 #[test]
1561 fn build_r2z_contains_manifest_and_configs() {
1562 let dir = tempfile::tempdir().unwrap();
1563 let root = dir.path();
1564
1565 crate::profile::modlist::write(root, &[profile_mod("Author-A", true)]).unwrap();
1566 write_test_file(&root.join("BepInEx/config/BepInEx.cfg"), b"shared");
1567 write_test_file(&root.join("BepInEx/plugins/Author-A/settings.cfg"), b"mine");
1568 write_test_file(&root.join("BepInEx/plugins/Author-A/Mod.dll"), b"dll");
1569
1570 let bytes = build_r2z_in(root, "MyProfile").unwrap();
1571
1572 let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
1573 let names: Vec<String> = (0..zip.len())
1574 .map(|i| zip.by_index(i).unwrap().name().replace('\\', "/"))
1575 .collect();
1576
1577 assert!(names.contains(&"export.r2x".to_string()));
1578 assert!(names.contains(&"config/BepInEx.cfg".to_string()));
1580 assert!(names.contains(&"BepInEx/plugins/Author-A/settings.cfg".to_string()));
1582 assert!(!names.iter().any(|n| n.ends_with("Mod.dll")));
1584 assert!(!names.contains(&"mods.yml".to_string()));
1585
1586 let mut manifest = String::new();
1587 std::io::Read::read_to_string(&mut zip.by_name("export.r2x").unwrap(), &mut manifest).unwrap();
1588
1589 let export = parse_export_r2x(&manifest).unwrap();
1590
1591 assert_eq!(export.profile_name, "MyProfile");
1592 assert_eq!(export.mods[0].name, "Author-A");
1593 }
1594
1595 #[test]
1596 fn export_to_file_uses_name_and_timestamp() {
1597 let dir = tempfile::tempdir().unwrap();
1598 let root = dir.path().join("profile");
1599 let out = dir.path().join("exports");
1600
1601 crate::profile::modlist::write(&root, &[profile_mod("Author-A", true)]).unwrap();
1602
1603 let path = export_to_file_in(&root, "MyProfile", &out, 1_700_000_000_000).unwrap();
1604
1605 assert_eq!(
1606 path.file_name().unwrap().to_string_lossy(),
1607 "MyProfile_1700000000000.r2z"
1608 );
1609 assert!(path.exists());
1610 }
1611
1612 #[test]
1613 fn export_to_file_rejects_unsafe_profile_name() {
1614 let dir = tempfile::tempdir().unwrap();
1615 let root = dir.path().join("profile");
1616 let out = dir.path().join("exports");
1617
1618 crate::profile::modlist::write(&root, &[profile_mod("Author-A", true)]).unwrap();
1619
1620 assert!(export_to_file_in(&root, "../evil", &out, 1).is_err());
1621 assert!(!dir.path().join("evil_1.r2z").exists());
1622 }
1623
1624 #[test]
1625 fn encode_profile_payload_prefixes_and_base64s() {
1626 let payload = encode_profile_payload(b"ABC").unwrap();
1627
1628 assert_eq!(payload, b"#r2modman\nQUJD");
1629 }
1630
1631 #[test]
1632 fn encode_profile_payload_rejects_oversize() {
1633 let big = vec![0u8; MAX_EXPORT_CODE_BYTES + 1];
1634
1635 assert!(matches!(
1636 encode_profile_payload(&big),
1637 Err(Error::SizeLimitExceeded { .. })
1638 ));
1639 }
1640
1641 #[test]
1642 fn decode_profile_payload_round_trips_and_rejects_bad_prefix() {
1643 let payload = encode_profile_payload(b"ABC").unwrap();
1644
1645 assert_eq!(decode_profile_payload(&payload).unwrap(), b"ABC");
1646 assert!(decode_profile_payload(b"nope\nQUJD").is_err());
1647 }
1648
1649 #[test]
1650 fn import_config_key_remaps_config_and_blocks_unsafe() {
1651 assert_eq!(
1653 import_config_key("config/BepInEx.cfg"),
1654 Some(PathBuf::from("BepInEx/config/BepInEx.cfg"))
1655 );
1656 assert_eq!(
1658 import_config_key("BepInEx/plugins/Author-A/settings.cfg"),
1659 Some(PathBuf::from("BepInEx/plugins/Author-A/settings.cfg"))
1660 );
1661 assert_eq!(import_config_key("export.r2x"), None);
1663 assert_eq!(import_config_key("mods.yml"), None);
1664 assert_eq!(import_config_key("BepInEx/plugins/evil.dll"), None);
1666 assert_eq!(import_config_key("../escape.cfg"), None);
1668 }
1669
1670 #[test]
1671 fn import_config_key_blocks_trailing_dot_and_space_bypass() {
1672 let blocked = [
1676 "evil.dll.",
1677 "evil.dll ",
1678 "evil.dll. . ",
1679 "evil.DLL",
1680 "evil.cfg.dll",
1681 ];
1682
1683 for name in blocked {
1684 assert_eq!(
1685 import_config_key(name),
1686 None,
1687 "{name} should have been blocked"
1688 );
1689 }
1690
1691 assert_eq!(import_config_key("a.cfg"), Some(PathBuf::from("a.cfg")));
1693 }
1694
1695 #[test]
1696 fn read_export_accepts_r2z_and_bare_r2x() {
1697 let dir = tempfile::tempdir().unwrap();
1698 let root = dir.path();
1699
1700 crate::profile::modlist::write(root, &[profile_mod("Author-A", true)]).unwrap();
1701
1702 let zip_bytes = build_r2z_in(root, "MyProfile").unwrap();
1703
1704 assert_eq!(read_export(&zip_bytes).unwrap().profile_name, "MyProfile");
1705
1706 let yaml = export_r2x_to_string(&export_format_from_mods("Bare", &[])).unwrap();
1707
1708 assert_eq!(read_export(yaml.as_bytes()).unwrap().profile_name, "Bare");
1709 }
1710
1711 #[test]
1712 fn extract_configs_writes_remapped_files_only() {
1713 let dir = tempfile::tempdir().unwrap();
1714 let source = dir.path().join("source");
1715 let target = dir.path().join("target");
1716
1717 crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
1718 write_test_file(&source.join("BepInEx/config/BepInEx.cfg"), b"shared");
1719 write_test_file(
1720 &source.join("BepInEx/plugins/Author-A/settings.cfg"),
1721 b"mine",
1722 );
1723
1724 let zip_bytes = build_r2z_in(&source, "MyProfile").unwrap();
1725 let written = extract_configs_in(&target, &zip_bytes).unwrap();
1726
1727 assert!(target.join("BepInEx/config/BepInEx.cfg").exists());
1728 assert!(
1729 target
1730 .join("BepInEx/plugins/Author-A/settings.cfg")
1731 .exists()
1732 );
1733 assert!(!target.join("export.r2x").exists());
1735 assert_eq!(written.len(), 2);
1736 }
1737
1738 #[test]
1739 fn extract_configs_treats_non_zip_input_as_no_configs() {
1740 let dir = tempfile::tempdir().unwrap();
1741 let target = dir.path().join("target");
1742
1743 let yaml = export_r2x_to_string(&export_format_from_mods("Bare", &[])).unwrap();
1745
1746 let written = extract_configs_in(&target, yaml.as_bytes()).unwrap();
1747
1748 assert!(written.is_empty());
1749 }
1750
1751 #[test]
1752 fn extract_configs_still_rejects_a_genuinely_corrupt_zip() {
1753 let dir = tempfile::tempdir().unwrap();
1754 let target = dir.path().join("target");
1755
1756 let corrupt = b"PK\x03\x04not a real zip".to_vec();
1758
1759 assert!(extract_configs_in(&target, &corrupt).is_err());
1760 }
1761
1762 #[test]
1763 #[cfg(unix)]
1764 fn extract_configs_refuses_to_write_through_a_symlink() {
1765 use std::os::unix::fs::symlink;
1766
1767 let outside = tempfile::tempdir().unwrap();
1768 let secret = outside.path().join("secret.cfg");
1769 std::fs::write(&secret, b"original").unwrap();
1770
1771 let source = tempfile::tempdir().unwrap();
1772
1773 crate::profile::modlist::write(source.path(), &[profile_mod("Author-A", true)]).unwrap();
1774 write_test_file(
1775 &source.path().join("BepInEx/config/BepInEx.cfg"),
1776 b"new-content",
1777 );
1778
1779 let zip_bytes = build_r2z_in(source.path(), "MyProfile").unwrap();
1780
1781 let target = tempfile::tempdir().unwrap();
1784 let dest = target.path().join("BepInEx/config/BepInEx.cfg");
1785
1786 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1787 symlink(&secret, &dest).unwrap();
1788
1789 let err = extract_configs_in(target.path(), &zip_bytes).unwrap_err();
1790
1791 assert!(matches!(err, Error::Profile(_)));
1792 assert_eq!(std::fs::read_to_string(&secret).unwrap(), "original");
1794 }
1795
1796 fn zip_with_one_zero_filled_entry(name: &str, inflated_len: usize) -> Vec<u8> {
1799 let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
1800 let options: FileOptions<'_, ()> =
1801 FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
1802
1803 zip.start_file(name, options).unwrap();
1804 zip.write_all(&vec![0u8; inflated_len]).unwrap();
1805
1806 zip.finish().unwrap().into_inner()
1807 }
1808
1809 #[test]
1810 fn extract_configs_rejects_entry_exceeding_the_per_entry_cap() {
1811 let bytes = zip_with_one_zero_filled_entry(
1812 "BepInEx/config/Big.cfg",
1813 (MAX_IMPORT_ENTRY_BYTES + 1) as usize,
1814 );
1815
1816 let dir = tempfile::tempdir().unwrap();
1817 let err = extract_configs_in(dir.path(), &bytes).unwrap_err();
1818
1819 assert!(matches!(err, Error::ExtractLimitExceeded(_)));
1820 }
1821
1822 #[test]
1823 fn extract_configs_rejects_import_exceeding_the_cumulative_cap() {
1824 let per_entry_len = (MAX_IMPORT_ENTRY_BYTES - 1024) as usize;
1827 let entries_needed = (MAX_IMPORT_TOTAL_BYTES / per_entry_len as u64) + 1;
1828
1829 let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
1830 let options: FileOptions<'_, ()> =
1831 FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
1832 let chunk = vec![0u8; per_entry_len];
1833
1834 for i in 0..entries_needed {
1835 zip
1836 .start_file(format!("BepInEx/config/{i}.cfg"), options)
1837 .unwrap();
1838 zip.write_all(&chunk).unwrap();
1839 }
1840
1841 let bytes = zip.finish().unwrap().into_inner();
1842
1843 let dir = tempfile::tempdir().unwrap();
1844 let err = extract_configs_in(dir.path(), &bytes).unwrap_err();
1845
1846 assert!(matches!(err, Error::ExtractLimitExceeded(_)));
1847 }
1848
1849 #[test]
1850 fn import_r2modman_dir_adopts_an_existing_profile() {
1851 let dir = tempfile::tempdir().unwrap();
1852 let source = dir.path().join("source");
1853 let target = dir.path().join("target");
1854
1855 crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
1856 write_test_file(&source.join("BepInEx/plugins/Author-A/Mod.dll"), b"dll");
1857
1858 import_r2modman_dir(&source, &target).unwrap();
1859
1860 assert!(target.join("BepInEx/plugins/Author-A/Mod.dll").exists());
1861 assert_eq!(crate::profile::modlist::read(&target).unwrap().len(), 1);
1862 assert!(import_r2modman_dir(&dir.path().join("missing"), &target).is_err());
1863 }
1864
1865 #[test]
1866 #[cfg(unix)]
1867 fn import_r2modman_dir_refuses_to_copy_through_a_symlink() {
1868 use std::os::unix::fs::symlink;
1869
1870 let outside = tempfile::tempdir().unwrap();
1871 let secret = outside.path().join("secret.cfg");
1872 std::fs::write(&secret, b"original").unwrap();
1873
1874 let dir = tempfile::tempdir().unwrap();
1875 let source = dir.path().join("source");
1876 let target = dir.path().join("target");
1877
1878 crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
1879 write_test_file(
1880 &source.join("BepInEx/plugins/Author-A/settings.cfg"),
1881 b"new-content",
1882 );
1883
1884 let dest = target.join("BepInEx/plugins/Author-A/settings.cfg");
1886
1887 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1888 symlink(&secret, &dest).unwrap();
1889
1890 let err = import_r2modman_dir(&source, &target).unwrap_err();
1891
1892 assert!(matches!(err, Error::Profile(_)));
1893 assert_eq!(std::fs::read_to_string(&secret).unwrap(), "original");
1895 }
1896
1897 #[test]
1898 fn copy_tree_skips_a_same_file_copy_instead_of_truncating_it() {
1899 let dir = tempfile::tempdir().unwrap();
1900 let root = dir.path();
1901
1902 write_test_file(&root.join("sub/file.txt"), b"original bytes");
1903
1904 copy_tree(root, root).unwrap();
1911
1912 let bytes = std::fs::read(root.join("sub/file.txt")).unwrap();
1913
1914 assert_eq!(
1915 bytes, b"original bytes",
1916 "a same-file copy must be skipped, not truncate the file to zero bytes"
1917 );
1918 }
1919}