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 at its exported version.** An exported version
13//! Thunderstore no longer serves fails that mod.
14
15#![deny(missing_docs)]
16
17use crate::client::ThunderstoreClient;
18use crate::error::{Error, Result};
19use crate::profile::modlist::{ProfileMod, SemVer};
20use crate::util::{canonicalize_missing, same_file};
21use base64::Engine as _;
22use base64::engine::general_purpose::STANDARD;
23use serde::{Deserialize, Serialize};
24use std::fs;
25use std::io::{Cursor, Read, Write};
26use std::path::{Component, Path, PathBuf};
27use zip::write::{FileOptions, ZipWriter};
28
29/// One entry of an `export.r2x` mod list.
30///
31/// `#[non_exhaustive]`: fields may be added as the format grows.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[non_exhaustive]
34pub struct ExportMod {
35  /// The `Owner-Name` identifier.
36  pub name: String,
37  /// The version. Note: `version`, not `versionNumber` as in `mods.yml`.
38  pub version: SemVer,
39  /// Whether the mod was enabled. Absent upstream means enabled.
40  #[serde(default = "default_true")]
41  pub enabled: bool,
42}
43
44/// serde default for [`ExportMod::enabled`]: r2modman treats an absent `enabled`
45/// as `true`.
46fn default_true() -> bool {
47  true
48}
49
50/// The `export.r2x` manifest inside a `.r2z`.
51///
52/// `#[non_exhaustive]`: fields may be added as the format grows.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55#[non_exhaustive]
56pub struct ExportFormat {
57  /// The exported profile's display name.
58  pub profile_name: String,
59  /// The exported mod list.
60  pub mods: Vec<ExportMod>,
61}
62
63/// Builds an [`ExportFormat`] from a target's `mods.yml` entries.
64pub fn export_format_from_mods(profile_name: &str, mods: &[ProfileMod]) -> ExportFormat {
65  let mods = mods
66    .iter()
67    .map(|entry| ExportMod {
68      name: entry.name.clone(),
69      version: entry.version_number,
70      enabled: entry.enabled,
71    })
72    .collect();
73
74  ExportFormat {
75    profile_name: profile_name.to_string(),
76    mods,
77  }
78}
79
80/// Serializes an [`ExportFormat`] to `export.r2x` YAML.
81pub fn export_r2x_to_string(export: &ExportFormat) -> Result<String> {
82  serde_yaml_ng::to_string(export)
83    .map_err(|e| Error::Profile(format!("serializing export.r2x: {e}")))
84}
85
86/// Parses `export.r2x` YAML.
87pub fn parse_export_r2x(yaml: &str) -> Result<ExportFormat> {
88  serde_yaml_ng::from_str(yaml).map_err(|e| Error::Profile(format!("parsing export.r2x: {e}")))
89}
90
91/// Filename suffixes r2modman treats as config files worth exporting.
92const CONFIG_EXTENSIONS: [&str; 6] = [".cfg", ".txt", ".json", ".yml", ".yaml", ".ini"];
93
94/// Whether a target-relative path is excluded from an export.
95///
96/// Mirrors r2modman's export prune list: manager state (`_state/`, `mods.yml`),
97/// the runtime it never ships (`dotnet/`, `MelonLoader/`, `GDWeave/core|mods`),
98/// per-mod package metadata, and `BepInEx/config/` (added separately under the
99/// zip's `config/` folder). Pure — no filesystem access.
100fn is_pruned(rel: &Path) -> bool {
101  let normalized = rel.to_string_lossy().replace('\\', "/");
102
103  let prefixes = [
104    "dotnet/",
105    "_state/",
106    "MelonLoader/",
107    "BepInEx/config/",
108    "GDWeave/core/",
109    "GDWeave/mods/",
110  ];
111
112  if prefixes.iter().any(|p| normalized.starts_with(p)) {
113    return true;
114  }
115
116  if normalized == "mods.yml" || normalized == "GDWeave/GDWeave.log" {
117    return true;
118  }
119
120  is_plugin_manifest(&normalized)
121}
122
123/// Whether a path is a per-mod `BepInEx/plugins/<Owner-Name>/manifest.json`,
124/// which r2modman strips from an export.
125fn is_plugin_manifest(normalized: &str) -> bool {
126  let parts: Vec<&str> = normalized.split('/').collect();
127
128  parts.len() == 4
129    && parts[0] == "BepInEx"
130    && parts[1] == "plugins"
131    && parts[3].eq_ignore_ascii_case("manifest.json")
132}
133
134/// Whether a filename ends with one of [`CONFIG_EXTENSIONS`] (case-insensitive).
135/// Pure — no filesystem access.
136fn is_config_file(rel: &Path) -> bool {
137  let name = match rel.file_name().and_then(|n| n.to_str()) {
138    Some(name) => name.to_ascii_lowercase(),
139    None => return false,
140  };
141
142  CONFIG_EXTENSIONS.iter().any(|ext| name.ends_with(ext))
143}
144
145/// Collects the target-relative paths an export should carry: config files that
146/// survive the prune list, sorted for a deterministic archive.
147fn collect_export_paths(target_dir: &Path) -> Result<Vec<PathBuf>> {
148  let mut found = Vec::new();
149
150  collect_files(target_dir, target_dir, &mut found)?;
151
152  let mut kept: Vec<PathBuf> = found
153    .into_iter()
154    .filter(|rel| is_config_file(rel) && !is_pruned(rel))
155    .collect();
156
157  kept.sort();
158
159  Ok(kept)
160}
161
162/// Recursively collects file paths under `dir`, relative to `root`, skipping
163/// symlinks so an export cannot escape the target (mirrors `install::walk_files`).
164fn collect_files(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
165  if !dir.is_dir() {
166    return Ok(());
167  }
168
169  for entry in
170    fs::read_dir(dir).map_err(|e| Error::Profile(format!("reading {}: {}", dir.display(), e)))?
171  {
172    let entry = entry.map_err(|e| Error::Profile(format!("reading an entry: {e}")))?;
173    let path = entry.path();
174    let metadata = fs::symlink_metadata(&path)
175      .map_err(|e| Error::Profile(format!("inspecting {}: {}", path.display(), e)))?;
176
177    if metadata.file_type().is_symlink() {
178      continue;
179    }
180
181    if metadata.is_dir() {
182      collect_files(root, &path, out)?;
183    } else if let Ok(rel) = path.strip_prefix(root) {
184      out.push(rel.to_path_buf());
185    }
186  }
187
188  Ok(())
189}
190
191/// The manifest filename inside a `.r2z`.
192const EXPORT_MANIFEST: &str = "export.r2x";
193
194/// The archive folder that a target's `BepInEx/config/` tree is re-rooted into.
195const ARCHIVE_CONFIG_DIR: &str = "config";
196
197/// The target-relative path of the shared BepInEx config tree.
198const BEPINEX_CONFIG_DIR: &str = "BepInEx/config";
199
200/// Builds the `.r2z` bytes for `target_dir`.
201///
202/// The archive carries `EXPORT_MANIFEST` (the mod list from `mods.yml`), the
203/// target's `BepInEx/config/` tree re-rooted under `config/`, and every other
204/// surviving config file at its target-relative path. Binaries and manager state
205/// are never exported (see `is_pruned`).
206///
207/// `profile_name` is recorded in the manifest as the exported profile's name and
208/// need not match any directory on disk.
209///
210/// # Errors
211///
212/// - [`Error::Profile`] if `target_dir` has no readable
213///   `mods.yml`, or the mod list cannot be serialized.
214/// - [`Error::Io`] if a config file cannot be read.
215/// - [`Error::Zip`] if the archive cannot be written.
216///
217/// # Examples
218///
219/// ```no_run
220/// use std::path::Path;
221/// use thunderstore_engine::profile::portability::build_r2z_in;
222///
223/// let bytes = build_r2z_in(Path::new("/srv/valheim"), "my-profile")?;
224///
225/// std::fs::write("my-profile.r2z", &bytes)?;
226/// # Ok::<(), thunderstore_engine::Error>(())
227/// ```
228pub fn build_r2z_in(target_dir: &Path, profile_name: &str) -> Result<Vec<u8>> {
229  let mods = crate::profile::modlist::read(target_dir)?;
230  let manifest = export_r2x_to_string(&export_format_from_mods(profile_name, &mods))?;
231
232  let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
233  let options: FileOptions<'_, ()> =
234    FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
235
236  zip
237    .start_file(EXPORT_MANIFEST, options)
238    .map_err(|e| Error::Profile(format!("writing {EXPORT_MANIFEST}: {e}")))?;
239  zip
240    .write_all(manifest.as_bytes())
241    .map_err(|e| Error::Profile(format!("writing {EXPORT_MANIFEST}: {e}")))?;
242
243  let config_root = target_dir.join(BEPINEX_CONFIG_DIR);
244  let mut shared = Vec::new();
245
246  collect_files(&config_root, &config_root, &mut shared)?;
247  shared.sort();
248
249  for rel in shared {
250    let archive_path = format!(
251      "{ARCHIVE_CONFIG_DIR}/{}",
252      rel.to_string_lossy().replace('\\', "/")
253    );
254
255    add_zip_file(&mut zip, options, &archive_path, &config_root.join(&rel))?;
256  }
257
258  for rel in collect_export_paths(target_dir)? {
259    let archive_path = rel.to_string_lossy().replace('\\', "/");
260
261    add_zip_file(&mut zip, options, &archive_path, &target_dir.join(&rel))?;
262  }
263
264  let cursor = zip
265    .finish()
266    .map_err(|e| Error::Profile(format!("finishing export archive: {e}")))?;
267
268  Ok(cursor.into_inner())
269}
270
271/// Adds one file to the archive under `archive_path`.
272fn add_zip_file(
273  zip: &mut ZipWriter<Cursor<Vec<u8>>>,
274  options: FileOptions<'_, ()>,
275  archive_path: &str,
276  source: &Path,
277) -> Result<()> {
278  let bytes =
279    fs::read(source).map_err(|e| Error::Profile(format!("reading {}: {}", source.display(), e)))?;
280
281  zip
282    .start_file(archive_path, options)
283    .map_err(|e| Error::Profile(format!("writing {archive_path}: {e}")))?;
284  zip
285    .write_all(&bytes)
286    .map_err(|e| Error::Profile(format!("writing {archive_path}: {e}")))?;
287
288  Ok(())
289}
290
291/// Writes `target_dir`'s export to `<out_dir>/<profile_name>_<timestamp_millis>.r2z`
292/// and returns the path. `timestamp_millis` is supplied by the caller so the
293/// filename is deterministic and testable. `profile_name` is validated (see
294/// [`crate::profile::layout::validate_name`]) because it becomes part of the
295/// output filename; an unchecked name could otherwise write outside `out_dir`.
296pub fn export_to_file_in(
297  target_dir: &Path,
298  profile_name: &str,
299  out_dir: &Path,
300  timestamp_millis: u64,
301) -> Result<PathBuf> {
302  crate::profile::layout::validate_name(profile_name)?;
303
304  let bytes = build_r2z_in(target_dir, profile_name)?;
305
306  fs::create_dir_all(out_dir)
307    .map_err(|e| Error::Profile(format!("creating {}: {}", out_dir.display(), e)))?;
308
309  let path = out_dir.join(format!("{profile_name}_{timestamp_millis}.r2z"));
310
311  fs::write(&path, bytes)
312    .map_err(|e| Error::Profile(format!("writing {}: {}", path.display(), e)))?;
313
314  Ok(path)
315}
316
317/// Exports an r2modman-layout profile into `<base>/<game>/exports/`.
318/// See [`export_to_file_in`].
319pub fn export_to_file(
320  base: &Path,
321  game: &str,
322  profile_name: &str,
323  timestamp_millis: u64,
324) -> Result<PathBuf> {
325  export_to_file_in(
326    &crate::profile::layout::profile_dir(base, game, profile_name),
327    profile_name,
328    &base.join(game).join("exports"),
329    timestamp_millis,
330  )
331}
332
333/// Upstream ceiling on a shared profile's zip, before base64.
334const MAX_EXPORT_CODE_BYTES: usize = 20_000_000;
335
336/// The literal prefix Thunderstore's profile-code payload carries.
337const PROFILE_DATA_PREFIX: &str = "#r2modman";
338
339/// Wraps `.r2z` bytes in the profile-code payload: the `#r2modman` prefix, a
340/// newline, then base64 of the zip. Rejects a zip above the upstream 20 MB cap
341/// before any upload is attempted.
342fn encode_profile_payload(zip_bytes: &[u8]) -> Result<Vec<u8>> {
343  if zip_bytes.len() > MAX_EXPORT_CODE_BYTES {
344    return Err(Error::SizeLimitExceeded {
345      context: "profile export".to_string(),
346      limit: MAX_EXPORT_CODE_BYTES as u64,
347      actual: Some(zip_bytes.len() as u64),
348    });
349  }
350
351  let mut payload = format!("{PROFILE_DATA_PREFIX}\n").into_bytes();
352
353  payload.extend_from_slice(STANDARD.encode(zip_bytes).as_bytes());
354
355  Ok(payload)
356}
357
358/// Exports `target_dir` as a shareable Thunderstore profile code.
359///
360/// `profile_name` is not validated here: unlike [`export_to_file_in`], it never
361/// becomes part of a filesystem path — it only rides inside the `export.r2x`
362/// manifest carried by the uploaded payload.
363pub async fn export_code_in(
364  target_dir: &Path,
365  profile_name: &str,
366  client: &ThunderstoreClient,
367) -> Result<String> {
368  let payload = encode_profile_payload(&build_r2z_in(target_dir, profile_name)?)?;
369
370  client.create_profile_code(&payload).await
371}
372
373/// Exports an r2modman-layout profile as a shareable code. See [`export_code_in`].
374pub async fn export_code(
375  base: &Path,
376  game: &str,
377  profile_name: &str,
378  client: &ThunderstoreClient,
379) -> Result<String> {
380  export_code_in(
381    &crate::profile::layout::profile_dir(base, game, profile_name),
382    profile_name,
383    client,
384  )
385  .await
386}
387
388/// Extensions never written by an import: a shared profile carries config, not
389/// executable code, so a hostile `.r2z` cannot plant a binary.
390const BLOCKED_IMPORT_EXTENSIONS: [&str; 12] = [
391  ".dll", ".exe", ".bat", ".cmd", ".com", ".ps1", ".sh", ".js", ".vbs", ".msi", ".scr", ".jar",
392];
393
394/// Unwraps a profile-code response body into `.r2z` bytes: validates the
395/// `#r2modman` prefix, then base64-decodes the remainder.
396fn decode_profile_payload(body: &[u8]) -> Result<Vec<u8>> {
397  let text = std::str::from_utf8(body)
398    .map_err(|e| Error::Profile(format!("profile code is not valid UTF-8: {e}")))?;
399
400  let encoded = text
401    .strip_prefix(PROFILE_DATA_PREFIX)
402    .ok_or_else(|| Error::Profile("profile code is missing its #r2modman prefix".to_string()))?;
403
404  STANDARD
405    .decode(encoded.trim())
406    .map_err(|e| Error::Profile(format!("decoding profile code: {e}")))
407}
408
409/// Ceiling on a single inflated archive entry, and on the cumulative inflated
410/// size of an import. A shared profile carries text config, so this sits far
411/// above any legitimate export while rejecting a decompression bomb (DEFLATE
412/// can reach ~1032:1, so a tiny `.r2z` can otherwise inflate to gigabytes).
413const MAX_IMPORT_ENTRY_BYTES: u64 = 16 * 1024 * 1024;
414
415/// See `MAX_IMPORT_ENTRY_BYTES`.
416const MAX_IMPORT_TOTAL_BYTES: u64 = 64 * 1024 * 1024;
417
418/// Reads one archive entry fully into memory, capped at `MAX_IMPORT_ENTRY_BYTES`.
419///
420/// The cap is checked against the bytes actually streamed out of the
421/// decompressor, not any size the archive declares, so a crafted high-ratio
422/// entry cannot forge its way past it. Returns [`Error::ExtractLimitExceeded`]
423/// on breach — the same variant [`crate::extract`] uses for this class of guard.
424fn read_capped_entry(entry: &mut impl Read, label: &str) -> Result<Vec<u8>> {
425  let mut bytes = Vec::new();
426
427  entry
428    .take(MAX_IMPORT_ENTRY_BYTES + 1)
429    .read_to_end(&mut bytes)
430    .map_err(|e| Error::Profile(format!("reading {label}: {e}")))?;
431
432  if bytes.len() as u64 > MAX_IMPORT_ENTRY_BYTES {
433    return Err(Error::ExtractLimitExceeded(format!(
434      "{label} exceeds the {MAX_IMPORT_ENTRY_BYTES}-byte per-entry import cap"
435    )));
436  }
437
438  Ok(bytes)
439}
440
441/// Reads an export manifest from either a `.r2z` archive or bare `.r2x` YAML.
442pub fn read_export(bytes: &[u8]) -> Result<ExportFormat> {
443  match zip::ZipArchive::new(Cursor::new(bytes.to_vec())) {
444    Ok(mut archive) => {
445      let mut entry = archive
446        .by_name(EXPORT_MANIFEST)
447        .map_err(|e| Error::Profile(format!("archive has no {EXPORT_MANIFEST}: {e}")))?;
448
449      let manifest_bytes = read_capped_entry(&mut entry, EXPORT_MANIFEST)?;
450      let manifest = std::str::from_utf8(&manifest_bytes)
451        .map_err(|e| Error::Profile(format!("{EXPORT_MANIFEST} is not valid UTF-8: {e}")))?;
452
453      parse_export_r2x(manifest)
454    }
455    Err(_) => {
456      let text = std::str::from_utf8(bytes)
457        .map_err(|e| Error::Profile(format!("export is neither a zip nor UTF-8 YAML: {e}")))?;
458
459      parse_export_r2x(text)
460    }
461  }
462}
463
464/// Maps an archive entry name to its target-relative destination, or `None` when
465/// the entry must not be written.
466///
467/// The manifest and `mods.yml` are skipped (the mod list is applied by installing,
468/// not by copying), a `config/…` entry is re-rooted back under `BepInEx/`, and any
469/// executable extension or unsafe path is refused. Pure — no filesystem access.
470///
471/// The blocklist check is matched against the name with trailing dots and
472/// spaces stripped: Windows silently drops those from the final path component
473/// on create, so an entry named e.g. `Evil.dll.` would otherwise land on disk as
474/// `Evil.dll` and bypass the filter on the platform that matters most here.
475fn import_config_key(name: &str) -> Option<PathBuf> {
476  let normalized = name.replace('\\', "/");
477
478  if normalized.is_empty() || normalized.ends_with('/') {
479    return None;
480  }
481
482  if normalized == EXPORT_MANIFEST || normalized == "mods.yml" {
483    return None;
484  }
485
486  let lower = normalized.to_ascii_lowercase();
487  let probe = lower.trim_end_matches(['.', ' ']);
488
489  if BLOCKED_IMPORT_EXTENSIONS
490    .iter()
491    .any(|ext| probe.ends_with(ext))
492  {
493    return None;
494  }
495
496  let remapped = match normalized.strip_prefix("config/") {
497    Some(rest) => format!("{BEPINEX_CONFIG_DIR}/{rest}"),
498    None => normalized,
499  };
500
501  let path = PathBuf::from(remapped);
502
503  for component in path.components() {
504    match component {
505      Component::Normal(_) | Component::CurDir => {}
506      Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
507    }
508  }
509
510  Some(path)
511}
512
513/// Whether `bytes` open with a zip archive's `PK` magic number, rather than
514/// being a bare `.r2x` YAML manifest (see [`read_export`]).
515///
516/// A zip's first local-file header, or the end-of-central-directory record of
517/// an empty zip, always starts with the two bytes `PK`; a YAML manifest never
518/// does. This distinguishes "not a zip at all" from "a zip that failed to
519/// parse" — the latter is a real corruption and must still surface as an
520/// error rather than be swallowed as "no configs".
521fn looks_like_zip(bytes: &[u8]) -> bool {
522  bytes.len() >= 2 && &bytes[0..2] == b"PK"
523}
524
525/// Refuses to write through a pre-existing symlink at `dest`.
526///
527/// Mirrors [`crate::install::apply_install`]'s `RefuseSymlink` handling: a
528/// symlink already sitting at the destination is refused rather than
529/// followed, so a pre-planted leaf link cannot redirect the write outside the
530/// target. This is best-effort: it inspects the final path component only
531/// (not symlinked parent directories) and is not race-free against a
532/// concurrent attacker mutating the target mid-import.
533fn refuse_symlink_dest(dest: &Path) -> Result<()> {
534  let is_symlink = fs::symlink_metadata(dest)
535    .map(|metadata| metadata.file_type().is_symlink())
536    .unwrap_or(false);
537
538  if is_symlink {
539    return Err(Error::Profile(format!(
540      "refusing to write through a symlink at {}",
541      dest.display()
542    )));
543  }
544
545  Ok(())
546}
547
548/// Extracts an export's config files into `target_dir`, returning the
549/// target-relative paths written. Entries `import_config_key` rejects are
550/// skipped.
551///
552/// A bare `.r2x` manifest (see [`read_export`]) carries no config files, so
553/// `zip_bytes` that don't even look like a zip (see `looks_like_zip`) are
554/// treated as "nothing to extract" rather than an error; a payload that does
555/// look like a zip but fails to parse is a genuine corruption and still
556/// surfaces as an error.
557///
558/// A pre-existing symlink at an entry's destination is refused rather than
559/// followed (see `refuse_symlink_dest`), so a planted leaf link cannot
560/// redirect a write outside `target_dir`.
561///
562/// Each entry is bounded by `MAX_IMPORT_ENTRY_BYTES`, and the running total
563/// across the whole import by `MAX_IMPORT_TOTAL_BYTES`, so a decompression
564/// bomb hiding in an otherwise-valid config entry cannot exhaust memory.
565pub fn extract_configs_in(target_dir: &Path, zip_bytes: &[u8]) -> Result<Vec<PathBuf>> {
566  if !looks_like_zip(zip_bytes) {
567    return Ok(Vec::new());
568  }
569
570  let mut archive = zip::ZipArchive::new(Cursor::new(zip_bytes.to_vec()))
571    .map_err(|e| Error::Profile(format!("reading export archive: {e}")))?;
572
573  let mut written = Vec::new();
574  let mut total_written: u64 = 0;
575
576  for index in 0..archive.len() {
577    let mut entry = archive
578      .by_index(index)
579      .map_err(|e| Error::Profile(format!("reading archive entry {index}: {e}")))?;
580
581    let rel = match import_config_key(entry.name()) {
582      Some(rel) => rel,
583      None => continue,
584    };
585
586    let bytes = read_capped_entry(&mut entry, &rel.display().to_string())?;
587
588    total_written += bytes.len() as u64;
589
590    if total_written > MAX_IMPORT_TOTAL_BYTES {
591      return Err(Error::ExtractLimitExceeded(format!(
592        "import exceeds the {MAX_IMPORT_TOTAL_BYTES}-byte cumulative import cap"
593      )));
594    }
595
596    let dest = target_dir.join(&rel);
597
598    if let Some(parent) = dest.parent() {
599      fs::create_dir_all(parent)
600        .map_err(|e| Error::Profile(format!("creating {}: {}", parent.display(), e)))?;
601    }
602
603    refuse_symlink_dest(&dest)?;
604    fs::write(&dest, &bytes)
605      .map_err(|e| Error::Profile(format!("writing {}: {}", dest.display(), e)))?;
606
607    written.push(rel);
608  }
609
610  Ok(written)
611}
612
613/// Imports a `.r2z` (or a bare `.r2x` manifest, which carries no configs) into
614/// `target_dir`: installs every mod the manifest lists (each with its
615/// dependency closure) and restores the recorded `enabled` state, then
616/// extracts the archive's config files.
617///
618/// These three steps run in this order deliberately:
619///
620/// 1. **Install first, extract configs second.** Most configs live in the
621///    shared `BepInEx/config/`, which an install never removes. A few mods
622///    keep settings inside their own `BepInEx/plugins/<Owner-Name>/` folder
623///    instead, and installing (or updating) a mod deletes that folder and
624///    writes the package's files fresh, as r2modman does. Extracting configs
625///    before installing would lose those exported files the moment their
626///    owning mod is installed; extracting after ensures the exported config
627///    is what's left on disk. A mod skipped as already current is not
628///    touched.
629/// 2. **Disable pass runs after every install, inside the batch.** It still
630///    runs only once every mod in the batch is installed, which is what
631///    makes it immune to a dependency installed later re-enabling an entry
632///    the export marked disabled. It now happens inside `install_batch`
633///    rather than as a loop here, and running before config extraction is
634///    harmless because disabling renames a mod's files rather than
635///    rewriting its config.
636///
637/// **Installs the exported version, not the latest.** A mod or version the
638/// index no longer has fails the import.
639///
640/// `cache_base` is the package cache root and must not be `target_dir` — see
641/// [`crate::profile::install_mod_in`]. Returns the identifiers the import
642/// covers, dependencies included, split into those it installed and those it
643/// skipped as already current, so importing a profile the target already
644/// matches reports every mod as unchanged.
645#[allow(clippy::too_many_arguments)]
646pub async fn import_r2z_in(
647  target_dir: &Path,
648  cache_base: &Path,
649  eco: &crate::ecosystem::Ecosystem,
650  index: &crate::models::PackageIndex,
651  client: &ThunderstoreClient,
652  game: &str,
653  zip_bytes: &[u8],
654  installed_at_time: u64,
655) -> Result<ImportedMods> {
656  let export = read_export(zip_bytes)?;
657
658  let desired: Vec<String> = export
659    .mods
660    .iter()
661    .map(|entry| format!("{}-{}", entry.name, entry.version))
662    .collect();
663  let protect: Vec<String> = export
664    .mods
665    .iter()
666    .filter(|entry| !entry.enabled)
667    .map(|entry| entry.name.clone())
668    .collect();
669  let mut batch = crate::profile::InstallBatch::new(desired, protect);
670
671  // A mod already recorded at its exported version is skipped rather than
672  // reinstalled, so one the export has enabled but the target has disabled
673  // would otherwise stay disabled.
674  batch.enable_requested = export
675    .mods
676    .iter()
677    .filter(|entry| entry.enabled)
678    .map(|entry| entry.name.clone())
679    .collect();
680
681  let outcome = crate::profile::install_batch(
682    target_dir,
683    cache_base,
684    eco,
685    index,
686    client,
687    game,
688    &batch,
689    installed_at_time,
690  )
691  .await?;
692
693  // Configs are not extracted after a failure, so the import is unfinished.
694  // Importing again once the cause is fixed skips the mods already installed
695  // and completes it. A missing exported version is never fixed by a retry
696  // alone, so the message does not promise one will.
697  if let Some((name, error)) = outcome.failed.into_iter().next() {
698    return Err(Error::Profile(format!(
699      "importing {name}: {error}; the import is unfinished (its config files were \
700       not restored)"
701    )));
702  }
703
704  // Configs are extracted after every install, so an exported per-mod config
705  // wins over the package default the install just wrote.
706  extract_configs_in(target_dir, zip_bytes)?;
707
708  // `install_batch` already sorts, dedups, and keeps the two lists disjoint.
709  Ok(ImportedMods {
710    installed: outcome.succeeded,
711    unchanged: outcome.unchanged,
712  })
713}
714
715/// What [`import_r2z_in`] and [`import_code_in`] did with each mod the import
716/// covers.
717///
718/// Both lists include the dependency closure, are sorted and deduplicated, and
719/// never name the same identifier.
720#[derive(Debug, Clone, Default, PartialEq, Eq)]
721#[non_exhaustive]
722pub struct ImportedMods {
723  /// Identifiers this import installed or reinstalled.
724  pub installed: Vec<String>,
725  /// Identifiers skipped because the target already recorded the version that
726  /// would be installed, with its files in place.
727  ///
728  /// Skipped means not reinstalled, not untouched: the import still applies the
729  /// export's enabled state, so a mod listed here may have been enabled or
730  /// disabled to match it.
731  pub unchanged: Vec<String>,
732}
733
734/// Imports a shared profile by its Thunderstore code. See [`import_r2z_in`].
735#[allow(clippy::too_many_arguments)]
736pub async fn import_code_in(
737  target_dir: &Path,
738  cache_base: &Path,
739  eco: &crate::ecosystem::Ecosystem,
740  index: &crate::models::PackageIndex,
741  client: &ThunderstoreClient,
742  game: &str,
743  code: &str,
744  installed_at_time: u64,
745) -> Result<ImportedMods> {
746  let body = client.fetch_profile_code(code).await?;
747  let zip_bytes = decode_profile_payload(&body)?;
748
749  import_r2z_in(
750    target_dir,
751    cache_base,
752    eco,
753    index,
754    client,
755    game,
756    &zip_bytes,
757    installed_at_time,
758  )
759  .await
760}
761
762/// Copies an existing r2modman profile directory into `target_dir`, adopting its
763/// `mods.yml`, installed files, and config as-is.
764///
765/// This is a raw directory adoption, not an install: nothing is downloaded and no
766/// package cache is populated, so re-enabling a `State`-tracked mod afterwards
767/// requires that mod to be reinstalled first.
768///
769/// **The result may not be uninstallable.** r2modman never wrote this engine's
770/// `_state` tracker, so an adopted mod loader ends up with a `mods.yml` entry and
771/// no install record, which is exactly what
772/// [`ensure_removable`](crate::profile::ensure_removable) refuses and therefore
773/// what [`uninstall_mod_in`](crate::profile::uninstall_mod_in) refuses. Use
774/// [`adopt_r2modman_dir_in`] instead unless the offline raw copy is what you
775/// actually want.
776///
777/// # Errors
778///
779/// - [`Error::Profile`] if `source_profile_dir` is not a
780///   directory, or if it and `target_dir` are nested in one another (a copy
781///   would otherwise recurse into its own output; see
782///   [`ensure_disjoint_trees`]).
783/// - [`Error::Io`] if a file cannot be read or written.
784///
785/// Failure can leave `target_dir` partially populated: the copy is not
786/// transactional, so a caller that must not keep a half-imported profile should
787/// import into a scratch directory and move it into place on success.
788pub fn import_r2modman_dir(source_profile_dir: &Path, target_dir: &Path) -> Result<()> {
789  if !source_profile_dir.is_dir() {
790    return Err(Error::Profile(format!(
791      "{} is not a profile directory",
792      source_profile_dir.display()
793    )));
794  }
795
796  ensure_disjoint_trees(source_profile_dir, target_dir)?;
797
798  copy_tree(source_profile_dir, target_dir)
799}
800
801/// What [`adopt_r2modman_dir_in`] adopted, and what it had to reinstall to leave
802/// the profile coherent.
803#[derive(Debug, Clone, Default)]
804#[non_exhaustive]
805pub struct AdoptedProfile {
806  /// Every identifier the adopted `mods.yml` names, in the order it named them.
807  pub adopted: Vec<String>,
808  /// The subset reinstalled from Thunderstore so it would have an install
809  /// record, in the order they were reinstalled.
810  ///
811  /// Each came from the package's **latest** version, so a version the source
812  /// profile had pinned is not preserved. A caller that shows this to a user
813  /// should say so.
814  pub reinstalled: Vec<String>,
815}
816
817/// Copies an existing r2modman profile directory into `target_dir` and then
818/// reinstalls whatever the copy alone would have left un-uninstallable, so the
819/// result is a profile this engine can manage.
820///
821/// [`import_r2modman_dir`] on its own cannot produce one: r2modman never wrote
822/// this engine's `_state` tracker, so an adopted mod loader gets a `mods.yml`
823/// entry and no install record, and
824/// [`ensure_removable`](crate::profile::ensure_removable) then refuses to remove
825/// it. This reinstalls each such loader from its package, which is the *only* way
826/// to get an exact file list for one.
827///
828/// Reconstructing the tracker from the copied files instead is deliberately not
829/// attempted. A loader installs into `BepInEx/core`, which is itself a declared
830/// route, so a loader file sitting there is shape-indistinguishable from one a
831/// user dropped in by hand; attributing files by shape would let a later sweep
832/// delete a file the loader never placed.
833///
834/// **Reinstalls the latest version, not the adopted one.** Resolution goes
835/// through [`crate::models::DependencyGraph`] with a bare `Owner-Name`, so a
836/// version the source profile had pinned is not preserved. The returned
837/// [`AdoptedProfile::reinstalled`] names exactly which packages this happened to,
838/// so a caller can disclose it.
839///
840/// Takes a `client` rather than a prefetched
841/// [`PackageIndex`](crate::models::PackageIndex), unlike [`import_r2z_in`]: the
842/// index is fetched only once something actually needs reinstalling, so adopting
843/// a profile that names no unrecorded loader stays fully offline. Requiring the
844/// caller to pass an index would cost them a network round trip to find out they
845/// did not need one.
846///
847/// `cache_base` is the package cache root and must not be `target_dir`; see
848/// [`crate::profile::install_mod_in`].
849#[allow(clippy::too_many_arguments)]
850pub async fn adopt_r2modman_dir_in(
851  source_profile_dir: &Path,
852  target_dir: &Path,
853  cache_base: &Path,
854  eco: &crate::ecosystem::Ecosystem,
855  client: &ThunderstoreClient,
856  game: &str,
857  installed_at_time: u64,
858) -> Result<AdoptedProfile> {
859  import_r2modman_dir(source_profile_dir, target_dir)?;
860
861  let adopted: Vec<String> = crate::profile::modlist::read(target_dir)?
862    .iter()
863    .map(|entry| entry.name.clone())
864    .collect();
865
866  // Every recognised loader, not merely the first: the registry is keyed on
867  // package identity rather than on a game, so nothing rules out an adopted
868  // profile naming two.
869  //
870  // Presence is tested rather than parsing the record, so a target that already
871  // holds an *unreadable* one is left exactly as it was. Overwriting it would be
872  // a destructive guess, and `ensure_removable` reports it plainly later.
873  let unrecorded: Vec<String> = adopted
874    .iter()
875    .filter(|name| eco.modloader_package(name).is_some())
876    .filter(|name| !crate::install::state_file_path(target_dir, name).exists())
877    .cloned()
878    .collect();
879
880  if unrecorded.is_empty() {
881    return Ok(AdoptedProfile {
882      adopted,
883      reinstalled: unrecorded,
884    });
885  }
886
887  let index = client.get_manifest().await?;
888
889  // Forced: the copied mods.yml already records each loader, usually at the
890  // version that would be installed, which an ordinary install skips. The
891  // reinstall is the point, since it is what writes the record.
892  for full_name in &unrecorded {
893    crate::profile::install_named_in(
894      target_dir,
895      cache_base,
896      eco,
897      &index,
898      client,
899      game,
900      full_name,
901      true,
902      installed_at_time,
903    )
904    .await?;
905  }
906
907  Ok(AdoptedProfile {
908    adopted,
909    reinstalled: unrecorded,
910  })
911}
912
913/// What an adoption installed, what it could not, and what it swept.
914///
915/// Does not derive `Clone`: `failed` carries an [`Error`] per item, and `Error`
916/// itself does not implement `Clone` (it wraps `io::Error` and `reqwest::Error`,
917/// neither of which do either).
918#[derive(Debug, Default)]
919#[non_exhaustive]
920pub struct AdoptOutcome {
921  /// Desired identifiers this adoption installed, sorted.
922  pub adopted: Vec<String>,
923  /// Desired identifiers already recorded and left alone, sorted: either at the
924  /// version that would be installed, or absent from the index so there was
925  /// nothing to install.
926  pub unchanged: Vec<String>,
927  /// Identifiers still missing from the record, sorted. Their files may be on
928  /// disk from an older installer, unrecorded, where a reconcile would sweep
929  /// them as delisted.
930  ///
931  /// `adopted`, `unchanged`, and `remaining` never name the same identifier. A
932  /// desired name that is recorded but whose install failed, such as a failed
933  /// upgrade, is in none of them, only in `failed`.
934  pub remaining: Vec<String>,
935  /// Root-relative folders the completing sweep removed. Empty unless every
936  /// desired name is recorded, that is unless `remaining` is empty.
937  pub swept: Vec<PathBuf>,
938  /// Each desired name that errored while installing, with the reason
939  /// (unresolvable dependency, network error, checksum mismatch, and so on).
940  ///
941  /// This answers a different question than `remaining`: a name lands here only
942  /// when its own install attempt failed, whereas `remaining` is derived from the
943  /// record afterwards and also holds a name that resolved to nothing without
944  /// erroring. A name can therefore be in `remaining` without being in `failed`.
945  pub failed: Vec<(String, Error)>,
946}
947
948/// Whether `desired` names anything the record does not yet hold.
949///
950/// Keyed on missing entries rather than a missing record: the record is written
951/// after each package installs, so an adoption that failed partway leaves the
952/// file present but incomplete. Keying on the file's existence would both hide
953/// that and refuse the retry, stranding the remaining mods on disk where a later
954/// reconcile would sweep them.
955pub fn needs_adoption(target_dir: &Path, desired: &[String]) -> Result<bool> {
956  if desired.is_empty() {
957    return Ok(false);
958  }
959
960  let recorded = crate::profile::modlist::read(target_dir).unwrap_or_default();
961
962  Ok(
963    desired
964      .iter()
965      .any(|name| crate::profile::modlist::find(&recorded, name).is_none()),
966  )
967}
968
969/// Adopts `desired` into `target_dir`'s record, then reconciles once complete.
970///
971/// Installing clears and rewrites whatever an older folder-based installer
972/// placed, so this is safe over an existing install and safe to retry after a
973/// partial failure: an entry already recorded at its latest version is skipped.
974///
975/// The delisted sweep runs **only** when every desired name is recorded, whether
976/// this run installed it or found it already current. Running it against a
977/// partial record would delete mods that have not been reached yet.
978///
979/// A name counts as adopted when its record carries this run's
980/// `installed_at_time`, since every install stamps it and a skipped mod keeps its
981/// old one. A caller must therefore pass a fresh time
982/// ([`now_millis`](crate::profile::modlist::now_millis)); reusing one from an
983/// earlier install reports the mods that install placed as adopted again.
984#[allow(clippy::too_many_arguments)]
985pub async fn adopt_names_in(
986  target_dir: &Path,
987  cache_base: &Path,
988  eco: &crate::ecosystem::Ecosystem,
989  index: &crate::models::PackageIndex,
990  client: &ThunderstoreClient,
991  game: &str,
992  desired: &[String],
993  installed_at_time: u64,
994) -> Result<AdoptOutcome> {
995  let batch = crate::profile::plan_install_batch(target_dir, desired, &[])?;
996
997  let batch_outcome = crate::profile::install_batch(
998    target_dir,
999    cache_base,
1000    eco,
1001    index,
1002    client,
1003    game,
1004    &batch,
1005    installed_at_time,
1006  )
1007  .await?;
1008
1009  let failed = batch_outcome.failed;
1010  let failed_names: std::collections::BTreeSet<&str> =
1011    failed.iter().map(|(name, _)| name.as_str()).collect();
1012
1013  // Everything is read back from the record rather than from the batch outcome.
1014  // A name can be missing because its own install failed or because it resolved
1015  // to nothing. And a name another desired mod installed as a dependency before
1016  // that mod's install errored is absent from `succeeded`, then found current by
1017  // its own attempt and listed as unchanged, so only the record's install time
1018  // tells this run's installs apart from mods it left alone.
1019  let recorded = crate::profile::modlist::read(target_dir).unwrap_or_default();
1020
1021  let mut adopted: Vec<String> = Vec::new();
1022  let mut unchanged: Vec<String> = Vec::new();
1023  let mut remaining: Vec<String> = Vec::new();
1024
1025  for name in desired {
1026    let Some(entry) = crate::profile::modlist::find(&recorded, name) else {
1027      remaining.push(name.clone());
1028
1029      continue;
1030    };
1031
1032    if failed_names.contains(name.as_str()) {
1033      // Recorded, but its own install failed (a failed upgrade, say), so it is
1034      // reported in `failed` alone.
1035      continue;
1036    }
1037
1038    if entry.installed_at_time == installed_at_time {
1039      adopted.push(name.clone());
1040    } else {
1041      unchanged.push(name.clone());
1042    }
1043  }
1044
1045  adopted.sort();
1046  adopted.dedup();
1047  unchanged.sort();
1048  unchanged.dedup();
1049  remaining.sort();
1050  remaining.dedup();
1051
1052  if !remaining.is_empty() {
1053    return Ok(AdoptOutcome {
1054      adopted,
1055      unchanged,
1056      remaining,
1057      swept: Vec::new(),
1058      failed,
1059    });
1060  }
1061
1062  let game_profile = eco
1063    .game(game)
1064    .and_then(|g| g.profile())
1065    .ok_or_else(|| Error::Profile(format!("no ecosystem profile for game {game:?}")))?;
1066  let keep: std::collections::BTreeSet<String> =
1067    recorded.iter().map(|entry| entry.name.clone()).collect();
1068  let swept = crate::install::remove_delisted(game_profile, target_dir, &keep)?;
1069
1070  Ok(AdoptOutcome {
1071    adopted,
1072    unchanged,
1073    remaining,
1074    swept,
1075    failed,
1076  })
1077}
1078
1079/// Refuses a copy whose source is, contains, or sits inside `target`.
1080///
1081/// `fs::copy` opens the destination with `O_TRUNC` before reading the source, so
1082/// copying a file onto itself zeroes it and returns success. Importing a profile
1083/// into itself would therefore empty the game executable, the loader proxy, every
1084/// plugin assembly, and every hand-edited config, and only then fail parsing the
1085/// record it had just zeroed.
1086///
1087/// The two nesting cases are refused for the same reason rather than for being
1088/// merely odd: a source inside the target has its own tree as the copy
1089/// destination, so a repeated directory name copies into the directory being
1090/// read; a source containing the target has the copy walking into its own
1091/// destination, growing a nested tree until the filesystem gives out.
1092///
1093/// Paths are canonicalized before comparison, so a relative spelling or one
1094/// reached through a symlinked directory still compares equal to what it names. A
1095/// path that cannot be resolved is compared as written rather than assumed
1096/// disjoint.
1097pub fn ensure_disjoint_trees(source: &Path, target: &Path) -> Result<()> {
1098  let source_at = resolve_as_far_as_it_exists(source);
1099  let target_at = resolve_as_far_as_it_exists(target);
1100
1101  if source_at == target_at {
1102    return Err(Error::Profile(format!(
1103      "refusing to copy {} onto itself: every file would be truncated to zero \
1104       bytes",
1105      source.display()
1106    )));
1107  }
1108
1109  if source_at.starts_with(&target_at) {
1110    return Err(Error::Profile(format!(
1111      "refusing to copy {} into {}: the source lives inside the destination, so \
1112       the copy would overwrite the tree it is reading",
1113      source.display(),
1114      target.display()
1115    )));
1116  }
1117
1118  if target_at.starts_with(&source_at) {
1119    return Err(Error::Profile(format!(
1120      "refusing to copy {} into {}: the destination lives inside the source, so \
1121       the copy would recurse into itself",
1122      source.display(),
1123      target.display()
1124    )));
1125  }
1126
1127  Ok(())
1128}
1129
1130/// Canonicalizes as much of `path` as exists, for [`ensure_disjoint_trees`].
1131///
1132/// Tries `path.canonicalize()` first, then [`canonicalize_missing`] for a leaf
1133/// that does not exist yet. Neither is enough on its own for every caller of
1134/// `ensure_disjoint_trees`: `target_dir` is commonly not yet created at all
1135/// when `import_r2modman_dir` calls this, before `copy_tree` has made anything
1136/// under it, so more than one trailing path component can be missing, and
1137/// `canonicalize_missing` only resolves the immediate parent. So when both of
1138/// those fail, this walks up past every missing level in turn, using the
1139/// first ancestor that does canonicalize and rejoining the missing remainder
1140/// onto it. That resolves a symlink anywhere in the existing prefix, not only
1141/// in the immediate parent, which matters here: a symlinked ancestor two or
1142/// more levels above a not-yet-created leaf would otherwise compare as its
1143/// own unresolved spelling and could wrongly look disjoint from a source it
1144/// really nests inside or contains.
1145///
1146/// Falls back to `path` itself, unresolved, if no ancestor down to the path's
1147/// root can be canonicalized (for example a relative path with no existing
1148/// prefix, or a permissions error). That fallback is intentional and matches
1149/// `ensure_disjoint_trees`'s documented behavior: compare something rather
1150/// than assume disjoint.
1151fn resolve_as_far_as_it_exists(path: &Path) -> PathBuf {
1152  if let Ok(canonical) = path.canonicalize() {
1153    return canonical;
1154  }
1155
1156  if let Ok(canonical) = canonicalize_missing(path) {
1157    return canonical;
1158  }
1159
1160  for ancestor in path.ancestors().skip(1) {
1161    let Ok(canonical_ancestor) = ancestor.canonicalize() else {
1162      continue;
1163    };
1164    let Ok(remainder) = path.strip_prefix(ancestor) else {
1165      continue;
1166    };
1167
1168    return canonical_ancestor.join(remainder);
1169  }
1170
1171  path.to_path_buf()
1172}
1173
1174/// Recursively copies a directory tree, skipping symlinks in `src`.
1175///
1176/// A pre-existing symlink at a file's destination in `dst` is refused rather
1177/// than followed (see `refuse_symlink_dest`), so a planted leaf link cannot
1178/// redirect a copy outside `dst`.
1179fn copy_tree(src: &Path, dst: &Path) -> Result<()> {
1180  fs::create_dir_all(dst)
1181    .map_err(|e| Error::Profile(format!("creating {}: {}", dst.display(), e)))?;
1182
1183  for entry in
1184    fs::read_dir(src).map_err(|e| Error::Profile(format!("reading {}: {}", src.display(), e)))?
1185  {
1186    let entry = entry.map_err(|e| Error::Profile(format!("reading an entry: {e}")))?;
1187    let path = entry.path();
1188    let metadata = fs::symlink_metadata(&path)
1189      .map_err(|e| Error::Profile(format!("inspecting {}: {}", path.display(), e)))?;
1190
1191    if metadata.file_type().is_symlink() {
1192      continue;
1193    }
1194
1195    let target = dst.join(entry.file_name());
1196
1197    if metadata.is_dir() {
1198      copy_tree(&path, &target)?;
1199    } else {
1200      refuse_symlink_dest(&target)?;
1201
1202      // A belt-and-braces second line of defence behind `ensure_disjoint_trees`:
1203      // a same-file copy truncates, so it is skipped rather than attempted even
1204      // if a caller reached here another way. Mirrors `stage_one_file`, including
1205      // running after the symlink refusal above, so a symlink planted at the
1206      // destination is still reported as a symlink even in the edge case where it
1207      // happens to resolve back to the source path.
1208      if same_file(&path, &target) {
1209        continue;
1210      }
1211
1212      fs::copy(&path, &target)
1213        .map_err(|e| Error::Profile(format!("copying to {}: {}", target.display(), e)))?;
1214    }
1215  }
1216
1217  Ok(())
1218}
1219
1220/// What an import source string names.
1221///
1222/// A user or a UI hands over one string for "import this", and which of the
1223/// three import routes it means is decided by looking at the filesystem. That
1224/// decision is made here, once, so no caller re-derives it: a caller that wants
1225/// to word the routes differently, or preview what a pasted string would do,
1226/// matches on this instead of stating its own `is_dir`/`is_file` rules.
1227///
1228/// Deliberately **not** `#[non_exhaustive]`, matching
1229/// [`BadProfile`](crate::profile::layout::BadProfile) and
1230/// [`NotRemovable`](crate::profile::NotRemovable): a caller that words each route
1231/// should stop compiling when a fourth appears.
1232#[derive(Debug, Clone, PartialEq, Eq)]
1233pub enum ImportSource {
1234  /// An existing directory, treated as an r2modman profile to adopt in place.
1235  R2modmanDir(PathBuf),
1236  /// An existing file, treated as a `.r2z` export.
1237  Archive(PathBuf),
1238  /// Anything else, treated as a shareable Thunderstore profile code.
1239  Code(String),
1240}
1241
1242impl ImportSource {
1243  /// Classifies one import source string.
1244  ///
1245  /// A path that exists but is neither a directory nor a regular file (a
1246  /// dangling symlink, a socket) falls through to [`Self::Code`], where the
1247  /// attempt fails naming the code endpoint. `is_dir`/`is_file` follow symlinks,
1248  /// so a link to either is classified as its destination.
1249  pub fn classify(source: &str) -> Self {
1250    let path = Path::new(source);
1251
1252    if path.is_dir() {
1253      return Self::R2modmanDir(path.to_path_buf());
1254    }
1255
1256    if path.is_file() {
1257      return Self::Archive(path.to_path_buf());
1258    }
1259
1260    Self::Code(source.to_string())
1261  }
1262}
1263
1264/// What an import installed, by which route.
1265///
1266/// Carries `source` so a caller can word the outcome per route without
1267/// classifying the source string a second time. Every route keeps the versions
1268/// the source recorded, except a loader an adoption reinstalls at latest; see
1269/// [`Self::reinstalled`].
1270#[derive(Debug)]
1271#[non_exhaustive]
1272pub struct ImportOutcome {
1273  /// How `source` was classified, and therefore which route ran.
1274  pub source: ImportSource,
1275  /// Identifiers this import installed, sorted. Dependencies are included for
1276  /// [`ImportSource::Archive`] and [`ImportSource::Code`]; for
1277  /// [`ImportSource::R2modmanDir`] it is every identifier the copied `mods.yml`
1278  /// names.
1279  pub installed: Vec<String>,
1280  /// Identifiers skipped because the target already recorded the version that
1281  /// would be installed, sorted and never also in `installed`. See
1282  /// [`ImportedMods::unchanged`]. Always empty for
1283  /// [`ImportSource::R2modmanDir`], which copies every file regardless.
1284  pub unchanged: Vec<String>,
1285  /// Identifiers reinstalled at their latest version so they would have an
1286  /// install record. Only ever non-empty for [`ImportSource::R2modmanDir`]; see
1287  /// [`AdoptedProfile::reinstalled`].
1288  pub reinstalled: Vec<String>,
1289}
1290
1291/// Imports whatever `source` names into `target_dir`: an r2modman profile
1292/// directory, a `.r2z` file, or a Thunderstore profile code.
1293///
1294/// The single entry point for "import this string". [`ImportSource::classify`]
1295/// picks the route and the returned [`ImportOutcome`] says which one ran, so a
1296/// caller neither inspects the filesystem itself nor reads the archive to hand
1297/// over its bytes.
1298///
1299/// The package index is fetched only on the routes that need it, so adopting a
1300/// directory that names no unrecorded loader stays fully offline. That is why
1301/// this takes a `client` rather than a prefetched
1302/// [`PackageIndex`](crate::models::PackageIndex), matching
1303/// [`adopt_r2modman_dir_in`].
1304///
1305/// `cache_base` is the package cache root and must not be `target_dir`; see
1306/// [`crate::profile::install_mod_in`].
1307pub async fn import_in(
1308  target_dir: &Path,
1309  cache_base: &Path,
1310  eco: &crate::ecosystem::Ecosystem,
1311  client: &ThunderstoreClient,
1312  game: &str,
1313  source: &str,
1314  installed_at_time: u64,
1315) -> Result<ImportOutcome> {
1316  let source = ImportSource::classify(source);
1317
1318  if let ImportSource::R2modmanDir(dir) = &source {
1319    let adopted = adopt_r2modman_dir_in(
1320      dir,
1321      target_dir,
1322      cache_base,
1323      eco,
1324      client,
1325      game,
1326      installed_at_time,
1327    )
1328    .await?;
1329    let mut installed = adopted.adopted;
1330
1331    installed.sort();
1332    installed.dedup();
1333
1334    return Ok(ImportOutcome {
1335      source,
1336      installed,
1337      unchanged: Vec::new(),
1338      reinstalled: adopted.reinstalled,
1339    });
1340  }
1341
1342  let index = client.get_manifest().await?;
1343
1344  let imported = match &source {
1345    ImportSource::Archive(path) => {
1346      let bytes =
1347        fs::read(path).map_err(|e| Error::Profile(format!("reading {}: {}", path.display(), e)))?;
1348
1349      import_r2z_in(
1350        target_dir,
1351        cache_base,
1352        eco,
1353        &index,
1354        client,
1355        game,
1356        &bytes,
1357        installed_at_time,
1358      )
1359      .await?
1360    }
1361    ImportSource::Code(code) => {
1362      import_code_in(
1363        target_dir,
1364        cache_base,
1365        eco,
1366        &index,
1367        client,
1368        game,
1369        code,
1370        installed_at_time,
1371      )
1372      .await?
1373    }
1374    // Unreachable: the `if let ImportSource::R2modmanDir(dir)` above returns
1375    // before this match, so the only way here is a variant added to
1376    // `ImportSource` without a corresponding arm, which the compiler catches
1377    // first. Not reachable by any caller input, so it is not a documented panic.
1378    ImportSource::R2modmanDir(_) => {
1379      unreachable!("R2modmanDir returns from the early branch above this match")
1380    }
1381  };
1382
1383  Ok(ImportOutcome {
1384    source,
1385    installed: imported.installed,
1386    unchanged: imported.unchanged,
1387    reinstalled: Vec::new(),
1388  })
1389}
1390
1391/// Imports a `.r2z` into an r2modman-layout profile. See [`import_r2z_in`].
1392#[allow(clippy::too_many_arguments)]
1393pub async fn import_r2z(
1394  base: &Path,
1395  game: &str,
1396  profile_name: &str,
1397  eco: &crate::ecosystem::Ecosystem,
1398  index: &crate::models::PackageIndex,
1399  client: &ThunderstoreClient,
1400  zip_bytes: &[u8],
1401  installed_at_time: u64,
1402) -> Result<ImportedMods> {
1403  import_r2z_in(
1404    &crate::profile::layout::profile_dir(base, game, profile_name),
1405    base,
1406    eco,
1407    index,
1408    client,
1409    game,
1410    zip_bytes,
1411    installed_at_time,
1412  )
1413  .await
1414}
1415
1416/// Imports a shared profile code into an r2modman-layout profile.
1417/// See [`import_code_in`].
1418#[allow(clippy::too_many_arguments)]
1419pub async fn import_code(
1420  base: &Path,
1421  game: &str,
1422  profile_name: &str,
1423  eco: &crate::ecosystem::Ecosystem,
1424  index: &crate::models::PackageIndex,
1425  client: &ThunderstoreClient,
1426  code: &str,
1427  installed_at_time: u64,
1428) -> Result<ImportedMods> {
1429  import_code_in(
1430    &crate::profile::layout::profile_dir(base, game, profile_name),
1431    base,
1432    eco,
1433    index,
1434    client,
1435    game,
1436    code,
1437    installed_at_time,
1438  )
1439  .await
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444  use super::*;
1445
1446  fn profile_mod(name: &str, enabled: bool) -> ProfileMod {
1447    let mut entry = crate::profile::mod_entry_from_version(name, "Author", &sample_version(), 1);
1448    entry.enabled = enabled;
1449
1450    entry
1451  }
1452
1453  fn sample_version() -> crate::models::Version {
1454    // Built through the index so no `#[non_exhaustive]` literal is needed here.
1455    let json = r#"{"name":"Mod","full_name":"Author-Mod","description":"d","icon":"i",
1456      "version_number":"1.2.3","dependencies":[],"download_url":"u","downloads":1,
1457      "date_created":"2024-01-01T12:00:00Z","website_url":"w","is_active":true,
1458      "uuid4":"u","file_size":1}"#;
1459
1460    serde_json::from_str(json).unwrap()
1461  }
1462
1463  #[test]
1464  fn import_source_classifies_a_directory_a_file_and_a_code() {
1465    let dir = tempfile::tempdir().unwrap();
1466    let profile = dir.path().join("SomeProfile");
1467    let archive = dir.path().join("export.r2z");
1468
1469    std::fs::create_dir_all(&profile).unwrap();
1470    write_test_file(&archive, b"not really a zip");
1471
1472    assert!(matches!(
1473      ImportSource::classify(profile.to_str().unwrap()),
1474      ImportSource::R2modmanDir(path) if path == profile
1475    ));
1476    assert!(matches!(
1477      ImportSource::classify(archive.to_str().unwrap()),
1478      ImportSource::Archive(path) if path == archive
1479    ));
1480
1481    // Anything that is not on disk is a Thunderstore profile code. A
1482    // path-shaped string that does not exist counts too: there is nothing to
1483    // read, and the code endpoint is the only remaining thing it could name.
1484    assert!(matches!(
1485      ImportSource::classify("abc123-def456"),
1486      ImportSource::Code(code) if code == "abc123-def456"
1487    ));
1488    assert!(matches!(
1489      ImportSource::classify("/no/such/path.r2z"),
1490      ImportSource::Code(_)
1491    ));
1492  }
1493
1494  #[test]
1495  fn export_format_maps_mods_and_preserves_enabled() {
1496    let mods = vec![
1497      profile_mod("Author-A", true),
1498      profile_mod("Author-B", false),
1499    ];
1500
1501    let export = export_format_from_mods("MyProfile", &mods);
1502
1503    assert_eq!(export.profile_name, "MyProfile");
1504    assert_eq!(export.mods.len(), 2);
1505    assert_eq!(export.mods[0].name, "Author-A");
1506    assert_eq!(
1507      export.mods[0].version,
1508      SemVer {
1509        major: 1,
1510        minor: 2,
1511        patch: 3
1512      }
1513    );
1514    assert!(export.mods[0].enabled);
1515    assert!(!export.mods[1].enabled);
1516  }
1517
1518  #[test]
1519  fn serialized_r2x_uses_version_not_version_number() {
1520    let mods = vec![profile_mod("Author-A", true)];
1521    let yaml = export_r2x_to_string(&export_format_from_mods("MyProfile", &mods)).unwrap();
1522
1523    assert!(yaml.contains("profileName: MyProfile"));
1524    assert!(yaml.contains("name: Author-A"));
1525    assert!(yaml.contains("version:"));
1526    assert!(yaml.contains("major: 1"));
1527    // `mods.yml` uses `versionNumber`; `export.r2x` must NOT.
1528    assert!(!yaml.contains("versionNumber"));
1529  }
1530
1531  #[test]
1532  fn parses_real_r2x_and_defaults_missing_enabled_to_true() {
1533    let yaml = r#"profileName: Shared
1534mods:
1535  - name: Author-A
1536    version:
1537      major: 2
1538      minor: 0
1539      patch: 1
1540    enabled: false
1541  - name: Author-B
1542    version:
1543      major: 1
1544      minor: 0
1545      patch: 0
1546"#;
1547
1548    let export = parse_export_r2x(yaml).unwrap();
1549
1550    assert_eq!(export.profile_name, "Shared");
1551    assert_eq!(export.mods.len(), 2);
1552    assert!(!export.mods[0].enabled);
1553    // r2modman treats an absent `enabled` as true.
1554    assert!(export.mods[1].enabled);
1555  }
1556
1557  #[test]
1558  fn parse_export_r2x_rejects_malformed() {
1559    assert!(parse_export_r2x("not: a valid export").is_err());
1560  }
1561
1562  fn write_test_file(path: &Path, bytes: &[u8]) {
1563    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1564    std::fs::write(path, bytes).unwrap();
1565  }
1566
1567  #[test]
1568  fn is_pruned_matches_r2modman_rules() {
1569    // Dropped.
1570    assert!(is_pruned(Path::new("dotnet/x.json")));
1571    assert!(is_pruned(Path::new("_state/Author-Mod-state.yml")));
1572    assert!(is_pruned(Path::new("BepInEx/config/BepInEx.cfg")));
1573    assert!(is_pruned(Path::new(
1574      "BepInEx/plugins/Author-Mod/manifest.json"
1575    )));
1576    assert!(is_pruned(Path::new("MelonLoader/x.cfg")));
1577    assert!(is_pruned(Path::new("GDWeave/core/a.json")));
1578    assert!(is_pruned(Path::new("GDWeave/mods/b.json")));
1579    assert!(is_pruned(Path::new("GDWeave/GDWeave.log")));
1580    assert!(is_pruned(Path::new("mods.yml")));
1581
1582    // Kept.
1583    assert!(!is_pruned(Path::new(
1584      "BepInEx/plugins/Author-Mod/settings.cfg"
1585    )));
1586    assert!(!is_pruned(Path::new("GDWeave/other.json")));
1587    assert!(!is_pruned(Path::new("notes.txt")));
1588  }
1589
1590  #[test]
1591  fn is_config_file_matches_supported_extensions() {
1592    assert!(is_config_file(Path::new("a/b.cfg")));
1593    assert!(is_config_file(Path::new("a/b.YAML")));
1594    assert!(is_config_file(Path::new("b.ini")));
1595    assert!(!is_config_file(Path::new("a/b.dll")));
1596    assert!(!is_config_file(Path::new("a/b")));
1597  }
1598
1599  #[test]
1600  fn collect_export_paths_selects_configs_and_skips_pruned() {
1601    let dir = tempfile::tempdir().unwrap();
1602    let root = dir.path();
1603
1604    write_test_file(&root.join("mods.yml"), b"[]");
1605    write_test_file(&root.join("BepInEx/config/BepInEx.cfg"), b"cfg");
1606    write_test_file(&root.join("BepInEx/plugins/Author-Mod/settings.cfg"), b"s");
1607    write_test_file(
1608      &root.join("BepInEx/plugins/Author-Mod/manifest.json"),
1609      b"{}",
1610    );
1611    write_test_file(&root.join("BepInEx/plugins/Author-Mod/Mod.dll"), b"dll");
1612    write_test_file(&root.join("_state/Author-Mod-state.yml"), b"x");
1613
1614    let paths = collect_export_paths(root).unwrap();
1615    let as_str: Vec<String> = paths
1616      .iter()
1617      .map(|p| p.to_string_lossy().replace('\\', "/"))
1618      .collect();
1619
1620    assert_eq!(
1621      as_str,
1622      vec!["BepInEx/plugins/Author-Mod/settings.cfg".to_string()]
1623    );
1624  }
1625
1626  #[test]
1627  fn build_r2z_contains_manifest_and_configs() {
1628    let dir = tempfile::tempdir().unwrap();
1629    let root = dir.path();
1630
1631    crate::profile::modlist::write(root, &[profile_mod("Author-A", true)]).unwrap();
1632    write_test_file(&root.join("BepInEx/config/BepInEx.cfg"), b"shared");
1633    write_test_file(&root.join("BepInEx/plugins/Author-A/settings.cfg"), b"mine");
1634    write_test_file(&root.join("BepInEx/plugins/Author-A/Mod.dll"), b"dll");
1635
1636    let bytes = build_r2z_in(root, "MyProfile").unwrap();
1637
1638    let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
1639    let names: Vec<String> = (0..zip.len())
1640      .map(|i| zip.by_index(i).unwrap().name().replace('\\', "/"))
1641      .collect();
1642
1643    assert!(names.contains(&"export.r2x".to_string()));
1644    // BepInEx/config/ is re-rooted under config/ in the archive.
1645    assert!(names.contains(&"config/BepInEx.cfg".to_string()));
1646    // Other config files keep their target-relative path.
1647    assert!(names.contains(&"BepInEx/plugins/Author-A/settings.cfg".to_string()));
1648    // Binaries and mods.yml are never exported.
1649    assert!(!names.iter().any(|n| n.ends_with("Mod.dll")));
1650    assert!(!names.contains(&"mods.yml".to_string()));
1651
1652    let mut manifest = String::new();
1653    std::io::Read::read_to_string(&mut zip.by_name("export.r2x").unwrap(), &mut manifest).unwrap();
1654
1655    let export = parse_export_r2x(&manifest).unwrap();
1656
1657    assert_eq!(export.profile_name, "MyProfile");
1658    assert_eq!(export.mods[0].name, "Author-A");
1659  }
1660
1661  #[test]
1662  fn export_to_file_uses_name_and_timestamp() {
1663    let dir = tempfile::tempdir().unwrap();
1664    let root = dir.path().join("profile");
1665    let out = dir.path().join("exports");
1666
1667    crate::profile::modlist::write(&root, &[profile_mod("Author-A", true)]).unwrap();
1668
1669    let path = export_to_file_in(&root, "MyProfile", &out, 1_700_000_000_000).unwrap();
1670
1671    assert_eq!(
1672      path.file_name().unwrap().to_string_lossy(),
1673      "MyProfile_1700000000000.r2z"
1674    );
1675    assert!(path.exists());
1676  }
1677
1678  #[test]
1679  fn export_to_file_rejects_unsafe_profile_name() {
1680    let dir = tempfile::tempdir().unwrap();
1681    let root = dir.path().join("profile");
1682    let out = dir.path().join("exports");
1683
1684    crate::profile::modlist::write(&root, &[profile_mod("Author-A", true)]).unwrap();
1685
1686    assert!(export_to_file_in(&root, "../evil", &out, 1).is_err());
1687    assert!(!dir.path().join("evil_1.r2z").exists());
1688  }
1689
1690  #[test]
1691  fn encode_profile_payload_prefixes_and_base64s() {
1692    let payload = encode_profile_payload(b"ABC").unwrap();
1693
1694    assert_eq!(payload, b"#r2modman\nQUJD");
1695  }
1696
1697  #[test]
1698  fn encode_profile_payload_rejects_oversize() {
1699    let big = vec![0u8; MAX_EXPORT_CODE_BYTES + 1];
1700
1701    assert!(matches!(
1702      encode_profile_payload(&big),
1703      Err(Error::SizeLimitExceeded { .. })
1704    ));
1705  }
1706
1707  #[test]
1708  fn decode_profile_payload_round_trips_and_rejects_bad_prefix() {
1709    let payload = encode_profile_payload(b"ABC").unwrap();
1710
1711    assert_eq!(decode_profile_payload(&payload).unwrap(), b"ABC");
1712    assert!(decode_profile_payload(b"nope\nQUJD").is_err());
1713  }
1714
1715  #[test]
1716  fn import_config_key_remaps_config_and_blocks_unsafe() {
1717    // `config/` is re-rooted back under BepInEx/.
1718    assert_eq!(
1719      import_config_key("config/BepInEx.cfg"),
1720      Some(PathBuf::from("BepInEx/config/BepInEx.cfg"))
1721    );
1722    // Other entries keep their path.
1723    assert_eq!(
1724      import_config_key("BepInEx/plugins/Author-A/settings.cfg"),
1725      Some(PathBuf::from("BepInEx/plugins/Author-A/settings.cfg"))
1726    );
1727    // Manifest and manager state are skipped.
1728    assert_eq!(import_config_key("export.r2x"), None);
1729    assert_eq!(import_config_key("mods.yml"), None);
1730    // Executables are refused even inside an otherwise valid path.
1731    assert_eq!(import_config_key("BepInEx/plugins/evil.dll"), None);
1732    // Traversal is refused.
1733    assert_eq!(import_config_key("../escape.cfg"), None);
1734  }
1735
1736  #[test]
1737  fn import_config_key_blocks_trailing_dot_and_space_bypass() {
1738    // Windows strips trailing dots/spaces from the final path component on
1739    // create, so these must be blocked even though they don't literally end
1740    // with a blocked extension.
1741    let blocked = [
1742      "evil.dll.",
1743      "evil.dll ",
1744      "evil.dll. . ",
1745      "evil.DLL",
1746      "evil.cfg.dll",
1747    ];
1748
1749    for name in blocked {
1750      assert_eq!(
1751        import_config_key(name),
1752        None,
1753        "{name} should have been blocked"
1754      );
1755    }
1756
1757    // An ordinary config entry is unaffected.
1758    assert_eq!(import_config_key("a.cfg"), Some(PathBuf::from("a.cfg")));
1759  }
1760
1761  #[test]
1762  fn read_export_accepts_r2z_and_bare_r2x() {
1763    let dir = tempfile::tempdir().unwrap();
1764    let root = dir.path();
1765
1766    crate::profile::modlist::write(root, &[profile_mod("Author-A", true)]).unwrap();
1767
1768    let zip_bytes = build_r2z_in(root, "MyProfile").unwrap();
1769
1770    assert_eq!(read_export(&zip_bytes).unwrap().profile_name, "MyProfile");
1771
1772    let yaml = export_r2x_to_string(&export_format_from_mods("Bare", &[])).unwrap();
1773
1774    assert_eq!(read_export(yaml.as_bytes()).unwrap().profile_name, "Bare");
1775  }
1776
1777  #[test]
1778  fn extract_configs_writes_remapped_files_only() {
1779    let dir = tempfile::tempdir().unwrap();
1780    let source = dir.path().join("source");
1781    let target = dir.path().join("target");
1782
1783    crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
1784    write_test_file(&source.join("BepInEx/config/BepInEx.cfg"), b"shared");
1785    write_test_file(
1786      &source.join("BepInEx/plugins/Author-A/settings.cfg"),
1787      b"mine",
1788    );
1789
1790    let zip_bytes = build_r2z_in(&source, "MyProfile").unwrap();
1791    let written = extract_configs_in(&target, &zip_bytes).unwrap();
1792
1793    assert!(target.join("BepInEx/config/BepInEx.cfg").exists());
1794    assert!(
1795      target
1796        .join("BepInEx/plugins/Author-A/settings.cfg")
1797        .exists()
1798    );
1799    // The manifest itself is never written into the target.
1800    assert!(!target.join("export.r2x").exists());
1801    assert_eq!(written.len(), 2);
1802  }
1803
1804  #[test]
1805  fn extract_configs_treats_non_zip_input_as_no_configs() {
1806    let dir = tempfile::tempdir().unwrap();
1807    let target = dir.path().join("target");
1808
1809    // A bare `.r2x` manifest — no zip container at all.
1810    let yaml = export_r2x_to_string(&export_format_from_mods("Bare", &[])).unwrap();
1811
1812    let written = extract_configs_in(&target, yaml.as_bytes()).unwrap();
1813
1814    assert!(written.is_empty());
1815  }
1816
1817  #[test]
1818  fn extract_configs_still_rejects_a_genuinely_corrupt_zip() {
1819    let dir = tempfile::tempdir().unwrap();
1820    let target = dir.path().join("target");
1821
1822    // Looks like a zip (starts with the `PK` magic) but is truncated garbage.
1823    let corrupt = b"PK\x03\x04not a real zip".to_vec();
1824
1825    assert!(extract_configs_in(&target, &corrupt).is_err());
1826  }
1827
1828  #[test]
1829  #[cfg(unix)]
1830  fn extract_configs_refuses_to_write_through_a_symlink() {
1831    use std::os::unix::fs::symlink;
1832
1833    let outside = tempfile::tempdir().unwrap();
1834    let secret = outside.path().join("secret.cfg");
1835    std::fs::write(&secret, b"original").unwrap();
1836
1837    let source = tempfile::tempdir().unwrap();
1838
1839    crate::profile::modlist::write(source.path(), &[profile_mod("Author-A", true)]).unwrap();
1840    write_test_file(
1841      &source.path().join("BepInEx/config/BepInEx.cfg"),
1842      b"new-content",
1843    );
1844
1845    let zip_bytes = build_r2z_in(source.path(), "MyProfile").unwrap();
1846
1847    // Plant a symlink at the destination the archive's `config/BepInEx.cfg`
1848    // entry remaps to, pointing outside the target.
1849    let target = tempfile::tempdir().unwrap();
1850    let dest = target.path().join("BepInEx/config/BepInEx.cfg");
1851
1852    std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1853    symlink(&secret, &dest).unwrap();
1854
1855    let err = extract_configs_in(target.path(), &zip_bytes).unwrap_err();
1856
1857    assert!(matches!(err, Error::Profile(_)));
1858    // The write is refused and the symlink target outside the target dir is untouched.
1859    assert_eq!(std::fs::read_to_string(&secret).unwrap(), "original");
1860  }
1861
1862  /// Builds an in-memory zip with a single highly-compressible entry, so the
1863  /// archive itself stays tiny while its inflated content can dwarf the caps.
1864  fn zip_with_one_zero_filled_entry(name: &str, inflated_len: usize) -> Vec<u8> {
1865    let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
1866    let options: FileOptions<'_, ()> =
1867      FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
1868
1869    zip.start_file(name, options).unwrap();
1870    zip.write_all(&vec![0u8; inflated_len]).unwrap();
1871
1872    zip.finish().unwrap().into_inner()
1873  }
1874
1875  #[test]
1876  fn extract_configs_rejects_entry_exceeding_the_per_entry_cap() {
1877    let bytes = zip_with_one_zero_filled_entry(
1878      "BepInEx/config/Big.cfg",
1879      (MAX_IMPORT_ENTRY_BYTES + 1) as usize,
1880    );
1881
1882    let dir = tempfile::tempdir().unwrap();
1883    let err = extract_configs_in(dir.path(), &bytes).unwrap_err();
1884
1885    assert!(matches!(err, Error::ExtractLimitExceeded(_)));
1886  }
1887
1888  #[test]
1889  fn extract_configs_rejects_import_exceeding_the_cumulative_cap() {
1890    // Each entry sits just under the per-entry cap individually, but enough of
1891    // them together must exceed the cumulative cap.
1892    let per_entry_len = (MAX_IMPORT_ENTRY_BYTES - 1024) as usize;
1893    let entries_needed = (MAX_IMPORT_TOTAL_BYTES / per_entry_len as u64) + 1;
1894
1895    let mut zip = ZipWriter::new(Cursor::new(Vec::new()));
1896    let options: FileOptions<'_, ()> =
1897      FileOptions::default().compression_method(zip::CompressionMethod::Deflated);
1898    let chunk = vec![0u8; per_entry_len];
1899
1900    for i in 0..entries_needed {
1901      zip
1902        .start_file(format!("BepInEx/config/{i}.cfg"), options)
1903        .unwrap();
1904      zip.write_all(&chunk).unwrap();
1905    }
1906
1907    let bytes = zip.finish().unwrap().into_inner();
1908
1909    let dir = tempfile::tempdir().unwrap();
1910    let err = extract_configs_in(dir.path(), &bytes).unwrap_err();
1911
1912    assert!(matches!(err, Error::ExtractLimitExceeded(_)));
1913  }
1914
1915  #[test]
1916  fn import_r2modman_dir_adopts_an_existing_profile() {
1917    let dir = tempfile::tempdir().unwrap();
1918    let source = dir.path().join("source");
1919    let target = dir.path().join("target");
1920
1921    crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
1922    write_test_file(&source.join("BepInEx/plugins/Author-A/Mod.dll"), b"dll");
1923
1924    import_r2modman_dir(&source, &target).unwrap();
1925
1926    assert!(target.join("BepInEx/plugins/Author-A/Mod.dll").exists());
1927    assert_eq!(crate::profile::modlist::read(&target).unwrap().len(), 1);
1928    assert!(import_r2modman_dir(&dir.path().join("missing"), &target).is_err());
1929  }
1930
1931  #[test]
1932  #[cfg(unix)]
1933  fn import_r2modman_dir_refuses_to_copy_through_a_symlink() {
1934    use std::os::unix::fs::symlink;
1935
1936    let outside = tempfile::tempdir().unwrap();
1937    let secret = outside.path().join("secret.cfg");
1938    std::fs::write(&secret, b"original").unwrap();
1939
1940    let dir = tempfile::tempdir().unwrap();
1941    let source = dir.path().join("source");
1942    let target = dir.path().join("target");
1943
1944    crate::profile::modlist::write(&source, &[profile_mod("Author-A", true)]).unwrap();
1945    write_test_file(
1946      &source.join("BepInEx/plugins/Author-A/settings.cfg"),
1947      b"new-content",
1948    );
1949
1950    // Plant a symlink at the destination the source's config file would copy to.
1951    let dest = target.join("BepInEx/plugins/Author-A/settings.cfg");
1952
1953    std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
1954    symlink(&secret, &dest).unwrap();
1955
1956    let err = import_r2modman_dir(&source, &target).unwrap_err();
1957
1958    assert!(matches!(err, Error::Profile(_)));
1959    // The copy is refused and the symlink target outside the target dir is untouched.
1960    assert_eq!(std::fs::read_to_string(&secret).unwrap(), "original");
1961  }
1962
1963  #[test]
1964  fn copy_tree_skips_a_same_file_copy_instead_of_truncating_it() {
1965    let dir = tempfile::tempdir().unwrap();
1966    let root = dir.path();
1967
1968    write_test_file(&root.join("sub/file.txt"), b"original bytes");
1969
1970    // `ensure_disjoint_trees` refuses an overlapping source and target on
1971    // every public path (see `importing_a_directory_into_itself_is_refused_before_anything_is_truncated`
1972    // in the integration suite), so `copy_tree`'s own `same_file` skip is
1973    // otherwise never reached there. Calling it directly, the way a future
1974    // caller reaching `copy_tree` another way might, is the only way to
1975    // exercise this second line of defence.
1976    copy_tree(root, root).unwrap();
1977
1978    let bytes = std::fs::read(root.join("sub/file.txt")).unwrap();
1979
1980    assert_eq!(
1981      bytes, b"original bytes",
1982      "a same-file copy must be skipped, not truncate the file to zero bytes"
1983    );
1984  }
1985}