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