Skip to content

fix(toolchain)!: restrict named toolchain characters - #4932

Open
cachebag wants to merge 5 commits into
rust-lang:mainfrom
cachebag:main
Open

fix(toolchain)!: restrict named toolchain characters#4932
cachebag wants to merge 5 commits into
rust-lang:mainfrom
cachebag:main

Conversation

@cachebag

@cachebag cachebag commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Partially addresses #4059 by restricting what characters a named toolchain may contain.

Named toolchains are now validated against the UTS #39 general security profile.

In essence; unicode_security::GeneralSecurityProfile::identifier_allowed() vs this hand-rolled pattern we had before. Letters and digits in any script stay legal, so 合法的 still works, as do ., _ and -. Whitespace, most punctuation, emoji, and invisible or direction-altering characters are rejected. I also exclude : (NTFS alternate data streams) and ' (shell quoting), plus . and .., which the profile allows.

This is breaking for unusual names like foo#bar or names with spaces. However it doesn't touch the rust-toolchain.toml diagnostics. Confusables are also still unresolved (i.e.μ vs µ, precomposed vs decomposed é).

@cachebag

Copy link
Copy Markdown
Contributor Author

Note to myself that this will probably need some corrections once #4930 is merged

@cachebag
cachebag force-pushed the main branch 4 times, most recently from 1318f92 to 9118fcc Compare June 30, 2026 14:11
Comment thread src/toolchain/names.rs
Comment thread src/toolchain/names.rs Outdated
@rustbot

This comment has been minimized.

@rustbot

This comment has been minimized.

@rami3l

rami3l commented Jun 30, 2026

Copy link
Copy Markdown
Member

@cachebag Thanks for this PR!

I think given your comment in #4932 (comment) I'll probably review #4930 first and come back to this one later, many thanks for your understanding 🙏

Comment thread src/toolchain/names.rs Outdated
Comment thread src/toolchain/names.rs Outdated
Comment thread src/toolchain/names.rs
@cachebag
cachebag force-pushed the main branch 7 times, most recently from 86f5e64 to b8c8bb9 Compare June 30, 2026 21:07
@cachebag
cachebag requested a review from djc June 30, 2026 21:09
@rustbot

This comment has been minimized.

@rustbot

This comment has been minimized.

@cachebag
cachebag force-pushed the main branch 4 times, most recently from 913f977 to a8ed506 Compare June 30, 2026 23:07
Comment thread src/toolchain/names.rs Outdated
Comment thread src/toolchain/names.rs Outdated
Ok(custom) => Ok(Self::Custom(custom)),
Err(_) => Err(InvalidName::ToolchainName(candidate.into())),
}
let candidate = validate_named_toolchain(candidate)?;

@rami3l rami3l Jul 1, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: Given that the previous change #4930 has focused on rewriting the existing validation code from functional to imperative, suggest keeping the imperative style here to minimize the diff. Same with the other functions that you may or may not have changed in this patch.

View changes since the review

Comment thread src/toolchain/names.rs
}
}

fn validate_named_toolchain(candidate: &str) -> Result<&str, InvalidName> {

@rami3l rami3l Jul 1, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Question: what is the rationale for having two distinct functions validate() and validate_named_toolchain() in this module?

View changes since the review

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.

As touched on in #4932 (comment) - validate() is a common baseline used by both named and path-capable inputs. The latter is stricter for the purpose of the introduced allowlist

@rami3l rami3l Jul 1, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Sorry for not having expressed my intentions super clearly. My question is rather: after your changes, do the validate() function still have any reasons to exist? Are there any cases where we want the normalization without the legality check/validation?

If not or if it becomes single-used, then maybe it's better just to keep the actual "validation" function to avoid confusions.

@cachebag cachebag Jul 1, 2026

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.

Right- so validate() is still needed because ResolvableLocalToolchainName::validate uses it to handle both named toolchains and paths, so it needs the basic normalization without the character allowlist.

/// Validates if the string is a resolvable toolchain, or a path based toolchain.
fn validate(candidate: &str) -> Result<Self, InvalidName> {
let candidate = validate(candidate)?;
if let Ok(name) = ResolvableToolchainName::try_from(candidate) {
return Ok(Self::Named(name));
}
Ok(Self::Path(PathBasedToolchainName::try_from(
&PathBuf::from(candidate) as &Path,
)?))
}
}

More concretely, it's used in three places: ResolvableLocalToolchainName::validate, MaybeResolvableToolchainName::validate and validate_named_toolchain itself.

validate_named_toolchain() builds on top of it for the stricter named-only callers.

I've applied your suggestion for CustomToolchainName. It now uses validate_named_toolchain() and is_legal_named_toolchain has been inlined.

Is there something I am misunderstanding? Are you asking if we should inline validate()'s logic? If so, that repeats code more than we would be by just keeping these two functions separate.

Though maybe I am just misunderstanding your question completely, and if so then pardon me.

Comment thread src/toolchain/names.rs Outdated
}
}

