Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/lang/dotnet.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,43 @@ version.
| Install location | `DOTNET_ROOT` | `installs/dotnet/<version>/` |
| Multi-targeting | Works out of the box | Requires switching versions |

## Runtime-only Installs

By default, mise installs the full .NET SDK. If you only need to _run_ .NET applications without building them and without the added overhead of the SDK, you can install just the runtime using the `runtime` inline option:

```sh
mise use dotnet[runtime=dotnet]@8.0.14
dotnet --list-runtimes
```

### Valid runtime values

| Value | Framework | Use case |
| -------------- | ---------------------------- | ------------------------ |
| dotnet | Microsoft.NETCore.App | Console apps, libraries |
| aspnetcore | Microsoft.AspNetCore.App | ASP.NET Core web apps |
| windowsdesktop | Microsoft.WindowsDesktop.App | WPF / WinForms (Windows) |

### Example: mix SDK and runtime

You can install a full SDK for development alongside a runtime for a production-like environment:

```toml
[tools]
dotnet = ["9", { version = "8.0.14", runtime = "dotnet" }]
```

::: warning

- **Version numbers are runtime versions**, not SDK versions. For example, `8.0.14` refers to .NET Runtime 8.0.14, not SDK 8.0.14. Check the [.NET release notes](https://github.com/dotnet/core/tree/main/release-notes) for available runtime versions.
- Runtime-only installs do **not** include the SDK build tools. Commands like `dotnet build` and `dotnet publish` will not be available, and `dotnet --version` will not report an SDK version.

:::

::: tip
Only exact runtime versions are supported (e.g., `dotnet[runtime=dotnet]@8.0.14`). Channel syntax like `@8` is not currently supported for runtime installs, as it resolves against SDK versions rather than runtime versions.
:::

## Environment Variables

The plugin sets the following environment variables:
Expand Down
15 changes: 15 additions & 0 deletions e2e/core/test_dotnet
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,18 @@ cat <<EOF >global.json
EOF

assert_contains "mise x -- dotnet --version" "8.0.408"

# Prepare and test environment is clean for runtime-only install tests
mise uninstall "dotnet[runtime=dotnet]@8.0.13" 2>/dev/null || true
assert_not_contains "mise x dotnet@8.0.408 -- dotnet --list-runtimes" "Microsoft.NETCore.App 8.0.13"

# Test runtime-only install: version NOT bundled by SDK 8.0.408, `--list-runtimes`
assert_contains "mise x dotnet[runtime=dotnet]@8.0.13 -- dotnet --list-runtimes" "Microsoft.NETCore.App 8.0.13"

# Test runtime uninstall cleans up shared directory
mise uninstall "dotnet[runtime=dotnet]@8.0.13"
assert_not_contains "mise x dotnet@8.0.408 -- dotnet --list-runtimes" "Microsoft.NETCore.App 8.0.13"

# Prepare by removing any stale install and test (uncached) invalid runtime option returns a validation error
mise uninstall "dotnet[runtime=invalid]@8.0.12" 2>/dev/null || mise uninstall "dotnet@8.0.12" 2>/dev/null || true
assert_fail_contains "mise x dotnet[runtime=invalid]@8.0.12 -- true" "Invalid runtime option"
23 changes: 15 additions & 8 deletions src/cli/uninstall.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,20 +125,27 @@ impl Uninstall {
.filter(|v| v.starts_with(&query))
.collect_vec(),
};
let mut tvs = matches
.into_iter()
.map(|v| {
let tvr = ToolRequest::new(backend.ba().clone(), v, ToolSource::Unknown)?;
let tv = ToolVersion::new(tvr, v.into());
Ok((backend.clone(), tv))
})
.collect::<Result<Vec<_>>>()?;

let mut tvs = Vec::new();

if let Some(tvr) = &ta.tvr {
tvs.push((
backend.clone(),
tvr.resolve(config, &Default::default()).await?,
));
}

tvs.extend(
matches
.into_iter()
.map(|v| {
let tvr = ToolRequest::new(backend.ba().clone(), v, ToolSource::Unknown)?;
let tv = ToolVersion::new(tvr, v.into());
Ok((backend.clone(), tv))
})
.collect::<Result<Vec<_>>>()?,
);

if tvs.is_empty() {
warn!(
"no versions found for {}",
Expand Down
82 changes: 70 additions & 12 deletions src/plugins/core/dotnet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ impl DotnetPlugin {
}

async fn test_dotnet(&self, ctx: &InstallContext, tv: &ToolVersion) -> Result<()> {
if tv.request.options().get("runtime").is_some() {
// Skip version check for runtime-only installs — `dotnet --version` exits non-zero without an SDK
return Ok(());
}
ctx.pr.set_message("dotnet --version".into());
CmdLineRunner::new(DOTNET_BIN)
.with_pr(ctx.pr.as_ref())
Expand Down Expand Up @@ -120,6 +124,17 @@ impl Backend for DotnetPlugin {
};
file::create_dir_all(&install_dir)?;

// Read and validate runtime options
let runtime = tv.request.options().get("runtime").map(|s| s.to_string());
if let Some(ref rt) = runtime
&& runtime_framework_name(rt).is_none()
{
return Err(eyre::eyre!(
"Invalid runtime option '{}'. Valid options: dotnet, aspnetcore, windowsdesktop",
rt
));
}

// Download install script (always refresh to pick up upstream fixes)
let script_path = install_script_path();
file::create_dir_all(script_path.parent().unwrap())?;
Expand All @@ -131,9 +146,10 @@ impl Backend for DotnetPlugin {
file::make_executable(&script_path)?;

// Run install script
let install_type = if runtime.is_some() { "Runtime" } else { "SDK" };
ctx.pr
.set_message(format!("Installing .NET SDK {}", tv.version));
install_cmd(&script_path, &install_dir, &tv.version)
.set_message(format!("Installing .NET {} {}", install_type, tv.version));
install_cmd(&script_path, &install_dir, &tv.version, runtime.as_deref())
.with_pr(ctx.pr.as_ref())
.envs(self.exec_env(&ctx.config, &ctx.ts, &tv).await?)
.execute()?;
Expand All @@ -158,10 +174,25 @@ impl Backend for DotnetPlugin {
if Self::is_isolated() {
// Isolated: mise handles removal of install_path by default
} else {
// Shared: only remove this SDK version from the shared root
let sdk_dir = dotnet_root().join("sdk").join(&tv.version);
if sdk_dir.exists() {
file::remove_all(&sdk_dir)?;
let runtime = tv.request.options().get("runtime").map(|s| s.to_string());
if let Some(rt) = runtime {
// Runtime: remove the shared runtime directory for this version
let Some(framework) = runtime_framework_name(&rt) else {
return Ok(());
};
let runtime_dir = dotnet_root()
.join("shared")
.join(framework)
.join(&tv.version);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The dotnet plugin uses the tool version string directly to construct file paths for installation and uninstallation without proper sanitization. Since the version string can be provided by a user via CLI arguments or a .mise.toml file, an attacker can provide a malicious version string containing path traversal characters (e.g., ..) to cause mise to create or delete arbitrary files or directories on the system.

For example, during uninstallation, if tv.version is .., the constructed runtime_dir or sdk_dir could resolve to the parent directory (e.g., the entire .dotnet-root directory), leading to unintended mass deletion of files when file::remove_all is called. Combined with more traversal components, this could target sensitive system files.

To remediate this, ensure the version string is sanitized to remove or reject path traversal characters before using it in path construction, or validate it against an expected version format.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This mirrors the existing SDK uninstall pattern that was already here before this PR. The version string comes from Microsoft's API or from the user's own config — same trust level as running any shell command. Proper version sanitization should be a separate effort at a higher level in mise, not patched into individual backends.

This comment was generated by Claude Code.

if runtime_dir.exists() {
file::remove_all(&runtime_dir)?;
}
} else {
// SDK: only remove this SDK version from the shared root
let sdk_dir = dotnet_root().join("sdk").join(&tv.version);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Similar to the runtime uninstallation logic, the SDK uninstallation logic uses tv.version unsafely to construct a path for deletion. This allows an attacker to trigger arbitrary file or directory deletion via path traversal in the version string.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing code, not introduced by this PR. Agreed that centralized version sanitization would be a good follow-up.

This comment was generated by Claude Code.

if sdk_dir.exists() {
file::remove_all(&sdk_dir)?;
}
}
}
Ok(())
Expand Down Expand Up @@ -207,6 +238,15 @@ impl Backend for DotnetPlugin {
}
}

fn runtime_framework_name(runtime: &str) -> Option<&'static str> {
match runtime {
"dotnet" => Some("Microsoft.NETCore.App"),
"aspnetcore" => Some("Microsoft.AspNetCore.App"),
"windowsdesktop" => Some("Microsoft.WindowsDesktop.App"),
_ => None,
}
}

fn dotnet_root() -> PathBuf {
Settings::get()
.dotnet
Expand Down Expand Up @@ -243,18 +283,32 @@ fn install_script_url() -> &'static str {
}

#[cfg(unix)]
fn install_cmd<'a>(script_path: &Path, install_dir: &Path, version: &str) -> CmdLineRunner<'a> {
CmdLineRunner::new(script_path)
fn install_cmd<'a>(
script_path: &Path,
install_dir: &Path,
version: &str,
runtime: Option<&str>,
) -> CmdLineRunner<'a> {
let mut cmd = CmdLineRunner::new(script_path)
.arg("--install-dir")
.arg(install_dir)
.arg("--version")
.arg(version)
.arg("--no-path")
.arg("--no-path");
if let Some(rt) = runtime {
cmd = cmd.arg("--runtime").arg(rt);
}
cmd
}

#[cfg(windows)]
fn install_cmd<'a>(script_path: &Path, install_dir: &Path, version: &str) -> CmdLineRunner<'a> {
CmdLineRunner::new("powershell")
fn install_cmd<'a>(
script_path: &Path,
install_dir: &Path,
version: &str,
runtime: Option<&str>,
) -> CmdLineRunner<'a> {
let mut cmd = CmdLineRunner::new("powershell")
.arg("-ExecutionPolicy")
.arg("Bypass")
.arg("-File")
Expand All @@ -263,7 +317,11 @@ fn install_cmd<'a>(script_path: &Path, install_dir: &Path, version: &str) -> Cmd
.arg(install_dir)
.arg("-Version")
.arg(version)
.arg("-NoPath")
.arg("-NoPath");
if let Some(rt) = runtime {
cmd = cmd.arg("-Runtime").arg(rt);
}
cmd
}

// --- Microsoft releases API types ---
Expand Down
Loading