Skip to main content

valheim_mod_manager/
target.rs

1use crate::error::{AppError, AppResult};
2use std::path::PathBuf;
3use thunderstore_engine::profile::layout::BadProfile;
4
5/// The Valheim community slug, which is also the engine's ecosystem game key.
6pub const GAME: &str = "valheim";
7
8/// Where this invocation installs, tracks, and launches from. The engine owns the
9/// layout; vmm adds only the wording below.
10pub type Target = thunderstore_engine::profile::target::InstallTarget;
11
12/// A phrase naming `target` for use inside a sentence, so a message can say which
13/// install root it is about without the caller branching on the mode.
14///
15/// A free function rather than a method because `Target` is a foreign type. This
16/// is the only part of the target concept that stays in vmm: it is wording, and a
17/// desktop client would write its own.
18pub fn describe(target: &Target) -> String {
19  match &target.profile {
20    Some(name) => format!("profile {name:?}"),
21    None => "the game directory".to_string(),
22  }
23}
24
25/// Resolves the install target, wording a refused profile name.
26///
27/// Every command that takes an install target goes through here, so naming a
28/// profile that does not exist reads the same whether it came from `--profile`,
29/// a persisted selection, or `vmm profile use`.
30pub fn resolve(base: PathBuf, game_dir: PathBuf, profile: Option<&str>) -> AppResult<Target> {
31  Target::resolve(base, game_dir, GAME, profile).map_err(profile_error)
32}
33
34/// vmm's wording for an engine failure that refused a profile name.
35///
36/// Only `Missing` has advice worth adding: a name that could escape the profiles
37/// directory is not one a user should be offered the chance to create, so it
38/// keeps the engine's own sentence, as does any other kind of failure.
39///
40/// Takes the whole [`thunderstore_engine::Error`] rather than a
41/// [`BadProfile`] so it can be dropped onto any engine operation that gates on a
42/// profile name, however else that operation can fail. vmm adds wording without
43/// taking over the operation.
44pub fn profile_error(error: thunderstore_engine::Error) -> AppError {
45  match error {
46    thunderstore_engine::Error::BadProfile(BadProfile::Missing { name, .. }) => {
47      missing_profile_error(&name)
48    }
49    other => other.into(),
50  }
51}
52
53/// Whether `name` can stand as a bare argument in a command a user copies.
54///
55/// [`thunderstore_engine::profile::layout::validate_name`] refuses only the
56/// names that could escape the profiles directory: empty, `.`, `..`, and
57/// anything containing a separator or a NUL. Spaces, `;`, `$`, quotes, and
58/// backticks are all legal profile names, so a name interpolated into a command
59/// string is not automatically a single argument, and is not automatically
60/// harmless either.
61///
62/// Deliberately an allowlist rather than an escaper. The characters permitted
63/// here are bare-word safe in POSIX shells, `cmd.exe`, and PowerShell alike,
64/// whereas quoting *correctly* differs between all three, and vmm has no way to
65/// know which one the message is being read in. A leading `-` is excluded on top
66/// of that, since it reads as a flag rather than a value.
67fn is_bare_shell_word(name: &str) -> bool {
68  !name.is_empty()
69    && !name.starts_with('-')
70    && name
71      .chars()
72      .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
73}
74
75/// The advice returned when a name matches no existing profile.
76///
77/// [`crate::error::advice_message`] presents its `next` entries as commands to
78/// copy, so the concrete `vmm profile create <name>` is only offered when the
79/// name survives [`is_bare_shell_word`]. Anything else degrades to the
80/// placeholder form: a name carrying a space would otherwise be pasted as two
81/// arguments, and one carrying `;` would run whatever followed it. The headline
82/// still shows the name as typed, where `{:?}` quotes and escapes it and nothing
83/// invites a paste.
84pub fn missing_profile_error(name: &str) -> AppError {
85  let create = match is_bare_shell_word(name) {
86    true => format!("vmm profile create {name}"),
87    false => "vmm profile create <name>".to_string(),
88  };
89
90  AppError::advice(
91    format!("there is no profile named {name:?}."),
92    "Nothing was changed. Create it first, or list the profiles you have.",
93    &[create.as_str(), "vmm profile list"],
94  )
95}
96
97#[cfg(test)]
98mod tests {
99  use super::*;
100  use tempfile::tempdir;
101
102  #[test]
103  fn describe_names_the_profile_or_the_game_directory() {
104    let base = tempdir().unwrap();
105    let game_dir = tempdir().unwrap();
106
107    let game = Target::resolve(
108      base.path().to_path_buf(),
109      game_dir.path().to_path_buf(),
110      GAME,
111      None,
112    )
113    .unwrap();
114
115    assert_eq!(describe(&game), "the game directory");
116
117    thunderstore_engine::profile::layout::create(base.path(), GAME, "experiment").unwrap();
118
119    let profile = Target::resolve(
120      base.path().to_path_buf(),
121      game_dir.path().to_path_buf(),
122      GAME,
123      Some("experiment"),
124    )
125    .unwrap();
126
127    assert_eq!(describe(&profile), "profile \"experiment\"");
128  }
129
130  #[test]
131  fn resolving_an_unsafe_profile_name_still_refuses() {
132    let base = tempdir().unwrap();
133    let game_dir = tempdir().unwrap();
134
135    // An unsafe name is not a profile a user could be offered the chance to
136    // create, so it keeps the engine's refusal rather than the advice above.
137    let error = resolve(
138      base.path().to_path_buf(),
139      game_dir.path().to_path_buf(),
140      Some("../escape"),
141    )
142    .unwrap_err();
143
144    assert_eq!(
145      error.to_string(),
146      "Profile error: invalid profile name \"../escape\""
147    );
148  }
149
150  #[test]
151  fn resolve_returns_a_target_for_a_profile_that_exists() {
152    let base = tempdir().unwrap();
153    let game_dir = tempdir().unwrap();
154
155    thunderstore_engine::profile::layout::create(base.path(), GAME, "experiment").unwrap();
156
157    let target = resolve(
158      base.path().to_path_buf(),
159      game_dir.path().to_path_buf(),
160      Some("experiment"),
161    )
162    .unwrap();
163
164    assert_eq!(target.profile.as_deref(), Some("experiment"));
165    assert_eq!(
166      target.dir,
167      thunderstore_engine::profile::layout::profile_dir(base.path(), GAME, "experiment")
168    );
169  }
170
171  #[test]
172  fn an_ordinary_profile_name_is_offered_as_a_command_to_copy() {
173    let message = missing_profile_error("experiment").to_string();
174
175    assert!(
176      message.contains("vmm profile create experiment"),
177      "a name that needs no quoting must stay concrete; got: {message}"
178    );
179    assert!(message.contains("vmm profile list"), "got: {message}");
180  }
181
182  #[test]
183  fn a_name_needing_quoting_is_never_offered_as_a_command_to_copy() {
184    // `validate_name` accepts every one of these: it refuses only what could
185    // escape the profiles directory. Pasted as written, the first becomes two
186    // arguments, the second runs `rm`, and the third substitutes a command.
187    for name in [
188      "work profile",
189      "main; rm -rf ~",
190      "$(whoami)",
191      "a`id`b",
192      "-all",
193    ] {
194      let message = missing_profile_error(name).to_string();
195
196      assert!(
197        message.contains("vmm profile create <name>"),
198        "{name:?} must degrade to the placeholder; got: {message}"
199      );
200      assert!(
201        !message.contains(&format!("vmm profile create {name}")),
202        "{name:?} must not appear in a copyable command; got: {message}"
203      );
204      // The headline still reports what was actually asked for, where `{:?}`
205      // quotes it and nothing suggests running it.
206      assert!(
207        message.contains("there is no profile named"),
208        "got: {message}"
209      );
210      assert!(message.contains("vmm profile list"), "got: {message}");
211    }
212  }
213}