use std::fmt;
const MAX_LENGTH: usize = 214;
const FORBIDDEN: &[char] = &[
'~', ')', '(', '\'', '!', '*', '"', '\\', ':', '?', '#', '[', ']', '<', '>', '|', '^', '`',
'{', '}', ';', ',', '=', '&', '+', '$', '%', ' ',
];
#[derive(Clone, PartialEq, Eq, Hash, Debug)]
pub struct PackageName {
name: String,
}
impl PackageName {
pub fn parse_route(raw: &str) -> Result<PackageName, InvalidPackageName> {
if raw.is_empty() {
return Err(InvalidPackageName::Empty);
}
if raw.len() > MAX_LENGTH {
return Err(InvalidPackageName::TooLong { length: raw.len() });
}
if raw.trim() != raw {
return Err(InvalidPackageName::Surrounding);
}
let (scope, bare) = match raw.strip_prefix('@') {
Some(rest) => {
let (scope, bare) = rest
.split_once('/')
.ok_or(InvalidPackageName::IncompleteScope)?;
(Some(scope), bare)
}
None => (None, raw),
};
if let Some(scope) = scope {
check_part(scope)?;
}
check_part(bare)?;
Ok(PackageName {
name: raw.to_owned(),
})
}
pub fn as_str(&self) -> &str {
&self.name
}
pub fn upstream_segment(&self) -> &str {
&self.name
}
}
impl fmt::Display for PackageName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.name)
}
}
fn check_part(part: &str) -> Result<(), InvalidPackageName> {
if part.is_empty() {
return Err(InvalidPackageName::Empty);
}
if part.starts_with('.') || part.starts_with('_') {
return Err(InvalidPackageName::LeadingCharacter);
}
if part.contains('/') || part.contains('@') {
return Err(InvalidPackageName::Separator);
}
if let Some(bad) = part
.chars()
.find(|c| c.is_control() || FORBIDDEN.contains(c))
{
return Err(InvalidPackageName::Character(bad));
}
Ok(())
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum InvalidPackageName {
Empty,
TooLong {
length: usize,
},
Surrounding,
IncompleteScope,
Separator,
LeadingCharacter,
Character(char),
}
impl fmt::Display for InvalidPackageName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
InvalidPackageName::Empty => f.write_str("a package name cannot be empty"),
InvalidPackageName::TooLong { length } => {
write!(
f,
"a package name is at most {MAX_LENGTH} characters, this is {length}"
)
}
InvalidPackageName::Surrounding => {
f.write_str("a package name cannot begin or end with whitespace")
}
InvalidPackageName::IncompleteScope => f.write_str("a scoped name is `@scope/name`"),
InvalidPackageName::Separator => {
f.write_str("a package name holds at most one `/`, after its `@scope`")
}
InvalidPackageName::LeadingCharacter => {
f.write_str("a package name cannot begin with `.` or `_`")
}
InvalidPackageName::Character(c) => {
write!(f, "`{c}` cannot appear in a package name")
}
}
}
}
impl std::error::Error for InvalidPackageName {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_and_scoped_names_are_admitted_unchanged() {
for name in ["left-pad", "@babel/core", "Base64", "a", "lodash.merge"] {
let parsed = PackageName::parse_route(name).unwrap_or_else(|err| {
panic!("`{name}` is a real npm name, but: {err}");
});
assert_eq!(parsed.as_str(), name, "a valid name is never rewritten");
}
}
#[test]
fn hostile_route_components_are_refused() {
for name in [
"",
".",
"..",
"../etc/passwd",
"/left-pad",
"left-pad/",
"a/b/c",
"@scope",
"@/name",
"@scope/",
"https://evil.invalid/x",
"left pad",
" left-pad",
"left-pad ",
"_hidden",
".hidden",
"a@b",
"left\npad",
"left\u{0}pad",
"left%2fpad",
] {
assert!(
PackageName::parse_route(name).is_err(),
"`{name}` must not be admitted as a package name"
);
}
assert!(PackageName::parse_route(&"a".repeat(MAX_LENGTH + 1)).is_err());
assert!(PackageName::parse_route(&"a".repeat(MAX_LENGTH)).is_ok());
}
}