1#![deny(missing_docs)]
16
17use crate::client::ThunderstoreClient;
18use crate::error::{Error, Result};
19use crate::profile::modlist::{ProfileMod, SemVer};
20use crate::util::{canonicalize_missing, same_file};
21use base64::Engine as _;
22use base64::engine::general_purpose::STANDARD;
23use serde::{Deserialize, Serialize};
24use std::fs;
25use std::io::{Cursor, Read, Write};
26use std::path::{Component, Path, PathBuf};
27use zip::write::{FileOptions, ZipWriter};
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[non_exhaustive]
34pub struct ExportMod {
35 pub name: String,
37 pub version: SemVer,
39 #[serde(default = "default_true")]
41 pub enabled: bool,
42}
43
44fn default_true() -> bool {
47 true
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55#[non_exhaustive]
56pub struct ExportFormat {
57 pub profile_name: String,
59 pub mods: Vec<ExportMod>,
61}
62
63pub fn export_format_from_mods(profile_name: &str, mods: &[ProfileMod]) -> ExportFormat {
65 let mods = mods
66 .iter()
67 .map(|entry| ExportMod {
68 name: entry.name.clone(),
69 version: entry.version_number,
70 enabled: entry.enabled,
71 })
72 .collect();
73
74 ExportFormat {
75 profile_name: profile_name.to_string(),
76 mods,
77 }
78}
79
80pub fn export_r2x_to_string(export: &ExportFormat) -> Result<String> {
82 serde_yaml_ng::to_string(export)
83 .map_err(|e| Error::Profile(format!("serializing export.r2x: {e}")))
84}
85
86pub fn parse_export_r2x(yaml: &str) -> Result<ExportFormat> {
88 serde_yaml_ng::from_str(yaml).map_err(|e| Error::Profile(format!("parsing export.r2x: {e}")))
89}
90
91const CONFIG_EXTENSIONS: [&str; 6] = [".cfg", ".txt", ".json", ".yml", ".yaml", ".ini"];
93
94fn is_pruned(rel: &Path) -> bool {
101 let normalized = rel.to_string_lossy().replace('\\', "/");
102
103 let prefixes = [
104 "dotnet/",
105 "_state/",
106 "MelonLoader/",
107 "BepInEx/config/",
108 "GDWeave/core/",
109 "GDWeave/mods/",
110 ];
111
112 if prefixes.iter().any(|p| normalized.starts_with(p)) {
113 return true;
114 }
115
116 if normalized == "mods.yml" || normalized == "GDWeave/GDWeave.log" {
117 return true;
118 }
119
120 is_plugin_manifest(&normalized)
121}
122
123fn is_plugin_manifest(normalized: &str) -> bool {
126 let parts: Vec<&str> = normalized.split('/').collect();
127
128 parts.len() == 4
129 && parts[0] == "BepInEx"
130 && parts[1] == "plugins"
131 && parts[3].eq_ignore_ascii_case("manifest.json")
132}
133
134fn is_config_file(rel: &Path) -> bool {
137 let name = match rel.file_name().and_then(|n| n.to_str()) {
138 Some(name) => name.to_ascii_lowercase(),
139 None => return false,
140 };
141
142 CONFIG_EXTENSIONS.iter().any(|ext| name.ends_with(ext))
143}
144
145fn collect_export_paths(target_dir: &Path) -> Result<Vec<PathBuf>> {
148 let mut found = Vec::new();
149
150 collect_files(target_dir, target_dir, &mut found)?;
151
152 let mut kept: Vec<PathBuf> = found
153 .into_iter()
154 .filter(|rel| is_config_file(rel) && !is_pruned(rel))
155 .collect();
156
157 kept.sort();
158
159 Ok(kept)
160}
161
162fn collect_files(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
165 if !dir.is_dir() {
166 return Ok(());
167 }
168
169 for entry in
170 fs::read_dir(dir).map_err(|e| Error::Profile(format!("reading {}: {}", dir.display(), e)))?
171 {
172 let entry = entry.map_err(|e| Error::Profile(format!("reading an entry: {e}")))?;
173 let path = entry.path();
174 let metadata = fs::symlink_metadata(&path)
175 .map_err(|e| Error::Profile(format!("inspecting {}: {}", path.display(), e)))?;
176
177 if metadata.file_type().is_symlink() {
178 continue;
179 }
180
181 if metadata.is_dir() {
182 collect_files(root, &path, out)?;
183 } else if let Ok(rel) = path.strip_prefix(root) {
184 out.push(rel.to_path_buf());
185 }
186 }
187
188 Ok(())
189}
190
191const EXPORT_MANIFEST: &str = "export.r2x";
193
194const ARCHIVE_CONFIG_DIR: &str = "config";
196
197const BEPINEX_CONFIG_DIR: &str = "BepInEx/config";
199
200pub fn build_r2z_in(target_dir: &Path, profile_name: &str) -> Result<Vec<u8>> {
229 let mods = crate::profile::modlist::read(target_dir)?;
230 let manifest = export_r2x_to_string(&export_format_from_mods(profile_name, &mods))?;
231
232 let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
233 let options: FileOptions<'_, ()> =
234 FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
235
236 zip
237 .start_file(EXPORT_MANIFEST, options)
238 .map_err(|e| Error::Profile(format!("writing {EXPORT_MANIFEST}: {e}")))?;
239 zip
240 .write_all(manifest.as_bytes())
241 .map_err(|e| Error::Profile(format!("writing {EXPORT_MANIFEST}: {e}")))?;
242
243 let config_root = target_dir.join(BEPINEX_CONFIG_DIR);
244 let mut shared = Vec::new();
245
246 collect_files(&config_root, &config_root, &mut shared)?;
247 shared.sort();
248
249 for rel in shared {
250 let archive_path = format!(
251 "{ARCHIVE_CONFIG_DIR}/{}",
252 rel.to_string_lossy().replace('\\', "/")
253 );
254
255 add_zip_file(&mut zip, options, &archive_path, &config_root.join(&rel))?;
256 }
257
258 for rel in collect_export_paths(target_dir)? {
259 let archive_path = rel.to_string_lossy().replace('\\', "/");
260
261 add_zip_file(&mut zip, options, &archive_path, &target_dir.join(&rel))?;
262 }
263
264 let cursor = zip
265 .finish()
266 .map_err(|e| Error::Profile(format!("finishing export archive: {e}")))?;
267
268 Ok(cursor.into_inner())
269}
270
271fn add_zip_file(
273 zip: &mut ZipWriter<Cursor<Vec<u8>>>,
274 options: FileOptions<'_, ()>,
275 archive_path: &str,
276 source: &Path,
277) -> Result<()> {
278 let bytes =
279 fs::read(source).map_err(|e| Error::Profile(format!("reading {}: {}", source.display(), e)))?;
280
281 zip
282 .start_file(archive_path, options)
283 .map_err(|e| Error::Profile(format!("writing {archive_path}: {e}")))?;
284 zip
285 .write_all(&bytes)
286 .map_err(|e| Error::Profile(format!("writing {archive_path}: {e}")))?;
287
288 Ok(())
289}
290
291pub fn export_to_file_in(
297 target_dir: &Path,
298 profile_name: &str,
299 out_dir: &Path,
300 timestamp_millis: u64,
301) -> Result<PathBuf> {
302 crate::profile::layout::validate_name(profile_name)?;
303
304 let bytes = build_r2z_in(target_dir, profile_name)?;
305
306 fs::create_dir_all(out_dir)
307 .map_err(|e| Error::Profile(format!("creating {}: {}", out_dir.display(), e)))?;
308
309 let path = out_dir.join(format!("{profile_name}_{timestamp_millis}.r2z"));
310
311 fs::write(&path, bytes)
312 .map_err(|e| Error::Profile(format!("writing {}: {}", path.display(), e)))?;
313
314 Ok(path)
315}
316
317pub fn export_to_file(
320 base: &Path,
321 game: &str,
322 profile_name: &str,
323 timestamp_millis: u64,
324) -> Result<PathBuf> {
325 export_to_file_in(
326 &crate::profile::layout::profile_dir(base, game, profile_name),
327 profile_name,
328 &base.join(game).join("exports"),
329 timestamp_millis,
330 )
331}
332
333const MAX_EXPORT_CODE_BYTES: usize = 20_000_000;
335
336const PROFILE_DATA_PREFIX: &str = "#r2modman";
338
339fn encode_profile_payload(zip_bytes: &[u8]) -> Result<Vec<u8>> {
343 if zip_bytes.len() > MAX_EXPORT_CODE_BYTES {
344 return Err(Error::SizeLimitExceeded {
345 context: "profile export".to_string(),
346 limit: MAX_EXPORT_CODE_BYTES as u64,
347 actual: Some(zip_bytes.len() as u64),
348 });
349 }
350
351 let mut payload = format!("{PROFILE_DATA_PREFIX}\n").into_bytes();
352
353 payload.extend_from_slice(STANDARD.encode(zip_bytes).as_bytes());
354
355 Ok(payload)
356}
357
358pub async fn export_code_in(
364 target_dir: &Path,
365 profile_name: &str,
366 client: &ThunderstoreClient,
367) -> Result<String> {
368 let payload = encode_profile_payload(&build_r2z_in(target_dir, profile_name)?)?;
369
370 client.create_profile_code(&payload).await
371}
372
373pub async fn export_code(
375 base: &Path,
376 game: &str,
377 profile_name: &str,
378 client: &ThunderstoreClient,
379) -> Result<String> {
380 export_code_in(
381 &crate::profile::layout::profile_dir(base, game, profile_name),
382 profile_name,
383 client,
384 )
385 .await
386}
387
388const BLOCKED_IMPORT_EXTENSIONS: [&str; 12] = [
391 ".dll", ".exe", ".bat", ".cmd", ".com", ".ps1", ".sh", ".js", ".vbs", ".msi", ".scr", ".jar",
392];
393
394fn decode_profile_payload(body: &[u8]) -> Result<Vec<u8>> {
397 let text = std::str::from_utf8(body)
398 .map_err(|e| Error::Profile(format!("profile code is not valid UTF-8: {e}")))?;
399
400 let encoded = text
401 .strip_prefix(PROFILE_DATA_PREFIX)
402 .ok_or_else(|| Error::Profile("profile code is missing its #r2modman prefix".to_string()))?;
403
404 STANDARD
405 .decode(encoded.trim())
406 .map_err(|e| Error::Profile(format!("decoding profile code: {e}")))
407}
408
409const MAX_IMPORT_ENTRY_BYTES: u64 = 16 * 1024 * 1024;
414
415const MAX_IMPORT_TOTAL_BYTES: u64 = 64 * 1024 * 1024;
417
418fn read_capped_entry(entry: &mut impl Read, label: &str) -> Result<Vec<u8>> {
425 let mut bytes = Vec::new();
426
427 entry
428 .take(MAX_IMPORT_ENTRY_BYTES + 1)
429 .read_to_end(&mut bytes)
430 .map_err(|e| Error::Profile(format!("reading {label}: {e}")))?;
431
432 if bytes.len() as u64 > MAX_IMPORT_ENTRY_BYTES {
433 return Err(Error::ExtractLimitExceeded(format!(
434 "{label} exceeds the {MAX_IMPORT_ENTRY_BYTES}-byte per-entry import cap"
435 )));
436 }
437
438 Ok(bytes)
439}
440
441pub fn read_export(bytes: &[u8]) -> Result<ExportFormat> {
443 match zip::ZipArchive::new(Cursor::new(bytes.to_vec())) {
444 Ok(mut archive) => {
445 let mut entry = archive
446 .by_name(EXPORT_MANIFEST)
447 .map_err(|e| Error::Profile(format!("archive has no {EXPORT_MANIFEST}: {e}")))?;
448
449 let manifest_bytes = read_capped_entry(&mut entry, EXPORT_MANIFEST)?;
450 let manifest = std::str::from_utf8(&manifest_bytes)
451 .map_err(|e| Error::Profile(format!("{EXPORT_MANIFEST} is not valid UTF-8: {e}")))?;
452
453 parse_export_r2x(manifest)
454 }
455 Err(_) => {
456 let text = std::str::from_utf8(bytes)
457 .map_err(|e| Error::Profile(format!("export is neither a zip nor UTF-8 YAML: {e}")))?;
458
459 parse_export_r2x(text)
460 }
461 }
462}
463
464fn import_config_key(name: &str) -> Option<PathBuf> {
476 let normalized = name.replace('\\', "/");
477
478 if normalized.is_empty() || normalized.ends_with('/') {
479 return None;
480 }
481
482 if normalized == EXPORT_MANIFEST || normalized == "mods.yml" {
483 return None;
484 }
485
486 let lower = normalized.to_ascii_lowercase();
487 let probe = lower.trim_end_matches(['.', ' ']);
488
489 if BLOCKED_IMPORT_EXTENSIONS
490 .iter()
491 .any(|ext| probe.ends_with(ext))
492 {
493 return None;
494 }
495
496 let remapped = match normalized.strip_prefix("config/") {
497 Some(rest) => format!("{BEPINEX_CONFIG_DIR}/{rest}"),
498 None => normalized,
499 };
500
501 let path = PathBuf::from(remapped);
502
503 for component in path.components() {
504 match component {
505 Component::Normal(_) | Component::CurDir => {}
506 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
507 }
508 }
509
510 Some(path)
511}
512
513fn looks_like_zip(bytes: &[u8]) -> bool {
522 bytes.len() >= 2 && &bytes[0..2] == b"PK"
523}
524
525fn refuse_symlink_dest(dest: &Path) -> Result<()> {
534 let is_symlink = fs::symlink_metadata(dest)
535 .map(|metadata| metadata.file_type().is_symlink())
536 .unwrap_or(false);
537
538 if is_symlink {
539 return Err(Error::Profile(format!(
540 "refusing to write through a symlink at {}",
541 dest.display()
542 )));
543 }
544
545 Ok(())
546}
547
548pub fn extract_configs_in(target_dir: &Path, zip_bytes: &[u8]) -> Result<Vec<PathBuf>> {
566 if !looks_like_zip(zip_bytes) {
567 return Ok(Vec::new());
568 }
569
570 let mut archive = zip::ZipArchive::new(Cursor::new(zip_bytes.to_vec()))
571 .map_err(|e| Error::Profile(format!("reading export archive: {e}")))?;
572
573 let mut written = Vec::new();
574 let mut total_written: u64 = 0;
575
576 for index in 0..archive.len() {
577 let mut entry = archive
578 .by_index(index)
579 .map_err(|e| Error::Profile(format!("reading archive entry {index}: {e}")))?;
580
581 let rel = match import_config_key(entry.name()) {
582 Some(rel) => rel,
583 None => continue,
584 };
585
586 let bytes = read_capped_entry(&mut entry, &rel.display().to_string())?;
587
588 total_written += bytes.len() as u64;
589
590 if total_written > MAX_IMPORT_TOTAL_BYTES {
591 return Err(Error::ExtractLimitExceeded(format!(
592 "import exceeds the {MAX_IMPORT_TOTAL_BYTES}-byte cumulative import cap"
593 )));
594 }
595
596 let dest = target_dir.join(&rel);
597
598 if let Some(parent) = dest.parent() {
599 fs::create_dir_all(parent)
600 .map_err(|e| Error::Profile(format!("creating {}: {}", parent.display(), e)))?;
601 }
602
603 refuse_symlink_dest(&dest)?;
604 fs::write(&dest, &bytes)
605 .map_err(|e| Error::Profile(format!("writing {}: {}", dest.display(), e)))?;
606
607 written.push(rel);
608 }
609
610 Ok(written)
611}
612
613#[allow(clippy::too_many_arguments)]
646pub async fn import_r2z_in(
647 target_dir: &Path,
648 cache_base: &Path,
649 eco: &crate::ecosystem::Ecosystem,
650 index: &crate::models::PackageIndex,
651 client: &ThunderstoreClient,
652 game: &str,
653 zip_bytes: &[u8],
654 installed_at_time: u64,
655) -> Result<ImportedMods> {
656 let export = read_export(zip_bytes)?;
657
658 let desired: Vec<String> = export
659 .mods
660 .iter()
661 .map(|entry| format!("{}-{}", entry.name, entry.version))
662 .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 mut batch = crate::profile::InstallBatch::new(desired, protect);
670
671 batch.enable_requested = export
675 .mods
676 .iter()
677 .filter(|entry| entry.enabled)
678 .map(|entry| entry.name.clone())
679 .collect();
680
681 let outcome = crate::profile::install_batch(
682 target_dir,
683 cache_base,
684 eco,
685 index,
686 client,
687 game,
688 &batch,
689 installed_at_time,
690 )
691 .await?;
692
693 if let Some((name, error)) = outcome.failed.into_iter().next() {
698 return Err(Error::Profile(format!(
699 "importing {name}: {error}; the import is unfinished (its config files were \
700 not restored)"
701 )));
702 }
703
704 extract_configs_in(target_dir, zip_bytes)?;
707
708 Ok(ImportedMods {
710 installed: outcome.succeeded,
711 unchanged: outcome.unchanged,
712 })
713}
714
715#[derive(Debug, Clone, Default, PartialEq, Eq)]
721#[non_exhaustive]
722pub struct ImportedMods {
723 pub installed: Vec<String>,
725 pub unchanged: Vec<String>,
732}
733
734#[allow(clippy::too_many_arguments)]
736pub async fn import_code_in(
737 target_dir: &Path,
738 cache_base: &Path,
739 eco: &crate::ecosystem::Ecosystem,
740 index: &crate::models::PackageIndex,
741 client: &ThunderstoreClient,
742 game: &str,
743 code: &str,
744 installed_at_time: u64,
745) -> Result<ImportedMods> {
746 let body = client.fetch_profile_code(code).await?;
747 let zip_bytes = decode_profile_payload(&body)?;
748
749 import_r2z_in(
750 target_dir,
751 cache_base,
752 eco,
753 index,
754 client,
755 game,
756 &zip_bytes,
757 installed_at_time,
758 )
759 .await
760}
761
762pub fn import_r2modman_dir(source_profile_dir: &Path, target_dir: &Path) -> Result<()> {
789 if !source_profile_dir.is_dir() {
790 return Err(Error::Profile(format!(
791 "{} is not a profile directory",
792 source_profile_dir.display()
793 )));
794 }
795
796 ensure_disjoint_trees(source_profile_dir, target_dir)?;
797
798 copy_tree(source_profile_dir, target_dir)
799}
800
801#[derive(Debug, Clone, Default)]
804#[non_exhaustive]
805pub struct AdoptedProfile {
806 pub adopted: Vec<String>,
808 pub reinstalled: Vec<String>,
815}
816
817#[allow(clippy::too_many_arguments)]
850pub async fn adopt_r2modman_dir_in(
851 source_profile_dir: &Path,
852 target_dir: &Path,
853 cache_base: &Path,
854 eco: &crate::ecosystem::Ecosystem,
855 client: &ThunderstoreClient,
856 game: &str,
857 installed_at_time: u64,
858) -> Result<AdoptedProfile> {
859 import_r2modman_dir(source_profile_dir, target_dir)?;
860
861 let adopted: Vec<String> = crate::profile::modlist::read(target_dir)?
862 .iter()
863 .map(|entry| entry.name.clone())
864 .collect();
865
866 let unrecorded: Vec<String> = adopted
874 .iter()
875 .filter(|name| eco.modloader_package(name).is_some())
876 .filter(|name| !crate::install::state_file_path(target_dir, name).exists())
877 .cloned()
878 .collect();
879
880 if unrecorded.is_empty() {
881 return Ok(AdoptedProfile {
882 adopted,
883 reinstalled: unrecorded,
884 });
885 }
886
887 let index = client.get_manifest().await?;
888
889 for full_name in &unrecorded {
893 crate::profile::install_named_in(
894 target_dir,
895 cache_base,
896 eco,
897 &index,
898 client,
899 game,
900 full_name,
901 true,
902 installed_at_time,
903 )
904 .await?;
905 }
906
907 Ok(AdoptedProfile {
908 adopted,
909 reinstalled: unrecorded,
910 })
911}
912
913#[derive(Debug, Default)]
919#[non_exhaustive]
920pub struct AdoptOutcome {
921 pub adopted: Vec<String>,
923 pub unchanged: Vec<String>,
927 pub remaining: Vec<String>,
935 pub swept: Vec<PathBuf>,
938 pub failed: Vec<(String, Error)>,
946}
947
948pub fn needs_adoption(target_dir: &Path, desired: &[String]) -> Result<bool> {
956 if desired.is_empty() {
957 return Ok(false);
958 }
959
960 let recorded = crate::profile::modlist::read(target_dir).unwrap_or_default();
961
962 Ok(
963 desired
964 .iter()
965 .any(|name| crate::profile::modlist::find(&recorded, name).is_none()),
966 )
967}
968
969#[allow(clippy::too_many_arguments)]
985pub async fn adopt_names_in(
986 target_dir: &Path,
987 cache_base: &Path,
988 eco: &crate::ecosystem::Ecosystem,
989 index: &crate::models::PackageIndex,
990 client: &ThunderstoreClient,
991 game: &str,
992 desired: &[String],
993 installed_at_time: u64,
994) -> Result<AdoptOutcome> {
995 let batch = crate::profile::plan_install_batch(target_dir, desired, &[])?;
996
997 let batch_outcome = crate::profile::install_batch(
998 target_dir,
999 cache_base,
1000 eco,
1001 index,
1002 client,
1003 game,
1004 &batch,
1005 installed_at_time,
1006 )
1007 .await?;
1008
1009 let failed = batch_outcome.failed;
1010 let failed_names: std::collections::BTreeSet<&str> =
1011 failed.iter().map(|(name, _)| name.as_str()).collect();
1012
1013 let recorded = crate::profile::modlist::read(target_dir).unwrap_or_default();
1020
1021 let mut adopted: Vec<String> = Vec::new();
1022 let mut unchanged: Vec<String> = Vec::new();
1023 let mut remaining: Vec<String> = Vec::new();
1024
1025 for name in desired {
1026 let Some(entry) = crate::profile::modlist::find(&recorded, name) else {
1027 remaining.push(name.clone());
1028
1029 continue;
1030 };
1031
1032 if failed_names.contains(name.as_str()) {
1033 continue;
1036 }
1037
1038 if entry.installed_at_time == installed_at_time {
1039 adopted.push(name.clone());
1040 } else {
1041 unchanged.push(name.clone());
1042 }
1043 }
1044
1045 adopted.sort();
1046 adopted.dedup();
1047 unchanged.sort();
1048 unchanged.dedup();
1049 remaining.sort();
1050 remaining.dedup();
1051
1052 if !remaining.is_empty() {
1053 return Ok(AdoptOutcome {
1054 adopted,
1055 unchanged,
1056 remaining,
1057 swept: Vec::new(),
1058 failed,
1059 });
1060 }
1061
1062 let game_profile = eco
1063 .game(game)
1064 .and_then(|g| g.profile())
1065 .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
1066 let keep: std::collections::BTreeSet<String> =
1067 recorded.iter().map(|entry| entry.name.clone()).collect();
1068 let swept = crate::install::remove_delisted(game_profile, target_dir, &keep)?;
1069
1070 Ok(AdoptOutcome {
1071 adopted,
1072 unchanged,
1073 remaining,
1074 swept,
1075 failed,
1076 })
1077}
1078
1079pub fn ensure_disjoint_trees(source: &Path, target: &Path) -> Result<()> {
1098 let source_at = resolve_as_far_as_it_exists(source);
1099 let target_at = resolve_as_far_as_it_exists(target);
1100
1101 if source_at == target_at {
1102 return Err(Error::Profile(format!(
1103 "refusing to copy {} onto itself: every file would be truncated to zero \
1104 bytes",
1105 source.display()
1106 )));
1107 }
1108
1109 if source_at.starts_with(&target_at) {
1110 return Err(Error::Profile(format!(
1111 "refusing to copy {} into {}: the source lives inside the destination, so \
1112 the copy would overwrite the tree it is reading",
1113 source.display(),
1114 target.display()
1115 )));
1116 }
1117
1118 if target_at.starts_with(&source_at) {
1119 return Err(Error::Profile(format!(
1120 "refusing to copy {} into {}: the destination lives inside the source, so \
1121 the copy would recurse into itself",
1122 source.display(),
1123 target.display()
1124 )));
1125 }
1126
1127 Ok(())
1128}
1129
1130fn resolve_as_far_as_it_exists(path: &Path) -> PathBuf {
1152 if let Ok(canonical) = path.canonicalize() {
1153 return canonical;
1154 }
1155
1156 if let Ok(canonical) = canonicalize_missing(path) {
1157 return canonical;
1158 }
1159
1160 for ancestor in path.ancestors().skip(1) {
1161 let Ok(canonical_ancestor) = ancestor.canonicalize() else {
1162 continue;
1163 };
1164 let Ok(remainder) = path.strip_prefix(ancestor) else {
1165 continue;
1166 };
1167
1168 return canonical_ancestor.join(remainder);
1169 }
1170
1171 path.to_path_buf()
1172}
1173
1174fn copy_tree(src: &Path, dst: &Path) -> Result<()> {
1180 fs::create_dir_all(dst)
1181 .map_err(|e| Error::Profile(format!("creating {}: {}", dst.display(), e)))?;
1182
1183 for entry in
1184 fs::read_dir(src).map_err(|e| Error::Profile(format!("reading {}: {}", src.display(), e)))?
1185 {
1186 let entry = entry.map_err(|e| Error::Profile(format!("reading an entry: {e}")))?;
1187 let path = entry.path();
1188 let metadata = fs::symlink_metadata(&path)
1189 .map_err(|e| Error::Profile(format!("inspecting {}: {}", path.display(), e)))?;
1190
1191 if metadata.file_type().is_symlink() {
1192 continue;
1193 }
1194
1195 let target = dst.join(entry.file_name());
1196
1197 if metadata.is_dir() {
1198 copy_tree(&path, &target)?;
1199 } else {
1200 refuse_symlink_dest(&target)?;
1201
1202 if same_file(&path, &target) {
1209 continue;
1210 }
1211
1212 fs::copy(&path, &target)
1213 .map_err(|e| Error::Profile(format!("copying to {}: {}", target.display(), e)))?;
1214 }
1215 }
1216
1217 Ok(())
1218}
1219
1220#[derive(Debug, Clone, PartialEq, Eq)]
1233pub enum ImportSource {
1234 R2modmanDir(PathBuf),
1236 Archive(PathBuf),
1238 Code(String),
1240}
1241
1242impl ImportSource {
1243 pub fn classify(source: &str) -> Self {
1250 let path = Path::new(source);
1251
1252 if path.is_dir() {
1253 return Self::R2modmanDir(path.to_path_buf());
1254 }
1255
1256 if path.is_file() {
1257 return Self::Archive(path.to_path_buf());
1258 }
1259
1260 Self::Code(source.to_string())
1261 }
1262}
1263
1264#[derive(Debug)]
1271#[non_exhaustive]
1272pub struct ImportOutcome {
1273 pub source: ImportSource,
1275 pub installed: Vec<String>,
1280 pub unchanged: Vec<String>,
1285 pub reinstalled: Vec<String>,
1289}
1290
1291pub async fn import_in(
1308 target_dir: &Path,
1309 cache_base: &Path,
1310 eco: &crate::ecosystem::Ecosystem,
1311 client: &ThunderstoreClient,
1312 game: &str,
1313 source: &str,
1314 installed_at_time: u64,
1315) -> Result<ImportOutcome> {
1316 let source = ImportSource::classify(source);
1317
1318 if let ImportSource::R2modmanDir(dir) = &source {
1319 let adopted = adopt_r2modman_dir_in(
1320 dir,
1321 target_dir,
1322 cache_base,
1323 eco,
1324 client,
1325 game,
1326 installed_at_time,
1327 )
1328 .await?;
1329 let mut installed = adopted.adopted;
1330
1331 installed.sort();
1332 installed.dedup();
1333
1334 return Ok(ImportOutcome {
1335 source,
1336 installed,
1337 unchanged: Vec::new(),
1338 reinstalled: adopted.reinstalled,
1339 });
1340 }
1341
1342 let index = client.get_manifest().await?;
1343
1344 let imported = match &source {
1345 ImportSource::Archive(path) => {
1346 let bytes =
1347 fs::read(path).map_err(|e| Error::Profile(format!("reading {}: {}", path.display(), e)))?;
1348
1349 import_r2z_in(
1350 target_dir,
1351 cache_base,
1352 eco,
1353 &index,
1354 client,
1355 game,
1356 &bytes,
1357 installed_at_time,
1358 )
1359 .await?
1360 }
1361 ImportSource::Code(code) => {
1362 import_code_in(
1363 target_dir,
1364 cache_base,
1365 eco,
1366 &index,
1367 client,
1368 game,
1369 code,
1370 installed_at_time,
1371 )
1372 .await?
1373 }
1374 ImportSource::R2modmanDir(_) => {
1379 unreachable!("R2modmanDir returns from the early branch above this match")
1380 }
1381 };
1382
1383 Ok(ImportOutcome {
1384 source,
1385 installed: imported.installed,
1386 unchanged: imported.unchanged,
1387 reinstalled: Vec::new(),
1388 })
1389}
1390
1391#[allow(clippy::too_many_arguments)]
1393pub async fn import_r2z(
1394 base: &Path,
1395 game: &str,
1396 profile_name: &str,
1397 eco: &crate::ecosystem::Ecosystem,
1398 index: &crate::models::PackageIndex,
1399 client: &ThunderstoreClient,
1400 zip_bytes: &[u8],
1401 installed_at_time: u64,
1402) -> Result<ImportedMods> {
1403 import_r2z_in(
1404 &crate::profile::layout::profile_dir(base, game, profile_name),
1405 base,
1406 eco,
1407 index,
1408 client,
1409 game,
1410 zip_bytes,
1411 installed_at_time,
1412 )
1413 .await
1414}
1415
1416#[allow(clippy::too_many_arguments)]
1419pub async fn import_code(
1420 base: &Path,
1421 game: &str,
1422 profile_name: &str,
1423 eco: &crate::ecosystem::Ecosystem,
1424 index: &crate::models::PackageIndex,
1425 client: &ThunderstoreClient,
1426 code: &str,
1427 installed_at_time: u64,
1428) -> Result<ImportedMods> {
1429 import_code_in(
1430 &crate::profile::layout::profile_dir(base, game, profile_name),
1431 base,
1432 eco,
1433 index,
1434 client,
1435 game,
1436 code,
1437 installed_at_time,
1438 )
1439 .await
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444 use super::*;
1445
1446 fn profile_mod(name: &str, enabled: bool) -> ProfileMod {
1447 let mut entry = crate::profile::mod_entry_from_version(name, "Author", &sample_version(), 1);
1448 entry.enabled = enabled;
1449
1450 entry
1451 }
1452
1453 fn sample_version() -> crate::models::Version {
1454 let json = r#"{"name":"Mod","full_name":"Author-Mod","description":"d","icon":"i",
1456 "version_number":"1.2.3","dependencies":[],"download_url":"u","downloads":1,
1457 "date_created":"2024-01-01T12:00:00Z","website_url":"w","is_active":true,
1458 "uuid4":"u","file_size":1}"#;
1459
1460 serde_json::from_str(json).unwrap()
1461 }
1462
1463 #[test]
1464 fn import_source_classifies_a_directory_a_file_and_a_code() {
1465 let dir = tempfile::tempdir().unwrap();
1466 let profile = dir.path().join("SomeProfile");
1467 let archive = dir.path().join("export.r2z");
1468
1469 std::fs::create_dir_all(&profile).unwrap();
1470 write_test_file(&archive, b"not really a zip");
1471
1472 assert!(matches!(
1473 ImportSource::classify(profile.to_str().unwrap()),
1474 ImportSource::R2modmanDir(path) if path == profile
1475 ));
1476 assert!(matches!(
1477 ImportSource::classify(archive.to_str().unwrap()),
1478 ImportSource::Archive(path) if path == archive
1479 ));
1480
1481 assert!(matches!(
1485 ImportSource::classify("abc123-def456"),
1486 ImportSource::Code(code) if code == "abc123-def456"
1487 ));
1488 assert!(matches!(
1489 ImportSource::classify("/no/such/path.r2z"),
1490 ImportSource::Code(_)
1491 ));
1492 }
1493
1494 #[test]
1495 fn export_format_maps_mods_and_preserves_enabled() {
1496 let mods = vec![
1497 profile_mod("Author-A", true),
1498 profile_mod("Author-B", false),
1499 ];
1500
1501 let export = export_format_from_mods("MyProfile", &mods);
1502
1503 assert_eq!(export.profile_name, "MyProfile");
1504 assert_eq!(export.mods.len(), 2);
1505 assert_eq!(export.mods[0].name, "Author-A");
1506 assert_eq!(
1507 export.mods[0].version,
1508 SemVer {
1509 major: 1,
1510 minor: 2,
1511 patch: 3
1512 }
1513 );
1514 assert!(export.mods[0].enabled);
1515 assert!(!export.mods[1].enabled);
1516 }
1517
1518 #[test]
1519 fn serialized_r2x_uses_version_not_version_number() {
1520 let mods = vec![profile_mod("Author-A", true)];
1521 let yaml = export_r2x_to_string(&export_format_from_mods("MyProfile", &mods)).unwrap();
1522
1523 assert!(yaml.contains("profileName: MyProfile"));
1524 assert!(yaml.contains("name: Author-A"));
1525 assert!(yaml.contains("version:"));
1526 assert!(yaml.contains("major: 1"));
1527 assert!(!yaml.contains("versionNumber"));
1529 }
1530
1531 #[test]
1532 fn parses_real_r2x_and_defaults_missing_enabled_to_true() {
1533 let yaml = r#"profileName: Shared
1534mods:
1535 - name: Author-A
1536 version:
1537 major: 2
1538 minor: 0
1539 patch: 1
1540 enabled: false
1541 - name: Author-B
1542 version:
1543 major: 1
1544 minor: 0
1545 patch: 0
1546"#;
1547
1548 let export = parse_export_r2x(yaml).unwrap();
1549
1550 assert_eq!(export.profile_name, "Shared");
1551 assert_eq!(export.mods.len(), 2);
1552 assert!(!export.mods[0].enabled);
1553 assert!(export.mods[1].enabled);
1555 }
1556
1557 #[test]
1558 fn parse_export_r2x_rejects_malformed() {
1559 assert!(parse_export_r2x("not: a valid export").is_err());
1560 }
1561
1562 fn write_test_file(path: &Path, bytes: &[u8]) {
1563 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1564 std::fs::write(path, bytes).unwrap();
1565 }
1566
1567 #[test]
1568 fn is_pruned_matches_r2modman_rules() {
1569 assert!(is_pruned(Path::new("dotnet/x.json")));
1571 assert!(is_pruned(Path::new("_state/Author-Mod-state.yml")));
1572 assert!(is_pruned(Path::new("BepInEx/config/BepInEx.cfg")));
1573 assert!(is_pruned(Path::new(
1574 "BepInEx/plugins/Author-Mod/manifest.json"
1575 )));
1576 assert!(is_pruned(Path::new("MelonLoader/x.cfg")));
1577 assert!(is_pruned(Path::new("GDWeave/core/a.json")));
1578 assert!(is_pruned(Path::new("GDWeave/mods/b.json")));
1579 assert!(is_pruned(Path::new("GDWeave/GDWeave.log")));
1580 assert!(is_pruned(Path::new("mods.yml")));
1581
1582 assert!(!is_pruned(Path::new(
1584 "BepInEx/plugins/Author-Mod/settings.cfg"
1585 )));
1586 assert!(!is_pruned(Path::new("GDWeave/other.json")));
1587 assert!(!is_pruned(Path::new("notes.txt")));
1588 }
1589
1590 #[test]
1591 fn is_config_file_matches_supported_extensions() {
1592 assert!(is_config_file(Path::new("a/b.cfg")));
1593 assert!(is_config_file(Path::new("a/b.YAML")));
1594 assert!(is_config_file(Path::new("b.ini")));
1595 assert!(!is_config_file(Path::new("a/b.dll")));
1596 assert!(!is_config_file(Path::new("a/b")));
1597 }
1598
1599 #[test]
1600 fn collect_export_paths_selects_configs_and_skips_pruned() {
1601 let dir = tempfile::tempdir().unwrap();
1602 let root = dir.path();
1603
1604 write_test_file(&root.join("mods.yml"), b"[]");
1605 write_test_file(&root.join("BepInEx/config/BepInEx.cfg"), b"cfg");
1606 write_test_file(&root.join("BepInEx/plugins/Author-Mod/settings.cfg"), b"s");
1607 write_test_file(
1608 &root.join("BepInEx/plugins/Author-Mod/manifest.json"),
1609 b"{}",
1610 );
1611 write_test_file(&root.join("BepInEx/plugins/Author-Mod/Mod.dll"), b"dll");
1612 write_test_file(&root.join("_state/Author-Mod-state.yml"), b"x");
1613
1614 let paths = collect_export_paths(root).unwrap();
1615 let as_str: Vec<String> = paths
1616 .iter()
1617 .map(|p| p.to_string_lossy().replace('\\', "/"))
1618 .collect();
1619
1620 assert_eq!(
1621 as_str,
1622 vec!["BepInEx/plugins/Author-Mod/settings.cfg".to_string()]
1623 );
1624 }
1625
1626 #[test]
1627 fn build_r2z_contains_manifest_and_configs() {
1628 let dir = tempfile::tempdir().unwrap();
1629 let root = dir.path();
1630
1631 crate::profile::modlist::write(root, &[profile_mod("Author-A", true)]).unwrap();
1632 write_test_file(&root.join("BepInEx/config/BepInEx.cfg"), b"shared");
1633 write_test_file(&root.join("BepInEx/plugins/Author-A/settings.cfg"), b"mine");
1634 write_test_file(&root.join("BepInEx/plugins/Author-A/Mod.dll"), b"dll");
1635
1636 let bytes = build_r2z_in(root, "MyProfile").unwrap();
1637
1638 let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
1639 let names: Vec<String> = (0..zip.len())
1640 .map(|i| zip.by_index(i).unwrap().name().replace('\\', "/"))
1641 .collect();
1642
1643 assert!(names.contains(&"export.r2x".to_string()));
1644 assert!(names.contains(&"config/BepInEx.cfg".to_string()));
1646 assert!(names.contains(&"BepInEx/plugins/Author-A/settings.cfg".to_string()));
1648 assert!(!names.iter().any(|n| n.ends_with("Mod.dll")));
1650 assert!(!names.contains(&"mods.yml".to_string()));
1651
1652 let mut manifest = String::new();
1653 std::io::Read::read_to_string(&mut zip.by_name("export.r2x").unwrap(), &mut manifest).unwrap();
1654
1655 let export = parse_export_r2x(&manifest).unwrap();
1656
1657 assert_eq!(export.profile_name, "MyProfile");
1658 assert_eq!(export.mods[0].name, "Author-A");
1659 }
1660
1661 #[test]
1662 fn export_to_file_uses_name_and_timestamp() {
1663 let dir = tempfile::tempdir().unwrap();
1664 let root = dir.path().join("profile");
1665 let out = dir.path().join("exports");
1666
1667 crate::profile::modlist::write(&root, &[profile_mod("Author-A", true)]).unwrap();
1668
1669 let path = export_to_file_in(&root, "MyProfile", &out, 1_700_000_000_000).unwrap();
1670
1671 assert_eq!(
1672 path.file_name().unwrap().to_string_lossy(),
1673 "MyProfile_1700000000000.r2z"
1674 );
1675 assert!(path.exists());
1676 }
1677
1678 #[test]
1679 fn export_to_file_rejects_unsafe_profile_name() {
1680 let dir = tempfile::tempdir().unwrap();
1681 let root = dir.path().join("profile");
1682 let out = dir.path().join("exports");
1683
1684 crate::profile::modlist::write(&root, &[profile_mod("Author-A", true)]).unwrap();
1685
1686 assert!(export_to_file_in(&root, "../evil", &out, 1).is_err());
1687 assert!(!dir.path().join("evil_1.r2z").exists());
1688 }
1689
1690 #[test]
1691 fn encode_profile_payload_prefixes_and_base64s() {
1692 let payload = encode_profile_payload(b"ABC").unwrap();
1693
1694 assert_eq!(payload, b"#r2modman\nQUJD");
1695 }
1696
1697 #[test]
1698 fn encode_profile_payload_rejects_oversize() {
1699 let big = vec![0u8; MAX_EXPORT_CODE_BYTES + 1];
1700
1701 assert!(matches!(
1702 encode_profile_payload(&big),
1703 Err(Error::SizeLimitExceeded { .. })
1704 ));
1705 }
1706
1707 #[test]
1708 fn decode_profile_payload_round_trips_and_rejects_bad_prefix() {
1709 let payload = encode_profile_payload(b"ABC").unwrap();
1710
1711 assert_eq!(decode_profile_payload(&payload).unwrap(), b"ABC");
1712 assert!(decode_profile_payload(b"nope\nQUJD").is_err());
1713 }
1714
1715 #[test]
1716 fn import_config_key_remaps_config_and_blocks_unsafe() {
1717 assert_eq!(
1719 import_config_key("config/BepInEx.cfg"),
1720 Some(PathBuf::from("BepInEx/config/BepInEx.cfg"))
1721 );
1722 assert_eq!(
1724 import_config_key("BepInEx/plugins/Author-A/settings.cfg"),
1725 Some(PathBuf::from("BepInEx/plugins/Author-A/settings.cfg"))
1726 );
1727 assert_eq!(import_config_key("export.r2x"), None);
1729 assert_eq!(import_config_key("mods.yml"), None);
1730 assert_eq!(import_config_key("BepInEx/plugins/evil.dll"), None);
1732 assert_eq!(import_config_key("../escape.cfg"), None);
1734 }
1735
1736 #[test]
1737 fn import_config_key_blocks_trailing_dot_and_space_bypass() {
1738 let blocked = [
1742 "evil.dll.",
1743 "evil.dll ",
1744 "evil.dll. . ",
1745 "evil.DLL",
1746 "evil.cfg.dll",
1747 ];
1748
1749 for name in blocked {
1750 assert_eq!(
1751 import_config_key(name),
1752 None,
1753 "{name} should have been blocked"
1754 );
1755 }
1756
1757 assert_eq!(import_config_key("a.cfg"), Some(PathBuf::from("a.cfg")));
1759 }
1760
1761 #[test]
1762 fn read_export_accepts_r2z_and_bare_r2x() {
1763 let dir = tempfile::tempdir().unwrap();
1764 let root = dir.path();
1765
1766 crate::profile::modlist::write(root, &[profile_mod("Author-A", true)]).unwrap();
1767
1768 let zip_bytes = build_r2z_in(root, "MyProfile").unwrap();
1769
1770 assert_eq!(read_export(&zip_bytes).unwrap().profile_name, "MyProfile");
1771
1772 let yaml = export_r2x_to_string(&export_format_from_mods("Bare", &[])).unwrap();
1773
1774 assert_eq!(read_export(yaml.as_bytes()).unwrap().profile_name, "Bare");
1775 }
1776
1777 #[test]
1778 fn extract_configs_writes_remapped_files_only() {
1779 let dir = tempfile::tempdir().unwrap();
1780 let source = dir.path().join("source");
1781 let target = dir.path().join("target");
1782
1783 crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
1784 write_test_file(&source.join("BepInEx/config/BepInEx.cfg"), b"shared");
1785 write_test_file(
1786 &source.join("BepInEx/plugins/Author-A/settings.cfg"),
1787 b"mine",
1788 );
1789
1790 let zip_bytes = build_r2z_in(&source, "MyProfile").unwrap();
1791 let written = extract_configs_in(&target, &zip_bytes).unwrap();
1792
1793 assert!(target.join("BepInEx/config/BepInEx.cfg").exists());
1794 assert!(
1795 target
1796 .join("BepInEx/plugins/Author-A/settings.cfg")
1797 .exists()
1798 );
1799 assert!(!target.join("export.r2x").exists());
1801 assert_eq!(written.len(), 2);
1802 }
1803
1804 #[test]
1805 fn extract_configs_treats_non_zip_input_as_no_configs() {
1806 let dir = tempfile::tempdir().unwrap();
1807 let target = dir.path().join("target");
1808
1809 let yaml = export_r2x_to_string(&export_format_from_mods("Bare", &[])).unwrap();
1811
1812 let written = extract_configs_in(&target, yaml.as_bytes()).unwrap();
1813
1814 assert!(written.is_empty());
1815 }
1816
1817 #[test]
1818 fn extract_configs_still_rejects_a_genuinely_corrupt_zip() {
1819 let dir = tempfile::tempdir().unwrap();
1820 let target = dir.path().join("target");
1821
1822 let corrupt = b"PK\x03\x04not a real zip".to_vec();
1824
1825 assert!(extract_configs_in(&target, &corrupt).is_err());
1826 }
1827
1828 #[test]
1829 #[cfg(unix)]
1830 fn extract_configs_refuses_to_write_through_a_symlink() {
1831 use std::os::unix::fs::symlink;
1832
1833 let outside = tempfile::tempdir().unwrap();
1834 let secret = outside.path().join("secret.cfg");
1835 std::fs::write(&secret, b"original").unwrap();
1836
1837 let source = tempfile::tempdir().unwrap();
1838
1839 crate::profile::modlist::write(source.path(), &[profile_mod("Author-A", true)]).unwrap();
1840 write_test_file(
1841 &source.path().join("BepInEx/config/BepInEx.cfg"),
1842 b"new-content",
1843 );
1844
1845 let zip_bytes = build_r2z_in(source.path(), "MyProfile").unwrap();
1846
1847 let target = tempfile::tempdir().unwrap();
1850 let dest = target.path().join("BepInEx/config/BepInEx.cfg");
1851
1852 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1853 symlink(&secret, &dest).unwrap();
1854
1855 let err = extract_configs_in(target.path(), &zip_bytes).unwrap_err();
1856
1857 assert!(matches!(err, Error::Profile(_)));
1858 assert_eq!(std::fs::read_to_string(&secret).unwrap(), "original");
1860 }
1861
1862 fn zip_with_one_zero_filled_entry(name: &str, inflated_len: usize) -> Vec<u8> {
1865 let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
1866 let options: FileOptions<'_, ()> =
1867 FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
1868
1869 zip.start_file(name, options).unwrap();
1870 zip.write_all(&vec![0u8; inflated_len]).unwrap();
1871
1872 zip.finish().unwrap().into_inner()
1873 }
1874
1875 #[test]
1876 fn extract_configs_rejects_entry_exceeding_the_per_entry_cap() {
1877 let bytes = zip_with_one_zero_filled_entry(
1878 "BepInEx/config/Big.cfg",
1879 (MAX_IMPORT_ENTRY_BYTES + 1) as usize,
1880 );
1881
1882 let dir = tempfile::tempdir().unwrap();
1883 let err = extract_configs_in(dir.path(), &bytes).unwrap_err();
1884
1885 assert!(matches!(err, Error::ExtractLimitExceeded(_)));
1886 }
1887
1888 #[test]
1889 fn extract_configs_rejects_import_exceeding_the_cumulative_cap() {
1890 let per_entry_len = (MAX_IMPORT_ENTRY_BYTES - 1024) as usize;
1893 let entries_needed = (MAX_IMPORT_TOTAL_BYTES / per_entry_len as u64) + 1;
1894
1895 let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
1896 let options: FileOptions<'_, ()> =
1897 FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
1898 let chunk = vec![0u8; per_entry_len];
1899
1900 for i in 0..entries_needed {
1901 zip
1902 .start_file(format!("BepInEx/config/{i}.cfg"), options)
1903 .unwrap();
1904 zip.write_all(&chunk).unwrap();
1905 }
1906
1907 let bytes = zip.finish().unwrap().into_inner();
1908
1909 let dir = tempfile::tempdir().unwrap();
1910 let err = extract_configs_in(dir.path(), &bytes).unwrap_err();
1911
1912 assert!(matches!(err, Error::ExtractLimitExceeded(_)));
1913 }
1914
1915 #[test]
1916 fn import_r2modman_dir_adopts_an_existing_profile() {
1917 let dir = tempfile::tempdir().unwrap();
1918 let source = dir.path().join("source");
1919 let target = dir.path().join("target");
1920
1921 crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
1922 write_test_file(&source.join("BepInEx/plugins/Author-A/Mod.dll"), b"dll");
1923
1924 import_r2modman_dir(&source, &target).unwrap();
1925
1926 assert!(target.join("BepInEx/plugins/Author-A/Mod.dll").exists());
1927 assert_eq!(crate::profile::modlist::read(&target).unwrap().len(), 1);
1928 assert!(import_r2modman_dir(&dir.path().join("missing"), &target).is_err());
1929 }
1930
1931 #[test]
1932 #[cfg(unix)]
1933 fn import_r2modman_dir_refuses_to_copy_through_a_symlink() {
1934 use std::os::unix::fs::symlink;
1935
1936 let outside = tempfile::tempdir().unwrap();
1937 let secret = outside.path().join("secret.cfg");
1938 std::fs::write(&secret, b"original").unwrap();
1939
1940 let dir = tempfile::tempdir().unwrap();
1941 let source = dir.path().join("source");
1942 let target = dir.path().join("target");
1943
1944 crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
1945 write_test_file(
1946 &source.join("BepInEx/plugins/Author-A/settings.cfg"),
1947 b"new-content",
1948 );
1949
1950 let dest = target.join("BepInEx/plugins/Author-A/settings.cfg");
1952
1953 std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1954 symlink(&secret, &dest).unwrap();
1955
1956 let err = import_r2modman_dir(&source, &target).unwrap_err();
1957
1958 assert!(matches!(err, Error::Profile(_)));
1959 assert_eq!(std::fs::read_to_string(&secret).unwrap(), "original");
1961 }
1962
1963 #[test]
1964 fn copy_tree_skips_a_same_file_copy_instead_of_truncating_it() {
1965 let dir = tempfile::tempdir().unwrap();
1966 let root = dir.path();
1967
1968 write_test_file(&root.join("sub/file.txt"), b"original bytes");
1969
1970 copy_tree(root, root).unwrap();
1977
1978 let bytes = std::fs::read(root.join("sub/file.txt")).unwrap();
1979
1980 assert_eq!(
1981 bytes, b"original bytes",
1982 "a same-file copy must be skipped, not truncate the file to zero bytes"
1983 );
1984 }
1985}