Add validate_package_name crate

This commit is contained in:
SkyfallWasTaken 2024-07-15 22:00:28 +01:00
parent 396b6d7124
commit 24c2634165
5 changed files with 299 additions and 5 deletions

24
Cargo.lock generated
View file

@ -1850,18 +1850,18 @@ dependencies = [
[[package]]
name = "thiserror"
version = "1.0.61"
version = "1.0.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c546c80d6be4bc6a00c0f01730c08df82eaa7a7a61f11d656526506112cc1709"
checksum = "f2675633b1499176c2dff06b0856a27976a8f9d436737b4cf4f312d4d91d8bbb"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.61"
version = "1.0.62"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46c3384250002a6d5af4d114f2845d37b57521033f30d5c3f46c4d70e1197533"
checksum = "d20468752b09f49e909e55a5d338caa8bedf615594e9d80bc4c565d30faf798c"
dependencies = [
"proc-macro2",
"quote",
@ -2113,12 +2113,28 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "validate_package_name"
version = "0.2.0"
dependencies = [
"lazy_static",
"regex",
"thiserror",
"urlencoding",
]
[[package]]
name = "valuable"
version = "0.1.0"

View file

@ -1,3 +1,3 @@
[workspace]
resolver = "2"
members = ["crates/*"]
members = ["crates/*"]

View file

@ -0,0 +1,10 @@
[package]
name = "validate_package_name"
version = "0.2.0"
edition = "2021"
[dependencies]
lazy_static = "1.5.0"
regex = "1.10.5"
thiserror = "1.0.62"
urlencoding = "2.1.3"

View file

@ -0,0 +1,59 @@
/// All the names **not** allowed.
#[cfg(not(tarpaulin_include))]
const BANNED_NAMES: [&str; 47] = [
"node_modules",
"favicon.ico",
// https://github.com/juliangruber/builtins/blob/main/index.js (used by npm)
"assert",
"buffer",
"child_process",
"cluster",
"console",
"constants",
"crypto",
"dgram",
"dns",
"domain",
"events",
"fs",
"http",
"https",
"module",
"net",
"os",
"path",
"punycode",
"querystring",
"readline",
"repl",
"stream",
"string_decoder",
"sys",
"timers",
"tls",
"tty",
"url",
"util",
"vm",
"zlib",
// also from npm `builtins`, but it's the version-locked modules
"freelist",
"v8",
"process",
"inspector",
"async_hooks",
"http2",
"perf_hooks",
"trace_events",
"worker_threads",
"node:test",
// also from npm `builtins`, but it's the experimental modules
"worker_threads",
"wasi",
"diagnostics_channel",
];
#[allow(clippy::ptr_arg)]
pub fn is_banned(name: &String) -> bool {
BANNED_NAMES.contains(&name.to_lowercase().as_str())
}

View file

@ -0,0 +1,209 @@
use lazy_static::lazy_static;
use regex::Regex;
use urlencoding::encode;
mod banned_names;
use banned_names::is_banned;
lazy_static! {
static ref SCOPED_PACKAGE_REGEX: Regex = Regex::new("^(?:@([^/]+?)[/])?([^/]+?)$").unwrap();
}
const MAX_LEN: usize = 214;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("package name must not be empty")]
NameEmpty,
#[error("package name cannot start with a period or underscore")]
InvalidStartingChar,
#[error("package name cannot contain leading or trailing spaces")]
LeadingOrTrailingSpaces,
#[error("package name is blacklisted or is a core module name")]
NameNotAllowed,
#[error("package name cannot contain more than 214 characters")]
NameTooLong,
#[error("package name cannot contain capital letters")]
CapsNotAllowed,
#[error("scoped package name is invalid")]
ScopedPackageNameInvalid,
#[error("package name can only contain URL-friendly characters")]
NameMustBeUrlFriendly,
}
/// Validates an `npm` package name.
///
/// ```rust
/// use validate_package_name::validate;
///
/// assert!(validate(&String::from("hello")).is_ok())
/// ```
///
/// # Errors
/// This function can fail if the package name is invalid.
pub fn validate(name: &String) -> Result<(), Error> {
if name.is_empty() {
return Err(Error::NameEmpty);
}
if name.starts_with('.') || name.starts_with('_') {
return Err(Error::InvalidStartingChar);
}
if name.trim() != name {
return Err(Error::LeadingOrTrailingSpaces);
}
if is_banned(name) {
return Err(Error::NameNotAllowed);
}
if name.len() > MAX_LEN {
return Err(Error::NameTooLong);
}
if &name.to_lowercase() != name {
return Err(Error::CapsNotAllowed);
}
if &encode(name).into_owned() != name {
let name_match = SCOPED_PACKAGE_REGEX.captures(name);
if let Some(matches) = name_match {
// The regex returns **three** matches:
//
// - the full name ("@custard/hey")
// - the user ("custard"),
// - the package ("hi")
if matches.len() != 3 || matches.get(1).is_none() || matches.get(2).is_none() {
return Err(Error::ScopedPackageNameInvalid);
}
let user = &matches[1];
let pkg = &matches[2];
if encode(user) == user && encode(pkg) == pkg {
return Ok(());
}
}
return Err(Error::NameMustBeUrlFriendly);
}
Ok(())
}
#[cfg(test)]
mod tests {
use crate::validate;
fn assert_err(name: &str) {
let is_valid = validate(&String::from(name));
if is_valid.is_ok() {
panic!("test failed: package name `{name}` passes when it shouldn't")
}
}
fn assert_ok(name: &str) {
let is_valid = validate(&String::from(name));
if let Err(e) = is_valid {
panic!("test failed: package name `{name}` fails with error: {e}")
}
}
#[test]
fn accept_valid() {
assert_ok("some-package");
assert_ok("discord.js");
assert_ok("num3ric");
assert_ok("under_scores")
}
#[test]
fn reject_special_characters() {
assert_err("crazy!");
assert_err("@npm-zors/money!time.js")
}
#[test]
fn accept_scoped() {
assert_ok("@custard/hi")
}
#[test]
fn reject_zero_len() {
assert_err("")
}
#[test]
fn reject_name_that_starts_with_period() {
assert_err(".start-with-period")
}
#[test]
fn reject_name_that_starts_with_underscore() {
assert_err("_start-with-underscore")
}
#[test]
fn reject_colons_in_name() {
assert_err("contain:colons")
}
#[test]
fn reject_leading_space() {
assert_err(" leading-space")
}
#[test]
fn reject_trailing_space() {
assert_err("trailing-space ")
}
#[test]
fn reject_non_url_friendly() {
assert_err("s/l/a/s/h/e/s")
}
#[test]
fn reject_blacklisted_name() {
assert_err("favicon.ico");
assert_err("node_modules")
}
#[test]
fn reject_core_modules() {
assert_err("http");
assert_err("process")
}
#[test]
fn accept_max_len() {
assert_ok("ifyouwanttogetthesumoftwonumberswherethosetwonumbersarechosenbyfindingthelargestoftwooutofthreenumbersandsquaringthemwhichismultiplyingthembyitselfthenyoushouldinputthreenumbersintothisfunctionanditwilldothatforyou")
}
#[test]
fn reject_mixed_case() {
assert_err("hello-WORLD")
}
#[test]
fn reject_name_over_max_len() {
assert_err("ifyouwanttogetthesumoftwonumberswherethosetwonumbersarechosenbyfindingthelargestoftwooutofthreenumbersandsquaringthemwhichismultiplyingthembyitselfthenyoushouldinputthreenumbersintothisfunctionanditwilldothatforyoucool")
}
#[test]
fn reject_empty() {
assert_err("")
}
}