Skip to main content

thunderstore_engine/profile/
layout.rs

1//! Directory layout and profile lifecycle.
2//!
3//! Profiles live at `<base>/<game>/profiles/<name>/`, matching r2modman so a
4//! profile directory produced here works when copied into r2modman's data dir.
5//! The caller owns `base` (platform/path discovery stays outside the engine).
6//!
7//! # Errors
8//!
9//! Every fallible function in this module returns
10//! [`Error::Profile`](crate::Error::Profile) and nothing else. Underlying IO
11//! failures are wrapped with the offending path rather than surfaced as
12//! [`Error::Io`](crate::Error::Io), so a caller can render the message directly
13//! and does not need to match other variants or prepend a path itself.
14//!
15//! Every function taking a profile name calls
16//! [`validate_name`](crate::profile::layout::validate_name) before touching the
17//! filesystem, so a name containing a separator, NUL, `.`, or `..` is rejected
18//! before it can escape the profiles directory. Individual functions document
19//! only the conditions beyond that shared contract.
20
21#![deny(missing_docs)]
22
23use crate::error::{Error, Result};
24use std::fs;
25use std::path::{Path, PathBuf};
26
27/// The profiles directory for a game: `<base>/<game>/profiles`.
28pub fn profiles_dir(base: &Path, game: &str) -> PathBuf {
29  base.join(game).join("profiles")
30}
31
32/// A single profile directory: `<base>/<game>/profiles/<name>`.
33pub fn profile_dir(base: &Path, game: &str, name: &str) -> PathBuf {
34  profiles_dir(base, game).join(name)
35}
36
37/// Rejects a profile name that is empty, a path component (`.`/`..`), or that
38/// contains a path separator or NUL — so a name can never escape the profiles
39/// directory.
40pub fn validate_name(name: &str) -> Result<()> {
41  let bad = name.is_empty() || name == "." || name == ".." || name.contains(['/', '\\', '\0']);
42
43  if bad {
44    return Err(Error::Profile(format!("invalid profile name {name:?}")));
45  }
46
47  Ok(())
48}
49
50/// Lists profile names (immediate subdirectories) for a game, sorted. A missing
51/// profiles directory yields an empty list.
52pub fn list(base: &Path, game: &str) -> Result<Vec<String>> {
53  let dir = profiles_dir(base, game);
54
55  if !dir.is_dir() {
56    return Ok(Vec::new());
57  }
58
59  let mut names = Vec::new();
60
61  for entry in
62    fs::read_dir(&dir).map_err(|e| Error::Profile(format!("reading {}: {}", dir.display(), e)))?
63  {
64    let entry = entry.map_err(|e| Error::Profile(format!("reading an entry: {e}")))?;
65
66    if entry.path().is_dir()
67      && let Some(name) = entry.file_name().to_str()
68    {
69      names.push(name.to_string());
70    }
71  }
72
73  names.sort();
74
75  Ok(names)
76}
77
78/// Creates a new, empty profile directory. Errors if it already exists so a
79/// create never silently adopts an existing profile.
80pub fn create(base: &Path, game: &str, name: &str) -> Result<PathBuf> {
81  validate_name(name)?;
82
83  let dir = profile_dir(base, game, name);
84
85  if dir.exists() {
86    return Err(Error::Profile(format!("profile {name:?} already exists")));
87  }
88
89  fs::create_dir_all(&dir)
90    .map_err(|e| Error::Profile(format!("creating {}: {}", dir.display(), e)))?;
91
92  Ok(dir)
93}
94
95/// Removes a profile directory and everything under it, and clears the stored
96/// selection if it named this profile.
97///
98/// # Errors
99///
100/// Errors if the profile does not exist, so deleting an already-deleted profile
101/// is reported rather than treated as success.
102pub fn delete(base: &Path, game: &str, name: &str) -> Result<()> {
103  validate_name(name)?;
104
105  let dir = profile_dir(base, game, name);
106
107  fs::remove_dir_all(&dir)
108    .map_err(|e| Error::Profile(format!("deleting {}: {}", dir.display(), e)))?;
109
110  if read_selection(base, game).as_deref() == Some(name) {
111    clear_selection(base, game)?;
112  }
113
114  Ok(())
115}
116
117/// Renames a profile, carrying the stored selection across if it named `from`.
118///
119/// # Errors
120///
121/// Errors if `to` already exists, so a rename never clobbers another profile.
122///
123/// Not transactional: the rename can succeed and the selection update then fail,
124/// leaving the selection naming a profile that no longer exists. Callers that
125/// care can re-read it with [`read_selection`].
126pub fn rename(base: &Path, game: &str, from: &str, to: &str) -> Result<()> {
127  validate_name(from)?;
128  validate_name(to)?;
129
130  let src = profile_dir(base, game, from);
131  let dst = profile_dir(base, game, to);
132
133  if dst.exists() {
134    return Err(Error::Profile(format!("profile {to:?} already exists")));
135  }
136
137  fs::rename(&src, &dst).map_err(|e| {
138    Error::Profile(format!(
139      "renaming {} to {}: {}",
140      src.display(),
141      dst.display(),
142      e
143    ))
144  })?;
145
146  // Carry the selection across, or it is left naming a directory that no longer
147  // exists. Not transactional: a failed write here leaves that dangling state.
148  // The window is narrow and accepted rather than missed.
149  if read_selection(base, game).as_deref() == Some(from) {
150    write_selection(base, game, to)?;
151  }
152
153  Ok(())
154}
155
156/// Duplicates a profile directory (recursive copy) under a new name.
157///
158/// Symlinks are skipped rather than followed or recreated, so the copy cannot be
159/// made to write outside `to`.
160///
161/// # Errors
162///
163/// Errors if `to` already exists. A failed copy can leave `to` partially
164/// written; it is not cleaned up.
165pub fn duplicate(base: &Path, game: &str, from: &str, to: &str) -> Result<PathBuf> {
166  validate_name(from)?;
167  validate_name(to)?;
168
169  let src = profile_dir(base, game, from);
170  let dst = profile_dir(base, game, to);
171
172  if dst.exists() {
173    return Err(Error::Profile(format!("profile {to:?} already exists")));
174  }
175
176  copy_tree(&src, &dst)?;
177
178  Ok(dst)
179}
180
181/// Recursively copies a directory tree, creating `dst`. Skips symlinks (never
182/// follows them out of the tree), like the install layer's `walk_files`.
183fn copy_tree(src: &Path, dst: &Path) -> Result<()> {
184  fs::create_dir_all(dst)
185    .map_err(|e| Error::Profile(format!("creating {}: {}", dst.display(), e)))?;
186
187  for entry in
188    fs::read_dir(src).map_err(|e| Error::Profile(format!("reading {}: {}", src.display(), e)))?
189  {
190    let entry = entry.map_err(|e| Error::Profile(format!("reading an entry: {e}")))?;
191    let metadata = fs::symlink_metadata(entry.path())
192      .map_err(|e| Error::Profile(format!("inspecting {}: {}", entry.path().display(), e)))?;
193
194    if metadata.file_type().is_symlink() {
195      continue;
196    }
197
198    let target = dst.join(entry.file_name());
199
200    if metadata.is_dir() {
201      copy_tree(&entry.path(), &target)?;
202    } else {
203      // `duplicate`'s src and dst are both `profile_dir(base, game, <name>)`
204      // siblings under one parent, and `dst.exists()` refuses the equal case
205      // before this ever runs, so an overlapping source and target cannot
206      // reach here today. The skip is added anyway: a future caller of
207      // `copy_tree` reaching it another way could otherwise truncate a file by
208      // copying it onto itself, the same failure mode `same_file` guards
209      // against in the portability layer's `copy_tree` and in
210      // `stage_one_file`.
211      if crate::util::same_file(&entry.path(), &target) {
212        continue;
213      }
214
215      fs::copy(entry.path(), &target)
216        .map_err(|e| Error::Profile(format!("copying to {}: {}", target.display(), e)))?;
217    }
218  }
219
220  Ok(())
221}
222
223/// The file under `<base>/<game>/` recording which profile is active.
224const SELECTION_FILE: &str = "selected_profile";
225
226/// The path of the persisted profile selection.
227///
228/// Public because two clients sharing one base directory must agree on this path
229/// or they silently stop seeing each other's active profile.
230pub fn selection_path(base: &Path, game: &str) -> PathBuf {
231  base.join(game).join(SELECTION_FILE)
232}
233
234/// The persisted selection, or `None` when absent, empty, or unreadable.
235pub fn read_selection(base: &Path, game: &str) -> Option<String> {
236  let text = fs::read_to_string(selection_path(base, game)).ok()?;
237  let name = text.trim();
238
239  match name.is_empty() {
240    true => None,
241    false => Some(name.to_string()),
242  }
243}
244
245/// Persists `name` as the selected profile.
246pub fn write_selection(base: &Path, game: &str, name: &str) -> Result<()> {
247  let path = selection_path(base, game);
248
249  if let Some(parent) = path.parent() {
250    fs::create_dir_all(parent)
251      .map_err(|e| Error::Profile(format!("creating {}: {}", parent.display(), e)))?;
252  }
253
254  fs::write(&path, name).map_err(|e| Error::Profile(format!("writing {}: {}", path.display(), e)))
255}
256
257/// Removes the persisted selection, returning to game-directory mode. Absent is
258/// not an error.
259pub fn clear_selection(base: &Path, game: &str) -> Result<()> {
260  let path = selection_path(base, game);
261
262  if !path.exists() {
263    return Ok(());
264  }
265
266  fs::remove_file(&path).map_err(|e| Error::Profile(format!("removing {}: {}", path.display(), e)))
267}
268
269/// Why a profile name cannot be used.
270///
271/// Returned rather than flattened into a message so a caller wording the refusal
272/// itself, e.g. to offer to create the profile, gets the reason and the path it
273/// would go at instead of a sentence it would have to re-derive both from.
274/// Travels inside [`Error::BadProfile`] out of operations that have other ways
275/// to fail, so no such operation has to give up its single error type or its
276/// composition to keep the reason intact.
277///
278/// Deliberately **not** `#[non_exhaustive]`, matching [`super::NotRemovable`]: a
279/// caller that words each refusal should stop compiling when a new reason
280/// appears, rather than printing a generic fallback for a case nobody worded.
281/// [`Error`] being `#[non_exhaustive]` does not weaken that: the wildcard arm a
282/// client needs there is for other kinds of failure, not for other refusals.
283#[derive(Debug)]
284pub enum BadProfile {
285  /// The name is not a safe single path component, so it could escape the
286  /// profiles directory. See [`validate_name`].
287  InvalidName {
288    /// The name as given.
289    name: String,
290  },
291  /// No profile directory exists at `dir`.
292  Missing {
293    /// The name as given.
294    name: String,
295    /// Where the profile was expected.
296    dir: PathBuf,
297  },
298}
299
300impl std::fmt::Display for BadProfile {
301  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
302    match self {
303      Self::InvalidName { name } => write!(f, "invalid profile name {name:?}"),
304      Self::Missing { name, .. } => write!(f, "there is no profile named {name:?}"),
305    }
306  }
307}
308
309impl std::error::Error for BadProfile {}
310
311/// The directory of an existing profile, classifying why not when there is none.
312///
313/// The single place that decides "is this a usable profile name": both
314/// [`use_profile`] and [`crate::profile::target::InstallTarget::resolve`] go
315/// through it, so they cannot drift apart, and a caller that wants to word the
316/// refusal itself gets the reason rather than a sentence.
317pub fn require_profile(
318  base: &Path,
319  game: &str,
320  name: &str,
321) -> std::result::Result<PathBuf, BadProfile> {
322  validate_name(name).map_err(|_| BadProfile::InvalidName {
323    name: name.to_string(),
324  })?;
325
326  let dir = profile_dir(base, game, name);
327
328  if !dir.is_dir() {
329    return Err(BadProfile::Missing {
330      name: name.to_string(),
331      dir,
332    });
333  }
334
335  Ok(dir)
336}
337
338/// Selects `name` as the active profile, refusing one that does not exist.
339pub fn use_profile(base: &Path, game: &str, name: &str) -> Result<()> {
340  require_profile(base, game, name)?;
341
342  write_selection(base, game, name)
343}
344
345/// Chooses the active profile from a caller's override and the persisted
346/// selection. Pure, so the precedence is testable without touching disk.
347///
348/// An explicit `flag` wins. `no_profile` opts out of a persisted selection.
349/// Otherwise the persisted selection applies, and its absence means
350/// game-directory mode.
351pub fn select_profile(
352  flag: Option<&str>,
353  no_profile: bool,
354  persisted: Option<&str>,
355) -> Option<String> {
356  if let Some(name) = flag {
357    return Some(name.to_string());
358  }
359
360  if no_profile {
361    return None;
362  }
363
364  persisted.map(|name| name.to_string())
365}
366
367#[cfg(test)]
368mod tests {
369  use super::*;
370  use std::fs;
371  use tempfile::tempdir;
372
373  #[test]
374  fn paths_follow_r2modman_layout() {
375    let base = Path::new("/base");
376
377    assert_eq!(
378      profiles_dir(base, "valheim"),
379      Path::new("/base/valheim/profiles")
380    );
381    assert_eq!(
382      profile_dir(base, "valheim", "Main"),
383      Path::new("/base/valheim/profiles/Main")
384    );
385  }
386
387  #[test]
388  fn validate_name_rejects_unsafe() {
389    assert!(validate_name("Main").is_ok());
390    assert!(validate_name("").is_err());
391    assert!(validate_name("..").is_err());
392    assert!(validate_name("a/b").is_err());
393    assert!(validate_name("a\\b").is_err());
394  }
395
396  #[test]
397  fn create_list_rename_duplicate_delete() {
398    let dir = tempdir().unwrap();
399    let base = dir.path();
400
401    assert_eq!(list(base, "valheim").unwrap(), Vec::<String>::new());
402
403    create(base, "valheim", "Main").unwrap();
404
405    assert!(profile_dir(base, "valheim", "Main").is_dir());
406    assert!(create(base, "valheim", "Main").is_err()); // already exists
407
408    fs::write(profile_dir(base, "valheim", "Main").join("mods.yml"), "[]").unwrap();
409
410    duplicate(base, "valheim", "Main", "Copy").unwrap();
411
412    assert!(
413      profile_dir(base, "valheim", "Copy")
414        .join("mods.yml")
415        .exists()
416    );
417
418    rename(base, "valheim", "Copy", "Renamed").unwrap();
419
420    let mut names = list(base, "valheim").unwrap();
421    names.sort();
422
423    assert_eq!(names, vec!["Main".to_string(), "Renamed".to_string()]);
424
425    delete(base, "valheim", "Main").unwrap();
426
427    assert_eq!(list(base, "valheim").unwrap(), vec!["Renamed".to_string()]);
428  }
429
430  #[test]
431  fn the_selection_follows_a_rename_and_clears_on_delete() {
432    let base = tempdir().unwrap();
433    let root = base.path();
434
435    create(root, "valheim", "one").unwrap();
436    create(root, "valheim", "two").unwrap();
437
438    assert_eq!(read_selection(root, "valheim"), None);
439
440    use_profile(root, "valheim", "one").unwrap();
441
442    assert_eq!(read_selection(root, "valheim"), Some("one".to_string()));
443
444    // Renaming the selected profile must carry the selection with it, or the
445    // selection is left naming a directory that no longer exists.
446    rename(root, "valheim", "one", "renamed").unwrap();
447
448    assert_eq!(read_selection(root, "valheim"), Some("renamed".to_string()));
449
450    // Renaming a profile that is not selected must not disturb the selection.
451    rename(root, "valheim", "two", "other").unwrap();
452
453    assert_eq!(read_selection(root, "valheim"), Some("renamed".to_string()));
454
455    // Deleting a profile that is not selected leaves the selection alone.
456    delete(root, "valheim", "other").unwrap();
457
458    assert_eq!(read_selection(root, "valheim"), Some("renamed".to_string()));
459
460    // Deleting the selected one clears it, returning to game-directory mode.
461    delete(root, "valheim", "renamed").unwrap();
462
463    assert_eq!(read_selection(root, "valheim"), None);
464
465    // Selecting something that does not exist is refused.
466    assert!(use_profile(root, "valheim", "ghost").is_err());
467    // Clearing an already-absent selection is not an error.
468    clear_selection(root, "valheim").unwrap();
469  }
470
471  #[test]
472  fn require_profile_classifies_why_a_name_is_unusable() {
473    let base = tempdir().unwrap();
474    let root = base.path();
475
476    create(root, "valheim", "one").unwrap();
477
478    assert_eq!(
479      require_profile(root, "valheim", "one").unwrap(),
480      profile_dir(root, "valheim", "one")
481    );
482
483    let unsafe_name = require_profile(root, "valheim", "../escape").unwrap_err();
484
485    assert!(
486      matches!(&unsafe_name, BadProfile::InvalidName { name } if name == "../escape"),
487      "an unsafe name must be told apart from an absent one; got: {unsafe_name:?}"
488    );
489
490    let missing = require_profile(root, "valheim", "ghost").unwrap_err();
491
492    // The expected directory comes back with the refusal, so a caller offering
493    // to create the profile does not have to recompute where it would go.
494    assert!(
495      matches!(&missing, BadProfile::Missing { name, dir }
496        if name == "ghost" && dir == &profile_dir(root, "valheim", "ghost")),
497      "a missing profile must carry its name and expected directory; got: {missing:?}"
498    );
499  }
500
501  #[test]
502  fn use_profile_and_resolve_refuse_a_missing_profile_identically() {
503    let base = tempdir().unwrap();
504    let root = base.path();
505    let game_dir = tempdir().unwrap();
506
507    // Both entry points classify through `require_profile`, so neither can drift
508    // into wording the same refusal differently.
509    let selecting = use_profile(root, "valheim", "ghost").unwrap_err();
510    let resolving = crate::profile::target::InstallTarget::resolve(
511      root.to_path_buf(),
512      game_dir.path().to_path_buf(),
513      "valheim",
514      Some("ghost"),
515    )
516    .unwrap_err();
517
518    assert_eq!(selecting.to_string(), resolving.to_string());
519    assert_eq!(
520      selecting.to_string(),
521      "Profile error: there is no profile named \"ghost\""
522    );
523
524    // Not just the same sentence: the same matchable reason, so a caller wording
525    // it does not have to care which entry point refused.
526    assert!(
527      matches!(selecting, Error::BadProfile(BadProfile::Missing { .. }))
528        && matches!(resolving, Error::BadProfile(BadProfile::Missing { .. })),
529      "both must carry the reason, not a flattened message"
530    );
531
532    let selecting = use_profile(root, "valheim", "a/b").unwrap_err();
533    let resolving = crate::profile::target::InstallTarget::resolve(
534      root.to_path_buf(),
535      game_dir.path().to_path_buf(),
536      "valheim",
537      Some("a/b"),
538    )
539    .unwrap_err();
540
541    assert_eq!(selecting.to_string(), resolving.to_string());
542    assert_eq!(
543      selecting.to_string(),
544      "Profile error: invalid profile name \"a/b\""
545    );
546  }
547
548  #[test]
549  fn select_profile_precedence() {
550    // An explicit flag wins over everything.
551    assert_eq!(
552      select_profile(Some("experiment"), false, Some("saved")),
553      Some("experiment".to_string())
554    );
555    // Opting out ignores a persisted selection.
556    assert_eq!(select_profile(None, true, Some("saved")), None);
557    // A persisted selection applies when nothing overrides it.
558    assert_eq!(
559      select_profile(None, false, Some("saved")),
560      Some("saved".to_string())
561    );
562    // Nothing set at all means game-directory mode.
563    assert_eq!(select_profile(None, false, None), None);
564  }
565}