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.** Most configs live in the
627///    shared `BepInEx/config/`, which an install never removes. A few mods
628///    keep settings inside their own `BepInEx/plugins/<Owner-Name>/` folder
629///    instead, and installing (or updating) a mod deletes that folder and
630///    writes the package's files fresh, as r2modman does. Extracting configs
631///    before installing would lose those exported files the moment their
632///    owning mod is installed; extracting after ensures the exported config
633///    is what's left on disk. A mod skipped as already current is not
634///    touched.
635/// 2. **Disable pass runs after every install, inside the batch.** It still
636///    runs only once every mod in the batch is installed, which is what
637///    makes it immune to a dependency installed later re-enabling an entry
638///    the export marked disabled. It now happens inside `install_batch`
639///    rather than as a loop here, and running before config extraction is
640///    harmless because disabling renames a mod's files rather than
641///    rewriting its config.
642///
643/// **Installs the latest version, not the exported one.** Each entry's
644/// `version` is not honored on import: resolution goes through
645/// [`crate::models::DependencyGraph::resolve`], which is version-agnostic and
646/// always installs a mod's latest available version. There is no version
647/// pinning — importing an export pinned to `1.2.3` can install `2.0.0` with no
648/// warning.
649///
650/// `cache_base` is the package cache root and must not be `target_dir` — see
651/// [`crate::profile::install_mod_in`]. Returns the sorted identifiers the
652/// import covers, dependencies included: those installed and those skipped as
653/// already current, so importing a profile the target already matches still
654/// lists every mod.
655#[allow(clippy::too_many_arguments)]
656pub async fn import_r2z_in(
657  target_dir: &Path,
658  cache_base: &Path,
659  eco: &crate::ecosystem::Ecosystem,
660  index: &crate::models::PackageIndex,
661  client: &ThunderstoreClient,
662  game: &str,
663  zip_bytes: &[u8],
664  installed_at_time: u64,
665) -> Result<Vec<String>> {
666  let export = read_export(zip_bytes)?;
667
668  let desired: Vec<String> = export.mods.iter().map(|entry| entry.name.clone()).collect();
669  let protect: Vec<String> = export
670    .mods
671    .iter()
672    .filter(|entry| !entry.enabled)
673    .map(|entry| entry.name.clone())
674    .collect();
675  let mut batch = crate::profile::InstallBatch::new(desired, protect);
676
677  // A mod already recorded at its latest version is skipped rather than
678  // reinstalled, so one the export has enabled but the target has disabled
679  // would otherwise stay disabled.
680  batch.enable_requested = export
681    .mods
682    .iter()
683    .filter(|entry| entry.enabled)
684    .map(|entry| entry.name.clone())
685    .collect();
686
687  let outcome = crate::profile::install_batch(
688    target_dir,
689    cache_base,
690    eco,
691    index,
692    client,
693    game,
694    &batch,
695    installed_at_time,
696  )
697  .await?;
698
699  // Configs are not extracted after a failure, so the import is unfinished.
700  // A retry skips the mods already installed and completes it.
701  if let Some((name, error)) = outcome.failed.into_iter().next() {
702    return Err(Error::Profile(format!(
703      "importing {name}: {error}; the import is unfinished (its config files were \
704       not restored), retry the import to finish it"
705    )));
706  }
707
708  // Configs are extracted after every install, so an exported per-mod config
709  // wins over the package default the install just wrote.
710  extract_configs_in(target_dir, zip_bytes)?;
711
712  // A mod skipped as already current is still part of the imported profile.
713  let mut imported = outcome.succeeded;
714
715  imported.extend(outcome.unchanged);
716  imported.sort();
717  imported.dedup();
718
719  Ok(imported)
720}
721
722/// Imports a shared profile by its Thunderstore code. See [`import_r2z_in`].
723#[allow(clippy::too_many_arguments)]
724pub async fn import_code_in(
725  target_dir: &Path,
726  cache_base: &Path,
727  eco: &crate::ecosystem::Ecosystem,
728  index: &crate::models::PackageIndex,
729  client: &ThunderstoreClient,
730  game: &str,
731  code: &str,
732  installed_at_time: u64,
733) -> Result<Vec<String>> {
734  let body = client.fetch_profile_code(code).await?;
735  let zip_bytes = decode_profile_payload(&body)?;
736
737  import_r2z_in(
738    target_dir,
739    cache_base,
740    eco,
741    index,
742    client,
743    game,
744    &zip_bytes,
745    installed_at_time,
746  )
747  .await
748}
749
750/// Copies an existing r2modman profile directory into `target_dir`, adopting its
751/// `mods.yml`, installed files, and config as-is.
752///
753/// This is a raw directory adoption, not an install: nothing is downloaded and no
754/// package cache is populated, so re-enabling a `State`-tracked mod afterwards
755/// requires that mod to be reinstalled first.
756///
757/// **The result may not be uninstallable.** r2modman never wrote this engine's
758/// `_state` tracker, so an adopted mod loader ends up with a `mods.yml` entry and
759/// no install record, which is exactly what
760/// [`ensure_removable`](crate::profile::ensure_removable) refuses and therefore
761/// what [`uninstall_mod_in`](crate::profile::uninstall_mod_in) refuses. Use
762/// [`adopt_r2modman_dir_in`] instead unless the offline raw copy is what you
763/// actually want.
764///
765/// # Errors
766///
767/// - [`Error::Profile`] if `source_profile_dir` is not a
768///   directory, or if it and `target_dir` are nested in one another (a copy
769///   would otherwise recurse into its own output; see
770///   [`ensure_disjoint_trees`]).
771/// - [`Error::Io`] if a file cannot be read or written.
772///
773/// Failure can leave `target_dir` partially populated: the copy is not
774/// transactional, so a caller that must not keep a half-imported profile should
775/// import into a scratch directory and move it into place on success.
776pub fn import_r2modman_dir(source_profile_dir: &Path, target_dir: &Path) -> Result<()> {
777  if !source_profile_dir.is_dir() {
778    return Err(Error::Profile(format!(
779      "{} is not a profile directory",
780      source_profile_dir.display()
781    )));
782  }
783
784  ensure_disjoint_trees(source_profile_dir, target_dir)?;
785
786  copy_tree(source_profile_dir, target_dir)
787}
788
789/// What [`adopt_r2modman_dir_in`] adopted, and what it had to reinstall to leave
790/// the profile coherent.
791#[derive(Debug, Clone, Default)]
792#[non_exhaustive]
793pub struct AdoptedProfile {
794  /// Every identifier the adopted `mods.yml` names, in the order it named them.
795  pub adopted: Vec<String>,
796  /// The subset reinstalled from Thunderstore so it would have an install
797  /// record, in the order they were reinstalled.
798  ///
799  /// Each came from the package's **latest** version, so a version the source
800  /// profile had pinned is not preserved. A caller that shows this to a user
801  /// should say so.
802  pub reinstalled: Vec<String>,
803}
804
805/// Copies an existing r2modman profile directory into `target_dir` and then
806/// reinstalls whatever the copy alone would have left un-uninstallable, so the
807/// result is a profile this engine can manage.
808///
809/// [`import_r2modman_dir`] on its own cannot produce one: r2modman never wrote
810/// this engine's `_state` tracker, so an adopted mod loader gets a `mods.yml`
811/// entry and no install record, and
812/// [`ensure_removable`](crate::profile::ensure_removable) then refuses to remove
813/// it. This reinstalls each such loader from its package, which is the *only* way
814/// to get an exact file list for one.
815///
816/// Reconstructing the tracker from the copied files instead is deliberately not
817/// attempted. A loader installs into `BepInEx/core`, which is itself a declared
818/// route, so a loader file sitting there is shape-indistinguishable from one a
819/// user dropped in by hand; attributing files by shape would let a later sweep
820/// delete a file the loader never placed.
821///
822/// **Reinstalls the latest version, not the adopted one.** Resolution goes
823/// through [`crate::models::DependencyGraph`], which is version-agnostic, so a
824/// version the source profile had pinned is not preserved. The returned
825/// [`AdoptedProfile::reinstalled`] names exactly which packages this happened to,
826/// so a caller can disclose it.
827///
828/// Takes a `client` rather than a prefetched
829/// [`PackageIndex`](crate::models::PackageIndex), unlike [`import_r2z_in`]: the
830/// index is fetched only once something actually needs reinstalling, so adopting
831/// a profile that names no unrecorded loader stays fully offline. Requiring the
832/// caller to pass an index would cost them a network round trip to find out they
833/// did not need one.
834///
835/// `cache_base` is the package cache root and must not be `target_dir`; see
836/// [`crate::profile::install_mod_in`].
837#[allow(clippy::too_many_arguments)]
838pub async fn adopt_r2modman_dir_in(
839  source_profile_dir: &Path,
840  target_dir: &Path,
841  cache_base: &Path,
842  eco: &crate::ecosystem::Ecosystem,
843  client: &ThunderstoreClient,
844  game: &str,
845  installed_at_time: u64,
846) -> Result<AdoptedProfile> {
847  import_r2modman_dir(source_profile_dir, target_dir)?;
848
849  let adopted: Vec<String> = crate::profile::modlist::read(target_dir)?
850    .iter()
851    .map(|entry| entry.name.clone())
852    .collect();
853
854  // Every recognised loader, not merely the first: the registry is keyed on
855  // package identity rather than on a game, so nothing rules out an adopted
856  // profile naming two.
857  //
858  // Presence is tested rather than parsing the record, so a target that already
859  // holds an *unreadable* one is left exactly as it was. Overwriting it would be
860  // a destructive guess, and `ensure_removable` reports it plainly later.
861  let unrecorded: Vec<String> = adopted
862    .iter()
863    .filter(|name| eco.modloader_package(name).is_some())
864    .filter(|name| !crate::install::state_file_path(target_dir, name).exists())
865    .cloned()
866    .collect();
867
868  if unrecorded.is_empty() {
869    return Ok(AdoptedProfile {
870      adopted,
871      reinstalled: unrecorded,
872    });
873  }
874
875  let index = client.get_manifest().await?;
876
877  // Forced: the copied mods.yml already records each loader, usually at the
878  // version that would be installed, which an ordinary install skips. The
879  // reinstall is the point, since it is what writes the record.
880  for full_name in &unrecorded {
881    crate::profile::install_named_in(
882      target_dir,
883      cache_base,
884      eco,
885      &index,
886      client,
887      game,
888      full_name,
889      true,
890      installed_at_time,
891    )
892    .await?;
893  }
894
895  Ok(AdoptedProfile {
896    adopted,
897    reinstalled: unrecorded,
898  })
899}
900
901/// What an adoption installed, what it could not, and what it swept.
902///
903/// Does not derive `Clone`: `failed` carries an [`Error`] per item, and `Error`
904/// itself does not implement `Clone` (it wraps `io::Error` and `reqwest::Error`,
905/// neither of which do either).
906#[derive(Debug, Default)]
907#[non_exhaustive]
908pub struct AdoptOutcome {
909  /// Identifiers now recorded, sorted.
910  pub adopted: Vec<String>,
911  /// Identifiers still missing from the record, sorted. Their files may be on
912  /// disk from an older installer, unrecorded, where a reconcile would sweep
913  /// them as delisted.
914  pub remaining: Vec<String>,
915  /// Root-relative folders the completing sweep removed. Empty unless every
916  /// desired name was adopted.
917  pub swept: Vec<PathBuf>,
918  /// Each desired name that errored while installing, with the reason
919  /// (unresolvable dependency, network error, checksum mismatch, and so on).
920  ///
921  /// This answers a different question than `remaining`: a name lands here only
922  /// when its own install attempt failed, whereas `remaining` is derived from the
923  /// record afterwards and also holds a name that resolved to nothing without
924  /// erroring. A name can therefore be in `remaining` without being in `failed`.
925  pub failed: Vec<(String, Error)>,
926}
927
928/// Whether `desired` names anything the record does not yet hold.
929///
930/// Keyed on missing entries rather than a missing record: the record is written
931/// after each package installs, so an adoption that failed partway leaves the
932/// file present but incomplete. Keying on the file's existence would both hide
933/// that and refuse the retry, stranding the remaining mods on disk where a later
934/// reconcile would sweep them.
935pub fn needs_adoption(target_dir: &Path, desired: &[String]) -> Result<bool> {
936  if desired.is_empty() {
937    return Ok(false);
938  }
939
940  let recorded = crate::profile::modlist::read(target_dir).unwrap_or_default();
941
942  Ok(
943    desired
944      .iter()
945      .any(|name| crate::profile::modlist::find(&recorded, name).is_none()),
946  )
947}
948
949/// Adopts `desired` into `target_dir`'s record, then reconciles once complete.
950///
951/// Installing clears and rewrites whatever an older folder-based installer
952/// placed, so this is safe over an existing install and safe to retry after a
953/// partial failure: an entry already recorded at its latest version is skipped.
954///
955/// The delisted sweep runs **only** when every desired name is adopted. Running
956/// it against a partial record would delete mods that have not been reached yet.
957#[allow(clippy::too_many_arguments)]
958pub async fn adopt_names_in(
959  target_dir: &Path,
960  cache_base: &Path,
961  eco: &crate::ecosystem::Ecosystem,
962  index: &crate::models::PackageIndex,
963  client: &ThunderstoreClient,
964  game: &str,
965  desired: &[String],
966  installed_at_time: u64,
967) -> Result<AdoptOutcome> {
968  let batch = crate::profile::plan_install_batch(target_dir, desired, &[])?;
969
970  let batch_outcome = crate::profile::install_batch(
971    target_dir,
972    cache_base,
973    eco,
974    index,
975    client,
976    game,
977    &batch,
978    installed_at_time,
979  )
980  .await?;
981
982  let failed = batch_outcome.failed;
983
984  // Which names made it is read back from the record rather than from the batch
985  // outcome: a name can be missing because its own install failed or because it
986  // resolved to nothing, and the record is the authority either way.
987  let recorded = crate::profile::modlist::read(target_dir).unwrap_or_default();
988
989  let mut adopted: Vec<String> = Vec::new();
990  let mut remaining: Vec<String> = Vec::new();
991
992  for name in desired {
993    match crate::profile::modlist::find(&recorded, name) {
994      Some(_) => adopted.push(name.clone()),
995      None => remaining.push(name.clone()),
996    }
997  }
998
999  adopted.sort();
1000  remaining.sort();
1001
1002  if !remaining.is_empty() {
1003    return Ok(AdoptOutcome {
1004      adopted,
1005      remaining,
1006      swept: Vec::new(),
1007      failed,
1008    });
1009  }
1010
1011  let game_profile = eco
1012    .game(game)
1013    .and_then(|g| g.profile())
1014    .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
1015  let keep: std::collections::BTreeSet<String> =
1016    recorded.iter().map(|entry| entry.name.clone()).collect();
1017  let swept = crate::install::remove_delisted(game_profile, target_dir, &keep)?;
1018
1019  Ok(AdoptOutcome {
1020    adopted,
1021    remaining,
1022    swept,
1023    failed,
1024  })
1025}
1026
1027/// Refuses a copy whose source is, contains, or sits inside `target`.
1028///
1029/// `fs::copy` opens the destination with `O_TRUNC` before reading the source, so
1030/// copying a file onto itself zeroes it and returns success. Importing a profile
1031/// into itself would therefore empty the game executable, the loader proxy, every
1032/// plugin assembly, and every hand-edited config, and only then fail parsing the
1033/// record it had just zeroed.
1034///
1035/// The two nesting cases are refused for the same reason rather than for being
1036/// merely odd: a source inside the target has its own tree as the copy
1037/// destination, so a repeated directory name copies into the directory being
1038/// read; a source containing the target has the copy walking into its own
1039/// destination, growing a nested tree until the filesystem gives out.
1040///
1041/// Paths are canonicalized before comparison, so a relative spelling or one
1042/// reached through a symlinked directory still compares equal to what it names. A
1043/// path that cannot be resolved is compared as written rather than assumed
1044/// disjoint.
1045pub fn ensure_disjoint_trees(source: &Path, target: &Path) -> Result<()> {
1046  let source_at = resolve_as_far_as_it_exists(source);
1047  let target_at = resolve_as_far_as_it_exists(target);
1048
1049  if source_at == target_at {
1050    return Err(Error::Profile(format!(
1051      "refusing to copy {} onto itself: every file would be truncated to zero \
1052       bytes",
1053      source.display()
1054    )));
1055  }
1056
1057  if source_at.starts_with(&target_at) {
1058    return Err(Error::Profile(format!(
1059      "refusing to copy {} into {}: the source lives inside the destination, so \
1060       the copy would overwrite the tree it is reading",
1061      source.display(),
1062      target.display()
1063    )));
1064  }
1065
1066  if target_at.starts_with(&source_at) {
1067    return Err(Error::Profile(format!(
1068      "refusing to copy {} into {}: the destination lives inside the source, so \
1069       the copy would recurse into itself",
1070      source.display(),
1071      target.display()
1072    )));
1073  }
1074
1075  Ok(())
1076}
1077
1078/// Canonicalizes as much of `path` as exists, for [`ensure_disjoint_trees`].
1079///
1080/// Tries `path.canonicalize()` first, then [`canonicalize_missing`] for a leaf
1081/// that does not exist yet. Neither is enough on its own for every caller of
1082/// `ensure_disjoint_trees`: `target_dir` is commonly not yet created at all
1083/// when `import_r2modman_dir` calls this, before `copy_tree` has made anything
1084/// under it, so more than one trailing path component can be missing, and
1085/// `canonicalize_missing` only resolves the immediate parent. So when both of
1086/// those fail, this walks up past every missing level in turn, using the
1087/// first ancestor that does canonicalize and rejoining the missing remainder
1088/// onto it. That resolves a symlink anywhere in the existing prefix, not only
1089/// in the immediate parent, which matters here: a symlinked ancestor two or
1090/// more levels above a not-yet-created leaf would otherwise compare as its
1091/// own unresolved spelling and could wrongly look disjoint from a source it
1092/// really nests inside or contains.
1093///
1094/// Falls back to `path` itself, unresolved, if no ancestor down to the path's
1095/// root can be canonicalized (for example a relative path with no existing
1096/// prefix, or a permissions error). That fallback is intentional and matches
1097/// `ensure_disjoint_trees`'s documented behavior: compare something rather
1098/// than assume disjoint.
1099fn resolve_as_far_as_it_exists(path: &Path) -> PathBuf {
1100  if let Ok(canonical) = path.canonicalize() {
1101    return canonical;
1102  }
1103
1104  if let Ok(canonical) = canonicalize_missing(path) {
1105    return canonical;
1106  }
1107
1108  for ancestor in path.ancestors().skip(1) {
1109    let Ok(canonical_ancestor) = ancestor.canonicalize() else {
1110      continue;
1111    };
1112    let Ok(remainder) = path.strip_prefix(ancestor) else {
1113      continue;
1114    };
1115
1116    return canonical_ancestor.join(remainder);
1117  }
1118
1119  path.to_path_buf()
1120}
1121
1122/// Recursively copies a directory tree, skipping symlinks in `src`.
1123///
1124/// A pre-existing symlink at a file's destination in `dst` is refused rather
1125/// than followed (see `refuse_symlink_dest`), so a planted leaf link cannot
1126/// redirect a copy outside `dst`.
1127fn copy_tree(src: &Path, dst: &Path) -> Result<()> {
1128  fs::create_dir_all(dst)
1129    .map_err(|e| Error::Profile(format!("creating {}: {}", dst.display(), e)))?;
1130
1131  for entry in
1132    fs::read_dir(src).map_err(|e| Error::Profile(format!("reading {}: {}", src.display(), e)))?
1133  {
1134    let entry = entry.map_err(|e| Error::Profile(format!("reading an entry: {e}")))?;
1135    let path = entry.path();
1136    let metadata = fs::symlink_metadata(&path)
1137      .map_err(|e| Error::Profile(format!("inspecting {}: {}", path.display(), e)))?;
1138
1139    if metadata.file_type().is_symlink() {
1140      continue;
1141    }
1142
1143    let target = dst.join(entry.file_name());
1144
1145    if metadata.is_dir() {
1146      copy_tree(&path, &target)?;
1147    } else {
1148      refuse_symlink_dest(&target)?;
1149
1150      // A belt-and-braces second line of defence behind `ensure_disjoint_trees`:
1151      // a same-file copy truncates, so it is skipped rather than attempted even
1152      // if a caller reached here another way. Mirrors `stage_one_file`, including
1153      // running after the symlink refusal above, so a symlink planted at the
1154      // destination is still reported as a symlink even in the edge case where it
1155      // happens to resolve back to the source path.
1156      if same_file(&path, &target) {
1157        continue;
1158      }
1159
1160      fs::copy(&path, &target)
1161        .map_err(|e| Error::Profile(format!("copying to {}: {}", target.display(), e)))?;
1162    }
1163  }
1164
1165  Ok(())
1166}
1167
1168/// What an import source string names.
1169///
1170/// A user or a UI hands over one string for "import this", and which of the
1171/// three import routes it means is decided by looking at the filesystem. That
1172/// decision is made here, once, so no caller re-derives it: a caller that wants
1173/// to word the routes differently, or preview what a pasted string would do,
1174/// matches on this instead of stating its own `is_dir`/`is_file` rules.
1175///
1176/// Deliberately **not** `#[non_exhaustive]`, matching
1177/// [`BadProfile`](crate::profile::layout::BadProfile) and
1178/// [`NotRemovable`](crate::profile::NotRemovable): a caller that words each route
1179/// should stop compiling when a fourth appears.
1180#[derive(Debug, Clone, PartialEq, Eq)]
1181pub enum ImportSource {
1182  /// An existing directory, treated as an r2modman profile to adopt in place.
1183  R2modmanDir(PathBuf),
1184  /// An existing file, treated as a `.r2z` export.
1185  Archive(PathBuf),
1186  /// Anything else, treated as a shareable Thunderstore profile code.
1187  Code(String),
1188}
1189
1190impl ImportSource {
1191  /// Classifies one import source string.
1192  ///
1193  /// A path that exists but is neither a directory nor a regular file (a
1194  /// dangling symlink, a socket) falls through to [`Self::Code`], where the
1195  /// attempt fails naming the code endpoint. `is_dir`/`is_file` follow symlinks,
1196  /// so a link to either is classified as its destination.
1197  pub fn classify(source: &str) -> Self {
1198    let path = Path::new(source);
1199
1200    if path.is_dir() {
1201      return Self::R2modmanDir(path.to_path_buf());
1202    }
1203
1204    if path.is_file() {
1205      return Self::Archive(path.to_path_buf());
1206    }
1207
1208    Self::Code(source.to_string())
1209  }
1210
1211  /// Whether this route keeps the versions the source recorded.
1212  ///
1213  /// True only for [`Self::R2modmanDir`], which copies files as they are. The
1214  /// other two resolve every entry through
1215  /// [`DependencyGraph`](crate::models::DependencyGraph) and install each mod's
1216  /// latest version instead. Even an adoption reinstalls an unrecorded loader at
1217  /// latest, which is why [`ImportOutcome::reinstalled`] is reported separately.
1218  ///
1219  /// Exposed because it is the one thing a caller has to tell a user before they
1220  /// wonder why a pinned version changed, and deriving it from the variant is
1221  /// the sort of rule that goes stale in a client.
1222  pub fn preserves_versions(&self) -> bool {
1223    matches!(self, Self::R2modmanDir(_))
1224  }
1225}
1226
1227/// What an import installed, by which route.
1228///
1229/// Carries `source` so a caller can word the outcome per route, e.g. disclosing
1230/// that versions were not preserved, without classifying the source string a
1231/// second time.
1232#[derive(Debug)]
1233#[non_exhaustive]
1234pub struct ImportOutcome {
1235  /// How `source` was classified, and therefore which route ran.
1236  pub source: ImportSource,
1237  /// Identifiers now recorded in the target.
1238  pub installed: Vec<String>,
1239  /// Identifiers reinstalled at their latest version so they would have an
1240  /// install record. Only ever non-empty for [`ImportSource::R2modmanDir`]; see
1241  /// [`AdoptedProfile::reinstalled`].
1242  pub reinstalled: Vec<String>,
1243}
1244
1245/// Imports whatever `source` names into `target_dir`: an r2modman profile
1246/// directory, a `.r2z` file, or a Thunderstore profile code.
1247///
1248/// The single entry point for "import this string". [`ImportSource::classify`]
1249/// picks the route and the returned [`ImportOutcome`] says which one ran, so a
1250/// caller neither inspects the filesystem itself nor reads the archive to hand
1251/// over its bytes.
1252///
1253/// The package index is fetched only on the routes that need it, so adopting a
1254/// directory that names no unrecorded loader stays fully offline. That is why
1255/// this takes a `client` rather than a prefetched
1256/// [`PackageIndex`](crate::models::PackageIndex), matching
1257/// [`adopt_r2modman_dir_in`].
1258///
1259/// `cache_base` is the package cache root and must not be `target_dir`; see
1260/// [`crate::profile::install_mod_in`].
1261pub async fn import_in(
1262  target_dir: &Path,
1263  cache_base: &Path,
1264  eco: &crate::ecosystem::Ecosystem,
1265  client: &ThunderstoreClient,
1266  game: &str,
1267  source: &str,
1268  installed_at_time: u64,
1269) -> Result<ImportOutcome> {
1270  let source = ImportSource::classify(source);
1271
1272  if let ImportSource::R2modmanDir(dir) = &source {
1273    let adopted = adopt_r2modman_dir_in(
1274      dir,
1275      target_dir,
1276      cache_base,
1277      eco,
1278      client,
1279      game,
1280      installed_at_time,
1281    )
1282    .await?;
1283
1284    return Ok(ImportOutcome {
1285      source,
1286      installed: adopted.adopted,
1287      reinstalled: adopted.reinstalled,
1288    });
1289  }
1290
1291  let index = client.get_manifest().await?;
1292
1293  let installed = match &source {
1294    ImportSource::Archive(path) => {
1295      let bytes =
1296        fs::read(path).map_err(|e| Error::Profile(format!("reading {}: {}", path.display(), e)))?;
1297
1298      import_r2z_in(
1299        target_dir,
1300        cache_base,
1301        eco,
1302        &index,
1303        client,
1304        game,
1305        &bytes,
1306        installed_at_time,
1307      )
1308      .await?
1309    }
1310    ImportSource::Code(code) => {
1311      import_code_in(
1312        target_dir,
1313        cache_base,
1314        eco,
1315        &index,
1316        client,
1317        game,
1318        code,
1319        installed_at_time,
1320      )
1321      .await?
1322    }
1323    // Unreachable: the `if let ImportSource::R2modmanDir(dir)` above returns
1324    // before this match, so the only way here is a variant added to
1325    // `ImportSource` without a corresponding arm, which the compiler catches
1326    // first. Not reachable by any caller input, so it is not a documented panic.
1327    ImportSource::R2modmanDir(_) => {
1328      unreachable!("R2modmanDir returns from the early branch above this match")
1329    }
1330  };
1331
1332  Ok(ImportOutcome {
1333    source,
1334    installed,
1335    reinstalled: Vec::new(),
1336  })
1337}
1338
1339/// Imports a `.r2z` into an r2modman-layout profile. See [`import_r2z_in`].
1340#[allow(clippy::too_many_arguments)]
1341pub async fn import_r2z(
1342  base: &Path,
1343  game: &str,
1344  profile_name: &str,
1345  eco: &crate::ecosystem::Ecosystem,
1346  index: &crate::models::PackageIndex,
1347  client: &ThunderstoreClient,
1348  zip_bytes: &[u8],
1349  installed_at_time: u64,
1350) -> Result<Vec<String>> {
1351  import_r2z_in(
1352    &crate::profile::layout::profile_dir(base, game, profile_name),
1353    base,
1354    eco,
1355    index,
1356    client,
1357    game,
1358    zip_bytes,
1359    installed_at_time,
1360  )
1361  .await
1362}
1363
1364/// Imports a shared profile code into an r2modman-layout profile.
1365/// See [`import_code_in`].
1366#[allow(clippy::too_many_arguments)]
1367pub async fn import_code(
1368  base: &Path,
1369  game: &str,
1370  profile_name: &str,
1371  eco: &crate::ecosystem::Ecosystem,
1372  index: &crate::models::PackageIndex,
1373  client: &ThunderstoreClient,
1374  code: &str,
1375  installed_at_time: u64,
1376) -> Result<Vec<String>> {
1377  import_code_in(
1378    &crate::profile::layout::profile_dir(base, game, profile_name),
1379    base,
1380    eco,
1381    index,
1382    client,
1383    game,
1384    code,
1385    installed_at_time,
1386  )
1387  .await
1388}
1389
1390#[cfg(test)]
1391mod tests {
1392  use super::*;
1393
1394  fn profile_mod(name: &str, enabled: bool) -> ProfileMod {
1395    let mut entry = crate::profile::mod_entry_from_version(name, "Author", &sample_version(), 1);
1396    entry.enabled = enabled;
1397
1398    entry
1399  }
1400
1401  fn sample_version() -> crate::models::Version {
1402    // Built through the index so no `#[non_exhaustive]` literal is needed here.
1403    let json = r#"{"name":"Mod","full_name":"Author-Mod","description":"d","icon":"i",
1404      "version_number":"1.2.3","dependencies":[],"download_url":"u","downloads":1,
1405      "date_created":"2024-01-01T12:00:00Z","website_url":"w","is_active":true,
1406      "uuid4":"u","file_size":1}"#;
1407
1408    serde_json::from_str(json).unwrap()
1409  }
1410
1411  #[test]
1412  fn import_source_classifies_a_directory_a_file_and_a_code() {
1413    let dir = tempfile::tempdir().unwrap();
1414    let profile = dir.path().join("SomeProfile");
1415    let archive = dir.path().join("export.r2z");
1416
1417    std::fs::create_dir_all(&profile).unwrap();
1418    write_test_file(&archive, b"not really a zip");
1419
1420    assert!(matches!(
1421      ImportSource::classify(profile.to_str().unwrap()),
1422      ImportSource::R2modmanDir(path) if path == profile
1423    ));
1424    assert!(matches!(
1425      ImportSource::classify(archive.to_str().unwrap()),
1426      ImportSource::Archive(path) if path == archive
1427    ));
1428
1429    // Anything that is not on disk is a Thunderstore profile code. A
1430    // path-shaped string that does not exist counts too: there is nothing to
1431    // read, and the code endpoint is the only remaining thing it could name.
1432    assert!(matches!(
1433      ImportSource::classify("abc123-def456"),
1434      ImportSource::Code(code) if code == "abc123-def456"
1435    ));
1436    assert!(matches!(
1437      ImportSource::classify("/no/such/path.r2z"),
1438      ImportSource::Code(_)
1439    ));
1440  }
1441
1442  #[test]
1443  fn import_source_reports_whether_versions_survive() {
1444    let dir = tempfile::tempdir().unwrap();
1445
1446    // The distinction a caller has to disclose: adoption keeps the source's
1447    // versions apart from a reinstalled loader, while the other two always
1448    // install each mod's latest.
1449    assert!(ImportSource::R2modmanDir(dir.path().to_path_buf()).preserves_versions());
1450    assert!(!ImportSource::Archive(dir.path().join("e.r2z")).preserves_versions());
1451    assert!(!ImportSource::Code("abc123".to_string()).preserves_versions());
1452  }
1453
1454  #[test]
1455  fn export_format_maps_mods_and_preserves_enabled() {
1456    let mods = vec![
1457      profile_mod("Author-A", true),
1458      profile_mod("Author-B", false),
1459    ];
1460
1461    let export = export_format_from_mods("MyProfile", &mods);
1462
1463    assert_eq!(export.profile_name, "MyProfile");
1464    assert_eq!(export.mods.len(), 2);
1465    assert_eq!(export.mods[0].name, "Author-A");
1466    assert_eq!(
1467      export.mods[0].version,
1468      SemVer {
1469        major: 1,
1470        minor: 2,
1471        patch: 3
1472      }
1473    );
1474    assert!(export.mods[0].enabled);
1475    assert!(!export.mods[1].enabled);
1476  }
1477
1478  #[test]
1479  fn serialized_r2x_uses_version_not_version_number() {
1480    let mods = vec![profile_mod("Author-A", true)];
1481    let yaml = export_r2x_to_string(&export_format_from_mods("MyProfile", &mods)).unwrap();
1482
1483    assert!(yaml.contains("profileName: MyProfile"));
1484    assert!(yaml.contains("name: Author-A"));
1485    assert!(yaml.contains("version:"));
1486    assert!(yaml.contains("major: 1"));
1487    // `mods.yml` uses `versionNumber`; `export.r2x` must NOT.
1488    assert!(!yaml.contains("versionNumber"));
1489  }
1490
1491  #[test]
1492  fn parses_real_r2x_and_defaults_missing_enabled_to_true() {
1493    let yaml = r#"profileName: Shared
1494mods:
1495  - name: Author-A
1496    version:
1497      major: 2
1498      minor: 0
1499      patch: 1
1500    enabled: false
1501  - name: Author-B
1502    version:
1503      major: 1
1504      minor: 0
1505      patch: 0
1506"#;
1507
1508    let export = parse_export_r2x(yaml).unwrap();
1509
1510    assert_eq!(export.profile_name, "Shared");
1511    assert_eq!(export.mods.len(), 2);
1512    assert!(!export.mods[0].enabled);
1513    // r2modman treats an absent `enabled` as true.
1514    assert!(export.mods[1].enabled);
1515  }
1516
1517  #[test]
1518  fn parse_export_r2x_rejects_malformed() {
1519    assert!(parse_export_r2x("not: a valid export").is_err());
1520  }
1521
1522  fn write_test_file(path: &Path, bytes: &[u8]) {
1523    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1524    std::fs::write(path, bytes).unwrap();
1525  }
1526
1527  #[test]
1528  fn is_pruned_matches_r2modman_rules() {
1529    // Dropped.
1530    assert!(is_pruned(Path::new("dotnet/x.json")));
1531    assert!(is_pruned(Path::new("_state/Author-Mod-state.yml")));
1532    assert!(is_pruned(Path::new("BepInEx/config/BepInEx.cfg")));
1533    assert!(is_pruned(Path::new(
1534      "BepInEx/plugins/Author-Mod/manifest.json"
1535    )));
1536    assert!(is_pruned(Path::new("MelonLoader/x.cfg")));
1537    assert!(is_pruned(Path::new("GDWeave/core/a.json")));
1538    assert!(is_pruned(Path::new("GDWeave/mods/b.json")));
1539    assert!(is_pruned(Path::new("GDWeave/GDWeave.log")));
1540    assert!(is_pruned(Path::new("mods.yml")));
1541
1542    // Kept.
1543    assert!(!is_pruned(Path::new(
1544      "BepInEx/plugins/Author-Mod/settings.cfg"
1545    )));
1546    assert!(!is_pruned(Path::new("GDWeave/other.json")));
1547    assert!(!is_pruned(Path::new("notes.txt")));
1548  }
1549
1550  #[test]
1551  fn is_config_file_matches_supported_extensions() {
1552    assert!(is_config_file(Path::new("a/b.cfg")));
1553    assert!(is_config_file(Path::new("a/b.YAML")));
1554    assert!(is_config_file(Path::new("b.ini")));
1555    assert!(!is_config_file(Path::new("a/b.dll")));
1556    assert!(!is_config_file(Path::new("a/b")));
1557  }
1558
1559  #[test]
1560  fn collect_export_paths_selects_configs_and_skips_pruned() {
1561    let dir = tempfile::tempdir().unwrap();
1562    let root = dir.path();
1563
1564    write_test_file(&root.join("mods.yml"), b"[]");
1565    write_test_file(&root.join("BepInEx/config/BepInEx.cfg"), b"cfg");
1566    write_test_file(&root.join("BepInEx/plugins/Author-Mod/settings.cfg"), b"s");
1567    write_test_file(
1568      &root.join("BepInEx/plugins/Author-Mod/manifest.json"),
1569      b"{}",
1570    );
1571    write_test_file(&root.join("BepInEx/plugins/Author-Mod/Mod.dll"), b"dll");
1572    write_test_file(&root.join("_state/Author-Mod-state.yml"), b"x");
1573
1574    let paths = collect_export_paths(root).unwrap();
1575    let as_str: Vec<String> = paths
1576      .iter()
1577      .map(|p| p.to_string_lossy().replace('\\', "/"))
1578      .collect();
1579
1580    assert_eq!(
1581      as_str,
1582      vec!["BepInEx/plugins/Author-Mod/settings.cfg".to_string()]
1583    );
1584  }
1585
1586  #[test]
1587  fn build_r2z_contains_manifest_and_configs() {
1588    let dir = tempfile::tempdir().unwrap();
1589    let root = dir.path();
1590
1591    crate::profile::modlist::write(root, &[profile_mod("Author-A", true)]).unwrap();
1592    write_test_file(&root.join("BepInEx/config/BepInEx.cfg"), b"shared");
1593    write_test_file(&root.join("BepInEx/plugins/Author-A/settings.cfg"), b"mine");
1594    write_test_file(&root.join("BepInEx/plugins/Author-A/Mod.dll"), b"dll");
1595
1596    let bytes = build_r2z_in(root, "MyProfile").unwrap();
1597
1598    let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
1599    let names: Vec<String> = (0..zip.len())
1600      .map(|i| zip.by_index(i).unwrap().name().replace('\\', "/"))
1601      .collect();
1602
1603    assert!(names.contains(&"export.r2x".to_string()));
1604    // BepInEx/config/ is re-rooted under config/ in the archive.
1605    assert!(names.contains(&"config/BepInEx.cfg".to_string()));
1606    // Other config files keep their target-relative path.
1607    assert!(names.contains(&"BepInEx/plugins/Author-A/settings.cfg".to_string()));
1608    // Binaries and mods.yml are never exported.
1609    assert!(!names.iter().any(|n| n.ends_with("Mod.dll")));
1610    assert!(!names.contains(&"mods.yml".to_string()));
1611
1612    let mut manifest = String::new();
1613    std::io::Read::read_to_string(&mut zip.by_name("export.r2x").unwrap(), &mut manifest).unwrap();
1614
1615    let export = parse_export_r2x(&manifest).unwrap();
1616
1617    assert_eq!(export.profile_name, "MyProfile");
1618    assert_eq!(export.mods[0].name, "Author-A");
1619  }
1620
1621  #[test]
1622  fn export_to_file_uses_name_and_timestamp() {
1623    let dir = tempfile::tempdir().unwrap();
1624    let root = dir.path().join("profile");
1625    let out = dir.path().join("exports");
1626
1627    crate::profile::modlist::write(&root, &[profile_mod("Author-A", true)]).unwrap();
1628
1629    let path = export_to_file_in(&root, "MyProfile", &out, 1_700_000_000_000).unwrap();
1630
1631    assert_eq!(
1632      path.file_name().unwrap().to_string_lossy(),
1633      "MyProfile_1700000000000.r2z"
1634    );
1635    assert!(path.exists());
1636  }
1637
1638  #[test]
1639  fn export_to_file_rejects_unsafe_profile_name() {
1640    let dir = tempfile::tempdir().unwrap();
1641    let root = dir.path().join("profile");
1642    let out = dir.path().join("exports");
1643
1644    crate::profile::modlist::write(&root, &[profile_mod("Author-A", true)]).unwrap();
1645
1646    assert!(export_to_file_in(&root, "../evil", &out, 1).is_err());
1647    assert!(!dir.path().join("evil_1.r2z").exists());
1648  }
1649
1650  #[test]
1651  fn encode_profile_payload_prefixes_and_base64s() {
1652    let payload = encode_profile_payload(b"ABC").unwrap();
1653
1654    assert_eq!(payload, b"#r2modman\nQUJD");
1655  }
1656
1657  #[test]
1658  fn encode_profile_payload_rejects_oversize() {
1659    let big = vec![0u8; MAX_EXPORT_CODE_BYTES + 1];
1660
1661    assert!(matches!(
1662      encode_profile_payload(&big),
1663      Err(Error::SizeLimitExceeded { .. })
1664    ));
1665  }
1666
1667  #[test]
1668  fn decode_profile_payload_round_trips_and_rejects_bad_prefix() {
1669    let payload = encode_profile_payload(b"ABC").unwrap();
1670
1671    assert_eq!(decode_profile_payload(&payload).unwrap(), b"ABC");
1672    assert!(decode_profile_payload(b"nope\nQUJD").is_err());
1673  }
1674
1675  #[test]
1676  fn import_config_key_remaps_config_and_blocks_unsafe() {
1677    // `config/` is re-rooted back under BepInEx/.
1678    assert_eq!(
1679      import_config_key("config/BepInEx.cfg"),
1680      Some(PathBuf::from("BepInEx/config/BepInEx.cfg"))
1681    );
1682    // Other entries keep their path.
1683    assert_eq!(
1684      import_config_key("BepInEx/plugins/Author-A/settings.cfg"),
1685      Some(PathBuf::from("BepInEx/plugins/Author-A/settings.cfg"))
1686    );
1687    // Manifest and manager state are skipped.
1688    assert_eq!(import_config_key("export.r2x"), None);
1689    assert_eq!(import_config_key("mods.yml"), None);
1690    // Executables are refused even inside an otherwise valid path.
1691    assert_eq!(import_config_key("BepInEx/plugins/evil.dll"), None);
1692    // Traversal is refused.
1693    assert_eq!(import_config_key("../escape.cfg"), None);
1694  }
1695
1696  #[test]
1697  fn import_config_key_blocks_trailing_dot_and_space_bypass() {
1698    // Windows strips trailing dots/spaces from the final path component on
1699    // create, so these must be blocked even though they don't literally end
1700    // with a blocked extension.
1701    let blocked = [
1702      "evil.dll.",
1703      "evil.dll ",
1704      "evil.dll. . ",
1705      "evil.DLL",
1706      "evil.cfg.dll",
1707    ];
1708
1709    for name in blocked {
1710      assert_eq!(
1711        import_config_key(name),
1712        None,
1713        "{name} should have been blocked"
1714      );
1715    }
1716
1717    // An ordinary config entry is unaffected.
1718    assert_eq!(import_config_key("a.cfg"), Some(PathBuf::from("a.cfg")));
1719  }
1720
1721  #[test]
1722  fn read_export_accepts_r2z_and_bare_r2x() {
1723    let dir = tempfile::tempdir().unwrap();
1724    let root = dir.path();
1725
1726    crate::profile::modlist::write(root, &[profile_mod("Author-A", true)]).unwrap();
1727
1728    let zip_bytes = build_r2z_in(root, "MyProfile").unwrap();
1729
1730    assert_eq!(read_export(&zip_bytes).unwrap().profile_name, "MyProfile");
1731
1732    let yaml = export_r2x_to_string(&export_format_from_mods("Bare", &[])).unwrap();
1733
1734    assert_eq!(read_export(yaml.as_bytes()).unwrap().profile_name, "Bare");
1735  }
1736
1737  #[test]
1738  fn extract_configs_writes_remapped_files_only() {
1739    let dir = tempfile::tempdir().unwrap();
1740    let source = dir.path().join("source");
1741    let target = dir.path().join("target");
1742
1743    crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
1744    write_test_file(&source.join("BepInEx/config/BepInEx.cfg"), b"shared");
1745    write_test_file(
1746      &source.join("BepInEx/plugins/Author-A/settings.cfg"),
1747      b"mine",
1748    );
1749
1750    let zip_bytes = build_r2z_in(&source, "MyProfile").unwrap();
1751    let written = extract_configs_in(&target, &zip_bytes).unwrap();
1752
1753    assert!(target.join("BepInEx/config/BepInEx.cfg").exists());
1754    assert!(
1755      target
1756        .join("BepInEx/plugins/Author-A/settings.cfg")
1757        .exists()
1758    );
1759    // The manifest itself is never written into the target.
1760    assert!(!target.join("export.r2x").exists());
1761    assert_eq!(written.len(), 2);
1762  }
1763
1764  #[test]
1765  fn extract_configs_treats_non_zip_input_as_no_configs() {
1766    let dir = tempfile::tempdir().unwrap();
1767    let target = dir.path().join("target");
1768
1769    // A bare `.r2x` manifest — no zip container at all.
1770    let yaml = export_r2x_to_string(&export_format_from_mods("Bare", &[])).unwrap();
1771
1772    let written = extract_configs_in(&target, yaml.as_bytes()).unwrap();
1773
1774    assert!(written.is_empty());
1775  }
1776
1777  #[test]
1778  fn extract_configs_still_rejects_a_genuinely_corrupt_zip() {
1779    let dir = tempfile::tempdir().unwrap();
1780    let target = dir.path().join("target");
1781
1782    // Looks like a zip (starts with the `PK` magic) but is truncated garbage.
1783    let corrupt = b"PK\x03\x04not a real zip".to_vec();
1784
1785    assert!(extract_configs_in(&target, &corrupt).is_err());
1786  }
1787
1788  #[test]
1789  #[cfg(unix)]
1790  fn extract_configs_refuses_to_write_through_a_symlink() {
1791    use std::os::unix::fs::symlink;
1792
1793    let outside = tempfile::tempdir().unwrap();
1794    let secret = outside.path().join("secret.cfg");
1795    std::fs::write(&secret, b"original").unwrap();
1796
1797    let source = tempfile::tempdir().unwrap();
1798
1799    crate::profile::modlist::write(source.path(), &[profile_mod("Author-A", true)]).unwrap();
1800    write_test_file(
1801      &source.path().join("BepInEx/config/BepInEx.cfg"),
1802      b"new-content",
1803    );
1804
1805    let zip_bytes = build_r2z_in(source.path(), "MyProfile").unwrap();
1806
1807    // Plant a symlink at the destination the archive's `config/BepInEx.cfg`
1808    // entry remaps to, pointing outside the target.
1809    let target = tempfile::tempdir().unwrap();
1810    let dest = target.path().join("BepInEx/config/BepInEx.cfg");
1811
1812    std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1813    symlink(&secret, &dest).unwrap();
1814
1815    let err = extract_configs_in(target.path(), &zip_bytes).unwrap_err();
1816
1817    assert!(matches!(err, Error::Profile(_)));
1818    // The write is refused and the symlink target outside the target dir is untouched.
1819    assert_eq!(std::fs::read_to_string(&secret).unwrap(), "original");
1820  }
1821
1822  /// Builds an in-memory zip with a single highly-compressible entry, so the
1823  /// archive itself stays tiny while its inflated content can dwarf the caps.
1824  fn zip_with_one_zero_filled_entry(name: &str, inflated_len: usize) -> Vec<u8> {
1825    let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
1826    let options: FileOptions<'_, ()> =
1827      FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
1828
1829    zip.start_file(name, options).unwrap();
1830    zip.write_all(&vec![0u8; inflated_len]).unwrap();
1831
1832    zip.finish().unwrap().into_inner()
1833  }
1834
1835  #[test]
1836  fn extract_configs_rejects_entry_exceeding_the_per_entry_cap() {
1837    let bytes = zip_with_one_zero_filled_entry(
1838      "BepInEx/config/Big.cfg",
1839      (MAX_IMPORT_ENTRY_BYTES + 1) as usize,
1840    );
1841
1842    let dir = tempfile::tempdir().unwrap();
1843    let err = extract_configs_in(dir.path(), &bytes).unwrap_err();
1844
1845    assert!(matches!(err, Error::ExtractLimitExceeded(_)));
1846  }
1847
1848  #[test]
1849  fn extract_configs_rejects_import_exceeding_the_cumulative_cap() {
1850    // Each entry sits just under the per-entry cap individually, but enough of
1851    // them together must exceed the cumulative cap.
1852    let per_entry_len = (MAX_IMPORT_ENTRY_BYTES - 1024) as usize;
1853    let entries_needed = (MAX_IMPORT_TOTAL_BYTES / per_entry_len as u64) + 1;
1854
1855    let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
1856    let options: FileOptions<'_, ()> =
1857      FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
1858    let chunk = vec![0u8; per_entry_len];
1859
1860    for i in 0..entries_needed {
1861      zip
1862        .start_file(format!("BepInEx/config/{i}.cfg"), options)
1863        .unwrap();
1864      zip.write_all(&chunk).unwrap();
1865    }
1866
1867    let bytes = zip.finish().unwrap().into_inner();
1868
1869    let dir = tempfile::tempdir().unwrap();
1870    let err = extract_configs_in(dir.path(), &bytes).unwrap_err();
1871
1872    assert!(matches!(err, Error::ExtractLimitExceeded(_)));
1873  }
1874
1875  #[test]
1876  fn import_r2modman_dir_adopts_an_existing_profile() {
1877    let dir = tempfile::tempdir().unwrap();
1878    let source = dir.path().join("source");
1879    let target = dir.path().join("target");
1880
1881    crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
1882    write_test_file(&source.join("BepInEx/plugins/Author-A/Mod.dll"), b"dll");
1883
1884    import_r2modman_dir(&source, &target).unwrap();
1885
1886    assert!(target.join("BepInEx/plugins/Author-A/Mod.dll").exists());
1887    assert_eq!(crate::profile::modlist::read(&target).unwrap().len(), 1);
1888    assert!(import_r2modman_dir(&dir.path().join("missing"), &target).is_err());
1889  }
1890
1891  #[test]
1892  #[cfg(unix)]
1893  fn import_r2modman_dir_refuses_to_copy_through_a_symlink() {
1894    use std::os::unix::fs::symlink;
1895
1896    let outside = tempfile::tempdir().unwrap();
1897    let secret = outside.path().join("secret.cfg");
1898    std::fs::write(&secret, b"original").unwrap();
1899
1900    let dir = tempfile::tempdir().unwrap();
1901    let source = dir.path().join("source");
1902    let target = dir.path().join("target");
1903
1904    crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
1905    write_test_file(
1906      &source.join("BepInEx/plugins/Author-A/settings.cfg"),
1907      b"new-content",
1908    );
1909
1910    // Plant a symlink at the destination the source's config file would copy to.
1911    let dest = target.join("BepInEx/plugins/Author-A/settings.cfg");
1912
1913    std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1914    symlink(&secret, &dest).unwrap();
1915
1916    let err = import_r2modman_dir(&source, &target).unwrap_err();
1917
1918    assert!(matches!(err, Error::Profile(_)));
1919    // The copy is refused and the symlink target outside the target dir is untouched.
1920    assert_eq!(std::fs::read_to_string(&secret).unwrap(), "original");
1921  }
1922
1923  #[test]
1924  fn copy_tree_skips_a_same_file_copy_instead_of_truncating_it() {
1925    let dir = tempfile::tempdir().unwrap();
1926    let root = dir.path();
1927
1928    write_test_file(&root.join("sub/file.txt"), b"original bytes");
1929
1930    // `ensure_disjoint_trees` refuses an overlapping source and target on
1931    // every public path (see `importing_a_directory_into_itself_is_refused_before_anything_is_truncated`
1932    // in the integration suite), so `copy_tree`'s own `same_file` skip is
1933    // otherwise never reached there. Calling it directly, the way a future
1934    // caller reaching `copy_tree` another way might, is the only way to
1935    // exercise this second line of defence.
1936    copy_tree(root, root).unwrap();
1937
1938    let bytes = std::fs::read(root.join("sub/file.txt")).unwrap();
1939
1940    assert_eq!(
1941      bytes, b"original bytes",
1942      "a same-file copy must be skipped, not truncate the file to zero bytes"
1943    );
1944  }
1945}