Skip to main content

thunderstore_engine/profile/
portability.rs

1//! r2modman-compatible profile portability: `.r2z` export/import and shareable
2//! Thunderstore profile codes.
3//!
4//! A `.r2z` is a zip holding an `export.r2x` manifest (the mod list) plus the
5//! target's config files. Note the manifest names the version field `version`,
6//! while `mods.yml` names it `versionNumber` — same shape, different key.
7//!
8//! Every operation is target-directory generic (`*_in`), with an r2modman-layout
9//! wrapper, so a game server can export its tracked mod set just as a desktop
10//! client exports a named profile.
11//!
12//! **Import installs each mod's latest version, not the exported one.** The
13//! manifest's `version` field is recorded and re-exported faithfully, but on
14//! import every mod resolves through
15//! [`crate::models::DependencyGraph::resolve`], which is version-agnostic: the
16//! version suffix is discarded and the *latest* available version is
17//! installed instead. There is no version pinning, so a recipient importing an
18//! export pinned to `version: 1.2.3` may receive `2.0.0` installed, with no
19//! warning.
20
21#![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/// One entry of an `export.r2x` mod list.
36///
37/// `#[non_exhaustive]`: fields may be added as the format grows.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[non_exhaustive]
40pub struct ExportMod {
41  /// The `Owner-Name` identifier.
42  pub name: String,
43  /// The version. Note: `version`, not `versionNumber` as in `mods.yml`.
44  pub version: SemVer,
45  /// Whether the mod was enabled. Absent upstream means enabled.
46  #[serde(default = "default_true")]
47  pub enabled: bool,
48}
49
50/// serde default for [`ExportMod::enabled`]: r2modman treats an absent `enabled`
51/// as `true`.
52fn default_true() -> bool {
53  true
54}
55
56/// The `export.r2x` manifest inside a `.r2z`.
57///
58/// `#[non_exhaustive]`: fields may be added as the format grows.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60#[serde(rename_all = "camelCase")]
61#[non_exhaustive]
62pub struct ExportFormat {
63  /// The exported profile's display name.
64  pub profile_name: String,
65  /// The exported mod list.
66  pub mods: Vec<ExportMod>,
67}
68
69/// Builds an [`ExportFormat`] from a target's `mods.yml` entries.
70pub 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
86/// Serializes an [`ExportFormat`] to `export.r2x` YAML.
87pub 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
92/// Parses `export.r2x` YAML.
93pub 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
97/// Filename suffixes r2modman treats as config files worth exporting.
98const CONFIG_EXTENSIONS: [&str; 6] = [".cfg", ".txt", ".json", ".yml", ".yaml", ".ini"];
99
100/// Whether a target-relative path is excluded from an export.
101///
102/// Mirrors r2modman's export prune list: manager state (`_state/`, `mods.yml`),
103/// the runtime it never ships (`dotnet/`, `MelonLoader/`, `GDWeave/core|mods`),
104/// per-mod package metadata, and `BepInEx/config/` (added separately under the
105/// zip's `config/` folder). Pure — no filesystem access.
106fn 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
129/// Whether a path is a per-mod `BepInEx/plugins/<Owner-Name>/manifest.json`,
130/// which r2modman strips from an export.
131fn 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
140/// Whether a filename ends with one of [`CONFIG_EXTENSIONS`] (case-insensitive).
141/// Pure — no filesystem access.
142fn 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
151/// Collects the target-relative paths an export should carry: config files that
152/// survive the prune list, sorted for a deterministic archive.
153fn 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
168/// Recursively collects file paths under `dir`, relative to `root`, skipping
169/// symlinks so an export cannot escape the target (mirrors `install::walk_files`).
170fn 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
197/// The manifest filename inside a `.r2z`.
198const EXPORT_MANIFEST: &str = "export.r2x";
199
200/// The archive folder that a target's `BepInEx/config/` tree is re-rooted into.
201const ARCHIVE_CONFIG_DIR: &str = "config";
202
203/// The target-relative path of the shared BepInEx config tree.
204const BEPINEX_CONFIG_DIR: &str = "BepInEx/config";
205
206/// Builds the `.r2z` bytes for `target_dir`.
207///
208/// The archive carries `EXPORT_MANIFEST` (the mod list from `mods.yml`), the
209/// target's `BepInEx/config/` tree re-rooted under `config/`, and every other
210/// surviving config file at its target-relative path. Binaries and manager state
211/// are never exported (see `is_pruned`).
212///
213/// `profile_name` is recorded in the manifest as the exported profile's name and
214/// need not match any directory on disk.
215///
216/// # Errors
217///
218/// - [`Error::Profile`] if `target_dir` has no readable
219///   `mods.yml`, or the mod list cannot be serialized.
220/// - [`Error::Io`] if a config file cannot be read.
221/// - [`Error::Zip`] if the archive cannot be written.
222///
223/// # Examples
224///
225/// ```no_run
226/// use std::path::Path;
227/// use thunderstore_engine::profile::portability::build_r2z_in;
228///
229/// let bytes = build_r2z_in(Path::new("/srv/valheim"), "my-profile")?;
230///
231/// std::fs::write("my-profile.r2z", &bytes)?;
232/// # Ok::<(), thunderstore_engine::Error>(())
233/// ```
234pub 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
277/// Adds one file to the archive under `archive_path`.
278fn 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
297/// Writes `target_dir`'s export to `<out_dir>/<profile_name>_<timestamp_millis>.r2z`
298/// and returns the path. `timestamp_millis` is supplied by the caller so the
299/// filename is deterministic and testable. `profile_name` is validated (see
300/// [`crate::profile::layout::validate_name`]) because it becomes part of the
301/// output filename; an unchecked name could otherwise write outside `out_dir`.
302pub 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
323/// Exports an r2modman-layout profile into `<base>/<game>/exports/`.
324/// See [`export_to_file_in`].
325pub 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
339/// Upstream ceiling on a shared profile's zip, before base64.
340const MAX_EXPORT_CODE_BYTES: usize = 20_000_000;
341
342/// The literal prefix Thunderstore's profile-code payload carries.
343const PROFILE_DATA_PREFIX: &str = "#r2modman";
344
345/// Wraps `.r2z` bytes in the profile-code payload: the `#r2modman` prefix, a
346/// newline, then base64 of the zip. Rejects a zip above the upstream 20 MB cap
347/// before any upload is attempted.
348fn 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
364/// Exports `target_dir` as a shareable Thunderstore profile code.
365///
366/// `profile_name` is not validated here: unlike [`export_to_file_in`], it never
367/// becomes part of a filesystem path — it only rides inside the `export.r2x`
368/// manifest carried by the uploaded payload.
369pub 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
379/// Exports an r2modman-layout profile as a shareable code. See [`export_code_in`].
380pub 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
394/// Extensions never written by an import: a shared profile carries config, not
395/// executable code, so a hostile `.r2z` cannot plant a binary.
396const BLOCKED_IMPORT_EXTENSIONS: [&str; 12] = [
397  ".dll", ".exe", ".bat", ".cmd", ".com", ".ps1", ".sh", ".js", ".vbs", ".msi", ".scr", ".jar",
398];
399
400/// Unwraps a profile-code response body into `.r2z` bytes: validates the
401/// `#r2modman` prefix, then base64-decodes the remainder.
402fn 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
415/// Ceiling on a single inflated archive entry, and on the cumulative inflated
416/// size of an import. A shared profile carries text config, so this sits far
417/// above any legitimate export while rejecting a decompression bomb (DEFLATE
418/// can reach ~1032:1, so a tiny `.r2z` can otherwise inflate to gigabytes).
419const MAX_IMPORT_ENTRY_BYTES: u64 = 16 * 1024 * 1024;
420
421/// See `MAX_IMPORT_ENTRY_BYTES`.
422const MAX_IMPORT_TOTAL_BYTES: u64 = 64 * 1024 * 1024;
423
424/// Reads one archive entry fully into memory, capped at `MAX_IMPORT_ENTRY_BYTES`.
425///
426/// The cap is checked against the bytes actually streamed out of the
427/// decompressor, not any size the archive declares, so a crafted high-ratio
428/// entry cannot forge its way past it. Returns [`Error::ExtractLimitExceeded`]
429/// on breach — the same variant [`crate::extract`] uses for this class of guard.
430fn 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
447/// Reads an export manifest from either a `.r2z` archive or bare `.r2x` YAML.
448pub 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
470/// Maps an archive entry name to its target-relative destination, or `None` when
471/// the entry must not be written.
472///
473/// The manifest and `mods.yml` are skipped (the mod list is applied by installing,
474/// not by copying), a `config/…` entry is re-rooted back under `BepInEx/`, and any
475/// executable extension or unsafe path is refused. Pure — no filesystem access.
476///
477/// The blocklist check is matched against the name with trailing dots and
478/// spaces stripped: Windows silently drops those from the final path component
479/// on create, so an entry named e.g. `Evil.dll.` would otherwise land on disk as
480/// `Evil.dll` and bypass the filter on the platform that matters most here.
481fn 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
519/// Whether `bytes` open with a zip archive's `PK` magic number, rather than
520/// being a bare `.r2x` YAML manifest (see [`read_export`]).
521///
522/// A zip's first local-file header, or the end-of-central-directory record of
523/// an empty zip, always starts with the two bytes `PK`; a YAML manifest never
524/// does. This distinguishes "not a zip at all" from "a zip that failed to
525/// parse" — the latter is a real corruption and must still surface as an
526/// error rather than be swallowed as "no configs".
527fn looks_like_zip(bytes: &[u8]) -> bool {
528  bytes.len() >= 2 && &bytes[0..2] == b"PK"
529}
530
531/// Refuses to write through a pre-existing symlink at `dest`.
532///
533/// Mirrors [`crate::install::apply_install`]'s `RefuseSymlink` handling: a
534/// symlink already sitting at the destination is refused rather than
535/// followed, so a pre-planted leaf link cannot redirect the write outside the
536/// target. This is best-effort: it inspects the final path component only
537/// (not symlinked parent directories) and is not race-free against a
538/// concurrent attacker mutating the target mid-import.
539fn 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
554/// Extracts an export's config files into `target_dir`, returning the
555/// target-relative paths written. Entries `import_config_key` rejects are
556/// skipped.
557///
558/// A bare `.r2x` manifest (see [`read_export`]) carries no config files, so
559/// `zip_bytes` that don't even look like a zip (see `looks_like_zip`) are
560/// treated as "nothing to extract" rather than an error; a payload that does
561/// look like a zip but fails to parse is a genuine corruption and still
562/// surfaces as an error.
563///
564/// A pre-existing symlink at an entry's destination is refused rather than
565/// followed (see `refuse_symlink_dest`), so a planted leaf link cannot
566/// redirect a write outside `target_dir`.
567///
568/// Each entry is bounded by `MAX_IMPORT_ENTRY_BYTES`, and the running total
569/// across the whole import by `MAX_IMPORT_TOTAL_BYTES`, so a decompression
570/// bomb hiding in an otherwise-valid config entry cannot exhaust memory.
571pub 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/// Imports a `.r2z` (or a bare `.r2x` manifest, which carries no configs) into
620/// `target_dir`: installs every mod the manifest lists (each with its
621/// dependency closure) and restores the recorded `enabled` state, then
622/// extracts the archive's config files.
623///
624/// These three steps run in this order deliberately:
625///
626/// 1. **Install first, extract configs second.** A per-mod config under
627///    `BepInEx/plugins/<Owner-Name>/` is `Subdir`-tracked, so installing a
628///    package always (re)writes its packaged default. Extracting configs
629///    before installing would let that default clobber the user's exported
630///    config the moment its owning mod is installed; extracting after
631///    ensures the exported config is what's left on disk.
632/// 2. **Disable pass runs after every install, inside the batch.** It still
633///    runs only once every mod in the batch is installed, which is what
634///    makes it immune to a dependency installed later re-enabling an entry
635///    the export marked disabled. It now happens inside `install_batch`
636///    rather than as a loop here, and running before config extraction is
637///    harmless because disabling renames a mod's files rather than
638///    rewriting its config.
639///
640/// **Installs the latest version, not the exported one.** Each entry's
641/// `version` is not honored on import: resolution goes through
642/// [`crate::models::DependencyGraph::resolve`], which is version-agnostic and
643/// always installs a mod's latest available version. There is no version
644/// pinning — importing an export pinned to `1.2.3` can install `2.0.0` with no
645/// warning.
646///
647/// `cache_base` is the package cache root and must not be `target_dir` — see
648/// [`crate::profile::install_mod_in`]. Returns the sorted identifiers installed.
649#[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  // Configs are extracted after every install, so an exported per-mod config
688  // wins over the package default the install just wrote.
689  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/// Imports a shared profile by its Thunderstore code. See [`import_r2z_in`].
700#[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
727/// Copies an existing r2modman profile directory into `target_dir`, adopting its
728/// `mods.yml`, installed files, and config as-is.
729///
730/// This is a raw directory adoption, not an install: nothing is downloaded and no
731/// package cache is populated, so re-enabling a `State`-tracked mod afterwards
732/// requires that mod to be reinstalled first.
733///
734/// **The result may not be uninstallable.** r2modman never wrote this engine's
735/// `_state` tracker, so an adopted mod loader ends up with a `mods.yml` entry and
736/// no install record, which is exactly what
737/// [`ensure_removable`](crate::profile::ensure_removable) refuses and therefore
738/// what [`uninstall_mod_in`](crate::profile::uninstall_mod_in) refuses. Use
739/// [`adopt_r2modman_dir_in`] instead unless the offline raw copy is what you
740/// actually want.
741///
742/// # Errors
743///
744/// - [`Error::Profile`] if `source_profile_dir` is not a
745///   directory, or if it and `target_dir` are nested in one another (a copy
746///   would otherwise recurse into its own output; see
747///   [`ensure_disjoint_trees`]).
748/// - [`Error::Io`] if a file cannot be read or written.
749///
750/// Failure can leave `target_dir` partially populated: the copy is not
751/// transactional, so a caller that must not keep a half-imported profile should
752/// import into a scratch directory and move it into place on success.
753pub 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/// What [`adopt_r2modman_dir_in`] adopted, and what it had to reinstall to leave
767/// the profile coherent.
768#[derive(Debug, Clone, Default)]
769#[non_exhaustive]
770pub struct AdoptedProfile {
771  /// Every identifier the adopted `mods.yml` names, in the order it named them.
772  pub adopted: Vec<String>,
773  /// The subset reinstalled from Thunderstore so it would have an install
774  /// record, in the order they were reinstalled.
775  ///
776  /// Each came from the package's **latest** version, so a version the source
777  /// profile had pinned is not preserved. A caller that shows this to a user
778  /// should say so.
779  pub reinstalled: Vec<String>,
780}
781
782/// Copies an existing r2modman profile directory into `target_dir` and then
783/// reinstalls whatever the copy alone would have left un-uninstallable, so the
784/// result is a profile this engine can manage.
785///
786/// [`import_r2modman_dir`] on its own cannot produce one: r2modman never wrote
787/// this engine's `_state` tracker, so an adopted mod loader gets a `mods.yml`
788/// entry and no install record, and
789/// [`ensure_removable`](crate::profile::ensure_removable) then refuses to remove
790/// it. This reinstalls each such loader from its package, which is the *only* way
791/// to get an exact file list for one.
792///
793/// Reconstructing the tracker from the copied files instead is deliberately not
794/// attempted. A loader installs into `BepInEx/core`, which is itself a declared
795/// route, so a loader file sitting there is shape-indistinguishable from one a
796/// user dropped in by hand; attributing files by shape would let a later sweep
797/// delete a file the loader never placed.
798///
799/// **Reinstalls the latest version, not the adopted one.** Resolution goes
800/// through [`crate::models::DependencyGraph`], which is version-agnostic, so a
801/// version the source profile had pinned is not preserved. The returned
802/// [`AdoptedProfile::reinstalled`] names exactly which packages this happened to,
803/// so a caller can disclose it.
804///
805/// Takes a `client` rather than a prefetched
806/// [`PackageIndex`](crate::models::PackageIndex), unlike [`import_r2z_in`]: the
807/// index is fetched only once something actually needs reinstalling, so adopting
808/// a profile that names no unrecorded loader stays fully offline. Requiring the
809/// caller to pass an index would cost them a network round trip to find out they
810/// did not need one.
811///
812/// `cache_base` is the package cache root and must not be `target_dir`; see
813/// [`crate::profile::install_mod_in`].
814#[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  // Every recognised loader, not merely the first: the registry is keyed on
832  // package identity rather than on a game, so nothing rules out an adopted
833  // profile naming two.
834  //
835  // Presence is tested rather than parsing the record, so a target that already
836  // holds an *unreadable* one is left exactly as it was. Overwriting it would be
837  // a destructive guess, and `ensure_removable` reports it plainly later.
838  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/// What an adoption installed, what it could not, and what it swept.
875///
876/// Does not derive `Clone`: `failed` carries an [`Error`] per item, and `Error`
877/// itself does not implement `Clone` (it wraps `io::Error` and `reqwest::Error`,
878/// neither of which do either).
879#[derive(Debug, Default)]
880#[non_exhaustive]
881pub struct AdoptOutcome {
882  /// Identifiers now recorded, sorted.
883  pub adopted: Vec<String>,
884  /// Identifiers still missing from the record, sorted. Their files may be on
885  /// disk from an older installer, unrecorded, where a reconcile would sweep
886  /// them as delisted.
887  pub remaining: Vec<String>,
888  /// Root-relative folders the completing sweep removed. Empty unless every
889  /// desired name was adopted.
890  pub swept: Vec<PathBuf>,
891  /// Each desired name that errored while installing, with the reason
892  /// (unresolvable dependency, network error, checksum mismatch, and so on).
893  ///
894  /// This answers a different question than `remaining`: a name lands here only
895  /// when its own install attempt failed, whereas `remaining` is derived from the
896  /// record afterwards and also holds a name that resolved to nothing without
897  /// erroring. A name can therefore be in `remaining` without being in `failed`.
898  pub failed: Vec<(String, Error)>,
899}
900
901/// Whether `desired` names anything the record does not yet hold.
902///
903/// Keyed on missing entries rather than a missing record: the record is written
904/// after each package installs, so an adoption that failed partway leaves the
905/// file present but incomplete. Keying on the file's existence would both hide
906/// that and refuse the retry, stranding the remaining mods on disk where a later
907/// reconcile would sweep them.
908pub 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/// Adopts `desired` into `target_dir`'s record, then reconciles once complete.
923///
924/// Installing rewrites whatever an older folder-based installer placed, so this
925/// is safe over an existing install and safe to retry after a partial failure: an
926/// already-recorded entry is simply reinstalled, which is cheap once its archive
927/// is cached.
928///
929/// The delisted sweep runs **only** when every desired name is adopted. Running
930/// it against a partial record would delete mods that have not been reached yet.
931#[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  // Which names made it is read back from the record rather than from the batch
959  // outcome: a name can be missing because its own install failed or because it
960  // resolved to nothing, and the record is the authority either way.
961  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
1001/// Refuses a copy whose source is, contains, or sits inside `target`.
1002///
1003/// `fs::copy` opens the destination with `O_TRUNC` before reading the source, so
1004/// copying a file onto itself zeroes it and returns success. Importing a profile
1005/// into itself would therefore empty the game executable, the loader proxy, every
1006/// plugin assembly, and every hand-edited config, and only then fail parsing the
1007/// record it had just zeroed.
1008///
1009/// The two nesting cases are refused for the same reason rather than for being
1010/// merely odd: a source inside the target has its own tree as the copy
1011/// destination, so a repeated directory name copies into the directory being
1012/// read; a source containing the target has the copy walking into its own
1013/// destination, growing a nested tree until the filesystem gives out.
1014///
1015/// Paths are canonicalized before comparison, so a relative spelling or one
1016/// reached through a symlinked directory still compares equal to what it names. A
1017/// path that cannot be resolved is compared as written rather than assumed
1018/// disjoint.
1019pub 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
1052/// Canonicalizes as much of `path` as exists, for [`ensure_disjoint_trees`].
1053///
1054/// Tries `path.canonicalize()` first, then [`canonicalize_missing`] for a leaf
1055/// that does not exist yet. Neither is enough on its own for every caller of
1056/// `ensure_disjoint_trees`: `target_dir` is commonly not yet created at all
1057/// when `import_r2modman_dir` calls this, before `copy_tree` has made anything
1058/// under it, so more than one trailing path component can be missing, and
1059/// `canonicalize_missing` only resolves the immediate parent. So when both of
1060/// those fail, this walks up past every missing level in turn, using the
1061/// first ancestor that does canonicalize and rejoining the missing remainder
1062/// onto it. That resolves a symlink anywhere in the existing prefix, not only
1063/// in the immediate parent, which matters here: a symlinked ancestor two or
1064/// more levels above a not-yet-created leaf would otherwise compare as its
1065/// own unresolved spelling and could wrongly look disjoint from a source it
1066/// really nests inside or contains.
1067///
1068/// Falls back to `path` itself, unresolved, if no ancestor down to the path's
1069/// root can be canonicalized (for example a relative path with no existing
1070/// prefix, or a permissions error). That fallback is intentional and matches
1071/// `ensure_disjoint_trees`'s documented behavior: compare something rather
1072/// than assume disjoint.
1073fn 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
1096/// Recursively copies a directory tree, skipping symlinks in `src`.
1097///
1098/// A pre-existing symlink at a file's destination in `dst` is refused rather
1099/// than followed (see `refuse_symlink_dest`), so a planted leaf link cannot
1100/// redirect a copy outside `dst`.
1101fn 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      // A belt-and-braces second line of defence behind `ensure_disjoint_trees`:
1125      // a same-file copy truncates, so it is skipped rather than attempted even
1126      // if a caller reached here another way. Mirrors `stage_one_file`, including
1127      // running after the symlink refusal above, so a symlink planted at the
1128      // destination is still reported as a symlink even in the edge case where it
1129      // happens to resolve back to the source path.
1130      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/// What an import source string names.
1143///
1144/// A user or a UI hands over one string for "import this", and which of the
1145/// three import routes it means is decided by looking at the filesystem. That
1146/// decision is made here, once, so no caller re-derives it: a caller that wants
1147/// to word the routes differently, or preview what a pasted string would do,
1148/// matches on this instead of stating its own `is_dir`/`is_file` rules.
1149///
1150/// Deliberately **not** `#[non_exhaustive]`, matching
1151/// [`BadProfile`](crate::profile::layout::BadProfile) and
1152/// [`NotRemovable`](crate::profile::NotRemovable): a caller that words each route
1153/// should stop compiling when a fourth appears.
1154#[derive(Debug, Clone, PartialEq, Eq)]
1155pub enum ImportSource {
1156  /// An existing directory, treated as an r2modman profile to adopt in place.
1157  R2modmanDir(PathBuf),
1158  /// An existing file, treated as a `.r2z` export.
1159  Archive(PathBuf),
1160  /// Anything else, treated as a shareable Thunderstore profile code.
1161  Code(String),
1162}
1163
1164impl ImportSource {
1165  /// Classifies one import source string.
1166  ///
1167  /// A path that exists but is neither a directory nor a regular file (a
1168  /// dangling symlink, a socket) falls through to [`Self::Code`], where the
1169  /// attempt fails naming the code endpoint. `is_dir`/`is_file` follow symlinks,
1170  /// so a link to either is classified as its destination.
1171  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  /// Whether this route keeps the versions the source recorded.
1186  ///
1187  /// True only for [`Self::R2modmanDir`], which copies files as they are. The
1188  /// other two resolve every entry through
1189  /// [`DependencyGraph`](crate::models::DependencyGraph) and install each mod's
1190  /// latest version instead. Even an adoption reinstalls an unrecorded loader at
1191  /// latest, which is why [`ImportOutcome::reinstalled`] is reported separately.
1192  ///
1193  /// Exposed because it is the one thing a caller has to tell a user before they
1194  /// wonder why a pinned version changed, and deriving it from the variant is
1195  /// the sort of rule that goes stale in a client.
1196  pub fn preserves_versions(&self) -> bool {
1197    matches!(self, Self::R2modmanDir(_))
1198  }
1199}
1200
1201/// What an import installed, by which route.
1202///
1203/// Carries `source` so a caller can word the outcome per route, e.g. disclosing
1204/// that versions were not preserved, without classifying the source string a
1205/// second time.
1206#[derive(Debug)]
1207#[non_exhaustive]
1208pub struct ImportOutcome {
1209  /// How `source` was classified, and therefore which route ran.
1210  pub source: ImportSource,
1211  /// Identifiers now recorded in the target.
1212  pub installed: Vec<String>,
1213  /// Identifiers reinstalled at their latest version so they would have an
1214  /// install record. Only ever non-empty for [`ImportSource::R2modmanDir`]; see
1215  /// [`AdoptedProfile::reinstalled`].
1216  pub reinstalled: Vec<String>,
1217}
1218
1219/// Imports whatever `source` names into `target_dir`: an r2modman profile
1220/// directory, a `.r2z` file, or a Thunderstore profile code.
1221///
1222/// The single entry point for "import this string". [`ImportSource::classify`]
1223/// picks the route and the returned [`ImportOutcome`] says which one ran, so a
1224/// caller neither inspects the filesystem itself nor reads the archive to hand
1225/// over its bytes.
1226///
1227/// The package index is fetched only on the routes that need it, so adopting a
1228/// directory that names no unrecorded loader stays fully offline. That is why
1229/// this takes a `client` rather than a prefetched
1230/// [`PackageIndex`](crate::models::PackageIndex), matching
1231/// [`adopt_r2modman_dir_in`].
1232///
1233/// `cache_base` is the package cache root and must not be `target_dir`; see
1234/// [`crate::profile::install_mod_in`].
1235pub 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    // Unreachable: the `if let ImportSource::R2modmanDir(dir)` above returns
1298    // before this match, so the only way here is a variant added to
1299    // `ImportSource` without a corresponding arm, which the compiler catches
1300    // first. Not reachable by any caller input, so it is not a documented panic.
1301    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/// Imports a `.r2z` into an r2modman-layout profile. See [`import_r2z_in`].
1314#[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/// Imports a shared profile code into an r2modman-layout profile.
1339/// See [`import_code_in`].
1340#[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    // Built through the index so no `#[non_exhaustive]` literal is needed here.
1377    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    // Anything that is not on disk is a Thunderstore profile code. A
1404    // path-shaped string that does not exist counts too: there is nothing to
1405    // read, and the code endpoint is the only remaining thing it could name.
1406    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    // The distinction a caller has to disclose: adoption keeps the source's
1421    // versions apart from a reinstalled loader, while the other two always
1422    // install each mod's latest.
1423    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    // `mods.yml` uses `versionNumber`; `export.r2x` must NOT.
1462    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    // r2modman treats an absent `enabled` as true.
1488    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    // Dropped.
1504    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    // Kept.
1517    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    // BepInEx/config/ is re-rooted under config/ in the archive.
1579    assert!(names.contains(&"config/BepInEx.cfg".to_string()));
1580    // Other config files keep their target-relative path.
1581    assert!(names.contains(&"BepInEx/plugins/Author-A/settings.cfg".to_string()));
1582    // Binaries and mods.yml are never exported.
1583    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    // `config/` is re-rooted back under BepInEx/.
1652    assert_eq!(
1653      import_config_key("config/BepInEx.cfg"),
1654      Some(PathBuf::from("BepInEx/config/BepInEx.cfg"))
1655    );
1656    // Other entries keep their path.
1657    assert_eq!(
1658      import_config_key("BepInEx/plugins/Author-A/settings.cfg"),
1659      Some(PathBuf::from("BepInEx/plugins/Author-A/settings.cfg"))
1660    );
1661    // Manifest and manager state are skipped.
1662    assert_eq!(import_config_key("export.r2x"), None);
1663    assert_eq!(import_config_key("mods.yml"), None);
1664    // Executables are refused even inside an otherwise valid path.
1665    assert_eq!(import_config_key("BepInEx/plugins/evil.dll"), None);
1666    // Traversal is refused.
1667    assert_eq!(import_config_key("../escape.cfg"), None);
1668  }
1669
1670  #[test]
1671  fn import_config_key_blocks_trailing_dot_and_space_bypass() {
1672    // Windows strips trailing dots/spaces from the final path component on
1673    // create, so these must be blocked even though they don't literally end
1674    // with a blocked extension.
1675    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    // An ordinary config entry is unaffected.
1692    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    // The manifest itself is never written into the target.
1734    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    // A bare `.r2x` manifest — no zip container at all.
1744    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    // Looks like a zip (starts with the `PK` magic) but is truncated garbage.
1757    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    // Plant a symlink at the destination the archive's `config/BepInEx.cfg`
1782    // entry remaps to, pointing outside the target.
1783    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    // The write is refused and the symlink target outside the target dir is untouched.
1793    assert_eq!(std::fs::read_to_string(&secret).unwrap(), "original");
1794  }
1795
1796  /// Builds an in-memory zip with a single highly-compressible entry, so the
1797  /// archive itself stays tiny while its inflated content can dwarf the caps.
1798  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    // Each entry sits just under the per-entry cap individually, but enough of
1825    // them together must exceed the cumulative cap.
1826    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    // Plant a symlink at the destination the source's config file would copy to.
1885    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    // The copy is refused and the symlink target outside the target dir is untouched.
1894    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    // `ensure_disjoint_trees` refuses an overlapping source and target on
1905    // every public path (see `importing_a_directory_into_itself_is_refused_before_anything_is_truncated`
1906    // in the integration suite), so `copy_tree`'s own `same_file` skip is
1907    // otherwise never reached there. Calling it directly, the way a future
1908    // caller reaching `copy_tree` another way might, is the only way to
1909    // exercise this second line of defence.
1910    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}