fn is_legal_named_toolchain(candidate: &str) -> bool {

@rami3l rami3l Jul 1, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Question: How is it/why should it be different from validate()/validate_named_toolchain()?

View changes since the review

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.

validate() only does shared normalization/basic checks: reject +, trim trailing /, reject empty. It still allows paths and old broad custom names.

validate_named_toolchain() builds on that and rejects illegal named-toolchain characters, returning a ToolchainName error.

It feels to me like it is the reusable predicate for just the allowlist. I wrote it because CustomToolchainName::validate() needs the same allowlist but must return CustomName, not ToolchainName.

That being said the names are not great. We could probably have them be normalize_toolchain_name(), is_valid_toolchain_name and validate_toolchain_name()?

@rami3l rami3l Jul 1, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Looking at your proposed name it looks like is_valid_toolchain_name(name) can be inlined as validate_toolchain_name(name).is_ok(). Of course this is not the case currently but I can't help thinking about moving towards that direction.

Looking at the docstring of validate():

/// Common validate rules for all sorts of toolchain names

It looks like we should just do all possible verification passes in one place? Would that be possible? Something like the following, for example:

impl CustomToolchainName {
    fn validate(candidate: &str) -> Result<Self, InvalidName> {
-       let candidate = validate(candidate)?;
+       // At this point there is already no need to check for `/` and `\`.
+       let candidate = validate_toolchain_name(candidate)?;
        if candidate.parse::<PartialToolchainDesc>().is_ok()
            || candidate == "none"
-           || candidate.contains('/')
-           || candidate.contains('\\')
        {
            return Err(InvalidName::CustomName(candidate.into()))
        }
        Ok(Self(candidate.into()))
    }
}

What I am trying to say is that in that case is_valid_toolchain_name(name) is not needed anymore.

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.

Makes sense. Done.

Comment thread src/toolchain/names.rs Outdated
}
}

fn is_legal_named_toolchain(candidate: &str) -> bool {

@rami3l rami3l Jul 1, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Nit: If we decide to keep it, it looks like it should be called is_legal_toolchain_name().

View changes since the review

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.

See above responses.

I do like is_legal_toolchain_name() more than is_valid_...()

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.

Why? We have a bunch of validate_* functions to I like that is_valid_ references that more clearly than introducing a seemingly separate concept of "legality".

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.

"legal" seemed to reflect the new allowlist/policy. i'm not hard pressed on the naming convention, is_valid_ is better for your reasons, that makes sense to me from a code standpoint

@cachebag
cachebag force-pushed the main branch 2 times, most recently from 6d57895 to c1a8e40 Compare July 1, 2026 12:57
Comment thread src/toolchain/names.rs Outdated
Comment thread src/toolchain/names.rs Outdated
@cachebag
cachebag requested a review from rami3l July 1, 2026 13:10
@rami3l rami3l changed the title fix(toolchain): restrict named toolchain characters fix(toolchain)!: restrict named toolchain characters Jul 1, 2026
@cachebag
cachebag force-pushed the main branch 2 times, most recently from b1e3812 to 39fbbcc Compare July 1, 2026 23:59
@rami3l rami3l self-assigned this Jul 3, 2026
@rami3l

rami3l commented Jul 3, 2026

Copy link
Copy Markdown
Member

@cachebag Sorry for the delay in reviewing! I have to see if the current code can be further streamlined and what we should do about #4059 (comment). Please stay tuned...

@cachebag
cachebag marked this pull request as draft July 6, 2026 01:26
@cachebag
cachebag force-pushed the main branch 6 times, most recently from 8e4516c to 39f608c Compare July 13, 2026 13:01
@cachebag
cachebag force-pushed the main branch 3 times, most recently from d14a77d to 4259f6e Compare July 20, 2026 00:46
@rustbot

This comment has been minimized.

@cachebag
cachebag force-pushed the main branch 2 times, most recently from 0bc07d2 to 2eecf4a Compare August 3, 2026 13:57
Validate named toolchains with the UTS rust-lang#39 general security profile via
`unicode_security::GeneralSecurityProfile::identifier_allowed()` instead
of an ASCII allowlist, following the approach sketched in rust-lang#4059.

Letters and digits in any script are now legal, so `合法的` works as a
custom toolchain name. ASCII letters, digits, `.`, `_` and `-` remain
allowed, so every official toolchain name still parses. Whitespace, most
punctuation, emoji, and invisible or direction-altering characters such
as U+202E RIGHT-TO-LEFT OVERRIDE are still rejected.

Two characters the profile permits are excluded anyway: `:`, because a
named toolchain becomes a directory under `.rustup/toolchains` and
`name:stream` denotes an NTFS alternate data stream on Windows, and `'`,
which needs quoting in too many shells to be worth allowing. `.` and
`..` are rejected explicitly, since the profile permits both.

Confusables remain unresolved: `μ` is accepted while `µ` is not, and
precomposed and decomposed `é` are distinct names. That matches the
existing status quo.
`try_from_str!()` fed `TryFrom<String>`, `TryFrom<&str>` and `FromStr` off a
single inherent `validate()`, which is why each name type had one. With the
macro gone every such method had exactly one caller, its own `from_str()`, so
move the bodies there and drop the methods.

Rename the free `validate()` to `normalize_name()`, which is precisely what it does:
strips a `+` prefix, trims trailing slashes, and rejects empty names.
@cachebag
cachebag marked this pull request as ready for review August 3, 2026 14:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants