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)]
656pub async fn import_r2z_in(
657 target_dir: &Path,
658 cache_base: &Path,
659 eco: &crate::ecosystem::Ecosystem,
660 index: &crate::models::PackageIndex,
661 client: &ThunderstoreClient,
662 game: &str,
663 zip_bytes: &[u8],
664 installed_at_time: u64,
665) -> Result<Vec<String>> {
666 let export = read_export(zip_bytes)?;
667
668 let desired: Vec<String> = export.mods.iter().map(|entry| entry.name.clone()).collect();
669 let protect: Vec<String> = export
670 .mods
671 .iter()
672 .filter(|entry| !entry.enabled)
673 .map(|entry| entry.name.clone())
674 .collect();
675 let mut batch = crate::profile::InstallBatch::new(desired, protect);
676
677 batch.enable_requested = export
681 .mods
682 .iter()
683 .filter(|entry| entry.enabled)
684 .map(|entry| entry.name.clone())
685 .collect();
686
687 let outcome = crate::profile::install_batch(
688 target_dir,
689 cache_base,
690 eco,
691 index,
692 client,
693 game,
694 &batch,
695 installed_at_time,
696 )
697 .await?;
698
699 if let Some((name, error)) = outcome.failed.into_iter().next() {
702 return Err(Error::Profile(format!(
703 "importing {name}: {error}; the import is unfinished (its config files were \
704 not restored), retry the import to finish it"
705 )));
706 }
707
708 extract_configs_in(target_dir, zip_bytes)?;
711
712 let mut imported = outcome.succeeded;
714
715 imported.extend(outcome.unchanged);
716 imported.sort();
717 imported.dedup();
718
719 Ok(imported)
720}
721
722#[allow(clippy::too_many_arguments)]
724pub async fn import_code_in(
725 target_dir: &Path,
726 cache_base: &Path,
727 eco: &crate::ecosystem::Ecosystem,
728 index: &crate::models::PackageIndex,
729 client: &ThunderstoreClient,
730 game: &str,
731 code: &str,
732 installed_at_time: u64,
733) -> Result<Vec<String>> {
734 let body = client.fetch_profile_code(code).await?;
735 let zip_bytes = decode_profile_payload(&body)?;
736
737 import_r2z_in(
738 target_dir,
739 cache_base,
740 eco,
741 index,
742 client,
743 game,
744 &zip_bytes,
745 installed_at_time,
746 )
747 .await
748}
749
750pub fn import_r2modman_dir(source_profile_dir: &Path, target_dir: &Path) -> Result<()> {
777 if !source_profile_dir.is_dir() {
778 return Err(Error::Profile(format!(
779 "{} is not a profile directory",
780 source_profile_dir.display()
781 )));
782 }
783
784 ensure_disjoint_trees(source_profile_dir, target_dir)?;
785
786 copy_tree(source_profile_dir, target_dir)
787}
788
789#[derive(Debug, Clone, Default)]
792#[non_exhaustive]
793pub struct AdoptedProfile {
794 pub adopted: Vec<String>,
796 pub reinstalled: Vec<String>,
803}
804
805#[allow(clippy::too_many_arguments)]
838pub async fn adopt_r2modman_dir_in(
839 source_profile_dir: &Path,
840 target_dir: &Path,
841 cache_base: &Path,
842 eco: &crate::ecosystem::Ecosystem,
843 client: &ThunderstoreClient,
844 game: &str,
845 installed_at_time: u64,
846) -> Result<AdoptedProfile> {
847 import_r2modman_dir(source_profile_dir, target_dir)?;
848
849 let adopted: Vec<String> = crate::profile::modlist::read(target_dir)?
850 .iter()
851 .map(|entry| entry.name.clone())
852 .collect();
853
854 let unrecorded: Vec<String> = adopted
862 .iter()
863 .filter(|name| eco.modloader_package(name).is_some())
864 .filter(|name| !crate::install::state_file_path(target_dir, name).exists())
865 .cloned()
866 .collect();
867
868 if unrecorded.is_empty() {
869 return Ok(AdoptedProfile {
870 adopted,
871 reinstalled: unrecorded,
872 });
873 }
874
875 let index = client.get_manifest().await?;
876
877 for full_name in &unrecorded {
881 crate::profile::install_named_in(
882 target_dir,
883 cache_base,
884 eco,
885 &index,
886 client,
887 game,
888 full_name,
889 true,
890 installed_at_time,
891 )
892 .await?;
893 }
894
895 Ok(AdoptedProfile {
896 adopted,
897 reinstalled: unrecorded,
898 })
899}
900
901#[derive(Debug, Default)]
907#[non_exhaustive]
908pub struct AdoptOutcome {
909 pub adopted: Vec<String>,
911 pub remaining: Vec<String>,
915 pub swept: Vec<PathBuf>,
918 pub failed: Vec<(String, Error)>,
926}
927
928pub fn needs_adoption(target_dir: &Path, desired: &[String]) -> Result<bool> {
936 if desired.is_empty() {
937 return Ok(false);
938 }
939
940 let recorded = crate::profile::modlist::read(target_dir).unwrap_or_default();
941
942 Ok(
943 desired
944 .iter()
945 .any(|name| crate::profile::modlist::find(&recorded, name).is_none()),
946 )
947}
948
949#[allow(clippy::too_many_arguments)]
958pub async fn adopt_names_in(
959 target_dir: &Path,
960 cache_base: &Path,
961 eco: &crate::ecosystem::Ecosystem,
962 index: &crate::models::PackageIndex,
963 client: &ThunderstoreClient,
964 game: &str,
965 desired: &[String],
966 installed_at_time: u64,
967) -> Result<AdoptOutcome> {
968 let batch = crate::profile::plan_install_batch(target_dir, desired, &[])?;
969
970 let batch_outcome = crate::profile::install_batch(
971 target_dir,
972 cache_base,
973 eco,
974 index,
975 client,
976 game,
977 &batch,
978 installed_at_time,
979 )
980 .await?;
981
982 let failed = batch_outcome.failed;
983
984 let recorded = crate::profile::modlist::read(target_dir).unwrap_or_default();
988
989 let mut adopted: Vec<String> = Vec::new();
990 let mut remaining: Vec<String> = Vec::new();
991
992 for name in desired {
993 match crate::profile::modlist::find(&recorded, name) {
994 Some(_) => adopted.push(name.clone()),
995 None => remaining.push(name.clone()),
996 }
997 }
998
999 adopted.sort();
1000 remaining.sort();
1001
1002 if !remaining.is_empty() {
1003 return Ok(AdoptOutcome {
1004 adopted,
1005 remaining,
1006 swept: Vec::new(),
1007 failed,
1008 });
1009 }
1010
1011 let game_profile = eco
1012 .game(game)
1013 .and_then(|g| g.profile())
1014 .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
1015 let keep: std::collections::BTreeSet<String> =
1016 recorded.iter().map(|entry| entry.name.clone()).collect();
1017 let swept = crate::install::remove_delisted(game_profile, target_dir, &keep)?;
1018
1019 Ok(AdoptOutcome {
1020 adopted,
1021 remaining,
1022 swept,
1023 failed,
1024 })
1025}
1026
1027pub fn ensure_disjoint_trees(source: &Path, target: &Path) -> Result<()> {
1046 let source_at = resolve_as_far_as_it_exists(source);
1047 let target_at = resolve_as_far_as_it_exists(target);
1048
1049 if source_at == target_at {
1050 return Err(Error::Profile(format!(
1051 "refusing to copy {} onto itself: every file would be truncated to zero \
1052 bytes",
1053 source.display()
1054 )));
1055 }
1056
1057 if source_at.starts_with(&target_at) {
1058 return Err(Error::Profile(format!(
1059 "refusing to copy {} into {}: the source lives inside the destination, so \
1060 the copy would overwrite the tree it is reading",
1061 source.display(),
1062 target.display()
1063 )));
1064 }
1065
1066 if target_at.starts_with(&source_at) {
1067 return Err(Error::Profile(format!(
1068 "refusing to copy {} into {}: the destination lives inside the source, so \
1069 the copy would recurse into itself",
1070 source.display(),
1071 target.display()
1072 )));
1073 }
1074
1075 Ok(())
1076}
1077
1078fn resolve_as_far_as_it_exists(path: &Path) -> PathBuf {
1100 if let Ok(canonical) = path.canonicalize() {
1101 return canonical;
1102 }
1103
1104 if let Ok(canonical) = canonicalize_missing(path) {
1105 return canonical;
1106 }
1107
1108 for ancestor in path.ancestors().skip(1) {
1109 let Ok(canonical_ancestor) = ancestor.canonicalize() else {
1110 continue;
1111 };
1112 let Ok(remainder) = path.strip_prefix(ancestor) else {
1113 continue;
1114 };
1115
1116 return canonical_ancestor.join(remainder);
1117 }
1118
1119 path.to_path_buf()
1120}
1121
1122fn copy_tree(src: &Path, dst: &Path) -> Result<()> {
1128 fs::create_dir_all(dst)
1129 .map_err(|e| Error::Profile(format!("creating {}: {}", dst.display(), e)))?;
1130
1131 for entry in
1132 fs::read_dir(src).map_err(|e| Error::Profile(format!("reading {}: {}", src.display(), e)))?
1133 {
1134 let entry = entry.map_err(|e| Error::Profile(format!("reading an entry: {e}")))?;
1135 let path = entry.path();
1136 let metadata = fs::symlink_metadata(&path)
1137 .map_err(|e| Error::Profile(format!("inspecting {}: {}", path.display(), e)))?;
1138
1139 if metadata.file_type().is_symlink() {
1140 continue;
1141 }
1142
1143 let target = dst.join(entry.file_name());
1144
1145 if metadata.is_dir() {
1146 copy_tree(&path, &target)?;
1147 } else {
1148 refuse_symlink_dest(&target)?;
1149
1150 if same_file(&path, &target) {
1157 continue;
1158 }
1159
1160 fs::copy(&path, &target)
1161 .map_err(|e| Error::Profile(format!("copying to {}: {}", target.display(), e)))?;
1162 }
1163 }
1164
1165 Ok(())
1166}
1167
1168#[derive(Debug, Clone, PartialEq, Eq)]
1181pub enum ImportSource {
1182 R2modmanDir(PathBuf),
1184 Archive(PathBuf),
1186 Code(String),
1188}
1189
1190impl ImportSource {
1191 pub fn classify(source: &str) -> Self {
1198 let path = Path::new(source);
1199
1200 if path.is_dir() {
1201 return Self::R2modmanDir(path.to_path_buf());
1202 }
1203
1204 if path.is_file() {
1205 return Self::Archive(path.to_path_buf());
1206 }
1207
1208 Self::Code(source.to_string())
1209 }
1210
1211 pub fn preserves_versions(&self) -> bool {
1223 matches!(self, Self::R2modmanDir(_))
1224 }
1225}
1226
1227#[derive(Debug)]
1233#[non_exhaustive]
1234pub struct ImportOutcome {
1235 pub source: ImportSource,
1237 pub installed: Vec<String>,
1239 pub reinstalled: Vec<String>,
1243}
1244
1245pub async fn import_in(
1262 target_dir: &Path,
1263 cache_base: &Path,
1264 eco: &crate::ecosystem::Ecosystem,
1265 client: &ThunderstoreClient,
1266 game: &str,
1267 source: &str,
1268 installed_at_time: u64,
1269) -> Result<ImportOutcome> {
1270 let source = ImportSource::classify(source);
1271
1272 if let ImportSource::R2modmanDir(dir) = &source {
1273 let adopted = adopt_r2modman_dir_in(
1274 dir,
1275 target_dir,
1276 cache_base,
1277 eco,
1278 client,
1279 game,
1280 installed_at_time,
1281 )
1282 .await?;
1283
1284 return Ok(ImportOutcome {
1285 source,
1286 installed: adopted.adopted,
1287 reinstalled: adopted.reinstalled,
1288 });
1289 }
1290
1291 let index = client.get_manifest().await?;
1292
1293 let installed = match &source {
1294 ImportSource::Archive(path) => {
1295 let bytes =
1296 fs::read(path).map_err(|e| Error::Profile(format!("reading {}: {}", path.display(), e)))?;
1297
1298 import_r2z_in(
1299 target_dir,
1300 cache_base,
1301 eco,
1302 &index,
1303 client,
1304 game,
1305 &bytes,
1306 installed_at_time,
1307 )
1308 .await?
1309 }
1310 ImportSource::Code(code) => {
1311 import_code_in(
1312 target_dir,
1313 cache_base,
1314 eco,
1315 &index,
1316 client,
1317 game,
1318 code,
1319 installed_at_time,
1320 )
1321 .await?
1322 }
1323 ImportSource::R2modmanDir(_) => {
1328 unreachable!("R2modmanDir returns from the early branch above this match")
1329 }
1330 };
1331
1332 Ok(ImportOutcome {
1333 source,
1334 installed,
1335 reinstalled: Vec::new(),
1336 })
1337}
1338
1339#[allow(clippy::too_many_arguments)]
1341pub async fn import_r2z(
1342 base: &Path,
1343 game: &str,
1344 profile_name: &str,
1345 eco: &crate::ecosystem::Ecosystem,
1346 index: &crate::models::PackageIndex,
1347 client: &ThunderstoreClient,
1348 zip_bytes: &[u8],
1349 installed_at_time: u64,
1350) -> Result<Vec<String>> {
1351 import_r2z_in(
1352 &crate::profile::layout::profile_dir(base, game, profile_name),
1353 base,
1354 eco,
1355 index,
1356 client,
1357 game,
1358 zip_bytes,
1359 installed_at_time,
1360 )
1361 .await
1362}
1363
1364#[allow(clippy::too_many_arguments)]
1367pub async fn import_code(
1368 base: &Path,
1369 game: &str,
1370 profile_name: &str,
1371 eco: &crate::ecosystem::Ecosystem,
1372 index: &crate::models::PackageIndex,
1373 client: &ThunderstoreClient,
1374 code: &str,
1375 installed_at_time: u64,
1376) -> Result<Vec<String>> {
1377 import_code_in(
1378 &crate::profile::layout::profile_dir(base, game, profile_name),
1379 base,
1380 eco,
1381 index,
1382 client,
1383 game,
1384 code,
1385 installed_at_time,
1386 )
1387 .await
1388}
1389
1390#[cfg(test)]
1391mod tests {
1392 use super::*;
1393
1394 fn profile_mod(name: &str, enabled: bool) -> ProfileMod {
1395 let mut entry = crate::profile::mod_entry_from_version(name, "Author", &sample_version(), 1);
1396 entry.enabled = enabled;
1397
1398 entry
1399 }
1400
1401 fn sample_version() -> crate::models::Version {
1402 let json = r#"{"name":"Mod","full_name":"Author-Mod","description":"d","icon":"i",
1404 "version_number":"1.2.3","dependencies":[],"download_url":"u","downloads":1,
1405 "date_created":"2024-01-01T12:00:00Z","website_url":"w","is_active":true,
1406 "uuid4":"u","file_size":1}"#;
1407
1408 serde_json::from_str(json).unwrap()
1409 }
1410
1411 #[test]
1412 fn import_source_classifies_a_directory_a_file_and_a_code() {
1413 let dir = tempfile::tempdir().unwrap();
1414 let profile = dir.path().join("SomeProfile");
1415 let archive = dir.path().join("export.r2z");
1416
1417 std::fs::create_dir_all(&profile).unwrap();
1418 write_test_file(&archive, b"not really a zip");
1419
1420 assert!(matches!(
1421 ImportSource::classify(profile.to_str().unwrap()),
1422 ImportSource::R2modmanDir(path) if path == profile
1423 ));
1424 assert!(matches!(
1425 ImportSource::classify(archive.to_str().unwrap()),
1426 ImportSource::Archive(path) if path == archive
1427 ));
1428
1429 assert!(matches!(
1433 ImportSource::classify("abc123-def456"),
1434 ImportSource::Code(code) if code == "abc123-def456"
1435 ));
1436 assert!(matches!(
1437 ImportSource::classify("/no/such/path.r2z"),
1438 ImportSource::Code(_)
1439 ));
1440 }
1441
1442 #[test]
1443 fn import_source_reports_whether_versions_survive() {
1444 let dir = tempfile::tempdir().unwrap();
1445
1446 assert!(ImportSource::R2modmanDir(dir.path().to_path_buf()).preserves_versions());
1450 assert!(!ImportSource::Archive(dir.path().join("e.r2z")).preserves_versions());
1451 assert!(!ImportSource::Code("abc123".to_string()).preserves_versions());
1452 }
1453
1454 #[test]
1455 fn export_format_maps_mods_and_preserves_enabled() {
1456 let mods = vec![
1457 profile_mod("Author-A", true),
1458 profile_mod("Author-B", false),
1459 ];
1460
1461 let export = export_format_from_mods("MyProfile", &mods);
1462
1463 assert_eq!(export.profile_name, "MyProfile");
1464 assert_eq!(export.mods.len(), 2);
1465 assert_eq!(export.mods[0].name, "Author-A");
1466 assert_eq!(
1467 export.mods[0].version,
1468 SemVer {
1469 major: 1,
1470 minor: 2,
1471 patch: 3
1472 }
1473 );
1474 assert!(export.mods[0].enabled);
1475 assert!(!export.mods[1].enabled);
1476 }
1477
1478 #[test]
1479 fn serialized_r2x_uses_version_not_version_number() {
1480 let mods = vec![profile_mod("Author-A", true)];
1481 let yaml = export_r2x_to_string(&export_format_from_mods("MyProfile", &mods)).unwrap();
1482
1483 assert!(yaml.contains("profileName: MyProfile"));
1484 assert!(yaml.contains("name: Author-A"));
1485 assert!(yaml.contains("version:"));
1486 assert!(yaml.contains("major: 1"));
1487 assert!(!yaml.contains("versionNumber"));
1489 }
1490
1491 #[test]
1492 fn parses_real_r2x_and_defaults_missing_enabled_to_true() {
1493 let yaml = r#"profileName: Shared
1494mods:
1495 - name: Author-A
1496 version:
1497 major: 2
1498 minor: 0
1499 patch: 1
1500 enabled: false
1501 - name: Author-B
1502 version:
1503 major: 1
1504 minor: 0
1505 patch: 0
1506"#;
1507
1508 let export = parse_export_r2x(yaml).unwrap();
1509
1510 assert_eq!(export.profile_name, "Shared");
1511 assert_eq!(export.mods.len(), 2);
1512 assert!(!export.mods[0].enabled);
1513 assert!(export.mods[1].enabled);
1515 }
1516
1517 #[test]
1518 fn parse_export_r2x_rejects_malformed() {
1519 assert!(parse_export_r2x("not: a valid export").is_err());
1520 }
1521
1522 fn write_test_file(path: &Path, bytes: &[u8]) {
1523 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1524 std::fs::write(path, bytes).unwrap();
1525 }
1526
1527 #[test]
1528 fn is_pruned_matches_r2modman_rules() {
1529 assert!(is_pruned(Path::new("dotnet/x.json")));
1531 assert!(is_pruned(Path::new("_state/Author-Mod-state.yml")));
1532 assert!(is_pruned(Path::new("BepInEx/config/BepInEx.cfg")));
1533 assert!(is_pruned(Path::new(
1534 "BepInEx/plugins/Author-Mod/manifest.json"
1535 )));
1536 assert!(is_pruned(Path::new("MelonLoader/x.cfg")));
1537 assert!(is_pruned(Path::new("GDWeave/core/a.json")));
1538 assert!(is_pruned(Path::new("GDWeave/mods/b.json")));
1539 assert!(is_pruned(Path::new("GDWeave/GDWeave.log")));
1540 assert!(is_pruned(Path::new("mods.yml")));
1541
1542 assert!(!is_pruned(Path::new(
1544 "BepInEx/plugins/Author-Mod/settings.cfg"
1545 )));
1546 assert!(!is_pruned(Path::new("GDWeave/other.json")));
1547 assert!(!is_pruned(Path::new("notes.txt")));
1548 }
1549
1550 #[test]
1551 fn is_config_file_matches_supported_extensions() {
1552 assert!(is_config_file(Path::new("a/b.cfg")));
1553 assert!(is_config_file(Path::new("a/b.YAML")));
1554 assert!(is_config_file(Path::new("b.ini")));
1555 assert!(!is_config_file(Path::new("a/b.dll")));
1556 assert!(!is_config_file(Path::new("a/b")));
1557 }
1558
1559 #[test]
1560 fn collect_export_paths_selects_configs_and_skips_pruned() {
1561 let dir = tempfile::tempdir().unwrap();
1562 let root = dir.path();
1563
1564 write_test_file(&root.join("mods.yml"), b"[]");
1565 write_test_file(&root.join("BepInEx/config/BepInEx.cfg"), b"cfg");
1566 write_test_file(&root.join("BepInEx/plugins/Author-Mod/settings.cfg"), b"s");
1567 write_test_file(
1568 &root.join("BepInEx/plugins/Author-Mod/manifest.json"),
1569 b"{}",
1570 );
1571 write_test_file(&root.join("BepInEx/plugins/Author-Mod/Mod.dll"), b"dll");
1572 write_test_file(&root.join("_state/Author-Mod-state.yml"), b"x");
1573
1574 let paths = collect_export_paths(root).unwrap();
1575 let as_str: Vec<String> = paths
1576 .iter()
1577 .map(|p| p.to_string_lossy().replace('\\', "/"))
1578 .collect();
1579
1580 assert_eq!(
1581 as_str,
1582 vec!["BepInEx/plugins/Author-Mod/settings.cfg".to_string()]
1583 );
1584 }
1585
1586 #[test]
1587 fn build_r2z_contains_manifest_and_configs() {
1588 let dir = tempfile::tempdir().unwrap();
1589 let root = dir.path();
1590
1591 crate::profile::modlist::write(root, &[profile_mod("Author-A", true)]).unwrap();
1592 write_test_file(&root.join("BepInEx/config/BepInEx.cfg"), b"shared");
1593 write_test_file(&root.join("BepInEx/plugins/Author-A/settings.cfg"), b"mine");
1594 write_test_file(&root.join("BepInEx/plugins/Author-A/Mod.dll"), b"dll");
1595
1596 let bytes = build_r2z_in(root, "MyProfile").unwrap();
1597
1598 let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
1599 let names: Vec<String> = (0..zip.len())
1600 .map(|i| zip.by_index(i).unwrap().name().replace('\\', "/"))
1601 .collect();
1602
1603 assert!(names.contains(&"export.r2x".to_string()));
1604 assert!(names.contains(&"config/BepInEx.cfg".to_string()));
1606 assert!(names.contains(&"BepInEx/plugins/Author-A/settings.cfg".to_string()));
1608 assert!(!names.iter().any(|n| n.ends_with("Mod.dll")));
1610 assert!(!names.contains(&"mods.yml".to_string()));
1611
1612 let mut manifest = String::new();
1613 std::io::Read::read_to_string(&mut zip.by_name("export.r2x").unwrap(), &mut manifest).unwrap();
1614
1615 let export = parse_export_r2x(&manifest).unwrap();
1616
1617 assert_eq!(export.profile_name, "MyProfile");
1618 assert_eq!(export.mods[0].name, "Author-A");
1619 }
1620
1621 #[test]
1622 fn export_to_file_uses_name_and_timestamp() {
1623 let dir = tempfile::tempdir().unwrap();
1624 let root = dir.path().join("profile");
1625 let out = dir.path().join("exports");
1626
1627 crate::profile::modlist::write(&root, &[profile_mod("Author-A", true)]).unwrap();
1628
1629 let path = export_to_file_in(&root, "MyProfile", &out, 1_700_000_000_000).unwrap();
1630
1631 assert_eq!(
1632 path.file_name().unwrap().to_string_lossy(),
1633 "MyProfile_1700000000000.r2z"
1634 );
1635 assert!(path.exists());
1636 }
1637
1638 #[test]
1639 fn export_to_file_rejects_unsafe_profile_name() {
1640 let dir = tempfile::tempdir().unwrap();
1641 let root = dir.path().join("profile");
1642 let out = dir.path().join("exports");
1643
1644 crate::profile::modlist::write(&root, &[profile_mod("Author-A", true)]).unwrap();
1645
1646 assert!(export_to_file_in(&root, "../evil", &out, 1).is_err());
1647 assert!(!dir.path().join("evil_1.r2z").exists());
1648 }
1649
1650 #[test]
1651 fn encode_profile_payload_prefixes_and_base64s() {
1652 let payload = encode_profile_payload(b"ABC").unwrap();
1653
1654 assert_eq!(payload, b"#r2modman\nQUJD");
1655 }
1656
1657 #[test]
1658 fn encode_profile_payload_rejects_oversize() {
1659 let big = vec![0u8; MAX_EXPORT_CODE_BYTES + 1];
1660
1661 assert!(matches!(
1662 encode_profile_payload(&big),
1663 Err(Error::SizeLimitExceeded { .. })
1664 ));
1665 }
1666
1667 #[test]
1668 fn decode_profile_payload_round_trips_and_rejects_bad_prefix() {
1669 let payload = encode_profile_payload(b"ABC").unwrap();
1670
1671 assert_eq!(decode_profile_payload(&payload).unwrap(), b"ABC");
1672 assert!(decode_profile_payload(b"nope\nQUJD").is_err());
1673 }
1674
1675 #[test]
1676 fn import_config_key_remaps_config_and_blocks_unsafe() {
1677 assert_eq!(
1679 import_config_key("config/BepInEx.cfg"),
1680 Some(PathBuf::from("BepInEx/config/BepInEx.cfg"))
1681 );
1682 assert_eq!(
1684 import_config_key("BepInEx/plugins/Author-A/settings.cfg"),
1685 Some(PathBuf::from("BepInEx/plugins/Author-A/settings.cfg"))
1686 );
1687 assert_eq!(import_config_key("export.r2x"), None);
1689 assert_eq!(import_config_key("mods.yml"), None);
1690 assert_eq!(import_config_key("BepInEx/plugins/evil.dll"), None);
1692 assert_eq!(import_config_key("../escape.cfg"), None);
1694 }
1695
1696 #[test]
1697 fn import_config_key_blocks_trailing_dot_and_space_bypass() {
1698 let blocked = [
1702 "evil.dll.",
1703 "evil.dll ",
1704 "evil.dll. . ",
1705 "evil.DLL",
1706 "evil.cfg.dll",
1707 ];
1708
1709 for name in blocked {
1710 assert_eq!(
1711 import_config_key(name),
1712 None,
1713 "{name} should have been blocked"
1714 );
1715 }
1716
1717 assert_eq!(import_config_key("a.cfg"), Some(PathBuf::from("a.cfg")));
1719 }
1720
1721 #[test]
1722 fn read_export_accepts_r2z_and_bare_r2x() {
1723 let dir = tempfile::tempdir().unwrap();
1724 let root = dir.path();
1725
1726 crate::profile::modlist::write(root, &[profile_mod("Author-A", true)]).unwrap();
1727
1728 let zip_bytes = build_r2z_in(root, "MyProfile").unwrap();
1729
1730 assert_eq!(read_export(&zip_bytes).unwrap().profile_name, "MyProfile");
1731
1732 let yaml = export_r2x_to_string(&export_format_from_mods("Bare", &[])).unwrap();
1733
1734 assert_eq!(read_export(yaml.as_bytes()).unwrap().profile_name, "Bare");
1735 }
1736
1737 #[test]
1738 fn extract_configs_writes_remapped_files_only() {
1739 let dir = tempfile::tempdir().unwrap();
1740 let source = dir.path().join("source");
1741 let target = dir.path().join("target");
1742
1743 crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
1744 write_test_file(&source.join("BepInEx/config/BepInEx.cfg"), b"shared");
1745 write_test_file(
1746 &source.join("BepInEx/plugins/Author-A/settings.cfg"),
1747 b"mine",
1748 );
1749
1750 let zip_bytes = build_r2z_in(&source, "MyProfile").unwrap();
1751 let written = extract_configs_in(&target, &zip_bytes).unwrap();
1752
1753 assert!(target.join("BepInEx/config/BepInEx.cfg").exists());
1754 assert!(
1755 target
1756 .join("BepInEx/plugins/Author-A/settings.cfg")
1757 .exists()
1758 );
1759 assert!(!target.join("export.r2x").exists());
1761 assert_eq!(written.len(), 2);
1762 }
1763
1764 #[test]
1765 fn extract_configs_treats_non_zip_input_as_no_configs() {
1766 let dir = tempfile::tempdir().unwrap();
1767 let target = dir.path().join("target");
1768
1769 let yaml = export_r2x_to_string(&export_format_from_mods("Bare", &[])).unwrap();
1771
1772 let written = extract_configs_in(&target, yaml.as_bytes()).unwrap();
1773
1774 assert!(written.is_empty());
1775 }
1776
1777 #[test]
1778 fn extract_configs_still_rejects_a_genuinely_corrupt_zip() {
1779 let dir = tempfile::tempdir().unwrap();
1780 let target = dir.path().join("target");
1781
1782 let corrupt = b"PK\x03\x04not a real zip".to_vec();
1784
1785 assert!(extract_configs_in(&target, &corrupt).is_err());
1786 }
1787
1788 #[test]
1789 #[cfg(unix)]
1790 fn extract_configs_refuses_to_write_through_a_symlink() {
1791 use std::os::unix::fs::symlink;
1792
1793 let outside = tempfile::tempdir().unwrap();
1794 let secret = outside.path().join("secret.cfg");
1795 std::fs::write(&secret, b"original").unwrap();
1796
1797 let source = tempfile::tempdir().unwrap();
1798
1799 crate::profile::modlist::write(source.path(), &[profile_mod("Author-A", true)]).unwrap();
1800 write_test_file(
1801 &source.path().join("BepInEx/config/BepInEx.cfg"),
1802 b"new-content",
1803 );
1804
1805 let zip_bytes = build_r2z_in(source.path(), "MyProfile").unwrap();
1806
1807 let target = tempfile::tempdir().unwrap();
1810 let dest = target.path().join("BepInEx/config/BepInEx.cfg");
1811
1812 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1813 symlink(&secret, &dest).unwrap();
1814
1815 let err = extract_configs_in(target.path(), &zip_bytes).unwrap_err();
1816
1817 assert!(matches!(err, Error::Profile(_)));
1818 assert_eq!(std::fs::read_to_string(&secret).unwrap(), "original");
1820 }
1821
1822 fn zip_with_one_zero_filled_entry(name: &str, inflated_len: usize) -> Vec<u8> {
1825 let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
1826 let options: FileOptions<'_, ()> =
1827 FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
1828
1829 zip.start_file(name, options).unwrap();
1830 zip.write_all(&vec![0u8; inflated_len]).unwrap();
1831
1832 zip.finish().unwrap().into_inner()
1833 }
1834
1835 #[test]
1836 fn extract_configs_rejects_entry_exceeding_the_per_entry_cap() {
1837 let bytes = zip_with_one_zero_filled_entry(
1838 "BepInEx/config/Big.cfg",
1839 (MAX_IMPORT_ENTRY_BYTES + 1) as usize,
1840 );
1841
1842 let dir = tempfile::tempdir().unwrap();
1843 let err = extract_configs_in(dir.path(), &bytes).unwrap_err();
1844
1845 assert!(matches!(err, Error::ExtractLimitExceeded(_)));
1846 }
1847
1848 #[test]
1849 fn extract_configs_rejects_import_exceeding_the_cumulative_cap() {
1850 let per_entry_len = (MAX_IMPORT_ENTRY_BYTES - 1024) as usize;
1853 let entries_needed = (MAX_IMPORT_TOTAL_BYTES / per_entry_len as u64) + 1;
1854
1855 let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
1856 let options: FileOptions<'_, ()> =
1857 FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
1858 let chunk = vec![0u8; per_entry_len];
1859
1860 for i in 0..entries_needed {
1861 zip
1862 .start_file(format!("BepInEx/config/{i}.cfg"), options)
1863 .unwrap();
1864 zip.write_all(&chunk).unwrap();
1865 }
1866
1867 let bytes = zip.finish().unwrap().into_inner();
1868
1869 let dir = tempfile::tempdir().unwrap();
1870 let err = extract_configs_in(dir.path(), &bytes).unwrap_err();
1871
1872 assert!(matches!(err, Error::ExtractLimitExceeded(_)));
1873 }
1874
1875 #[test]
1876 fn import_r2modman_dir_adopts_an_existing_profile() {
1877 let dir = tempfile::tempdir().unwrap();
1878 let source = dir.path().join("source");
1879 let target = dir.path().join("target");
1880
1881 crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
1882 write_test_file(&source.join("BepInEx/plugins/Author-A/Mod.dll"), b"dll");
1883
1884 import_r2modman_dir(&source, &target).unwrap();
1885
1886 assert!(target.join("BepInEx/plugins/Author-A/Mod.dll").exists());
1887 assert_eq!(crate::profile::modlist::read(&target).unwrap().len(), 1);
1888 assert!(import_r2modman_dir(&dir.path().join("missing"), &target).is_err());
1889 }
1890
1891 #[test]
1892 #[cfg(unix)]
1893 fn import_r2modman_dir_refuses_to_copy_through_a_symlink() {
1894 use std::os::unix::fs::symlink;
1895
1896 let outside = tempfile::tempdir().unwrap();
1897 let secret = outside.path().join("secret.cfg");
1898 std::fs::write(&secret, b"original").unwrap();
1899
1900 let dir = tempfile::tempdir().unwrap();
1901 let source = dir.path().join("source");
1902 let target = dir.path().join("target");
1903
1904 crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
1905 write_test_file(
1906 &source.join("BepInEx/plugins/Author-A/settings.cfg"),
1907 b"new-content",
1908 );
1909
1910 let dest = target.join("BepInEx/plugins/Author-A/settings.cfg");
1912
1913 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1914 symlink(&secret, &dest).unwrap();
1915
1916 let err = import_r2modman_dir(&source, &target).unwrap_err();
1917
1918 assert!(matches!(err, Error::Profile(_)));
1919 assert_eq!(std::fs::read_to_string(&secret).unwrap(), "original");
1921 }
1922
1923 #[test]
1924 fn copy_tree_skips_a_same_file_copy_instead_of_truncating_it() {
1925 let dir = tempfile::tempdir().unwrap();
1926 let root = dir.path();
1927
1928 write_test_file(&root.join("sub/file.txt"), b"original bytes");
1929
1930 copy_tree(root, root).unwrap();
1937
1938 let bytes = std::fs::read(root.join("sub/file.txt")).unwrap();
1939
1940 assert_eq!(
1941 bytes, b"original bytes",
1942 "a same-file copy must be skipped, not truncate the file to zero bytes"
1943 );
1944 }
1945}