diff --git a/Cargo.lock b/Cargo.lock index a11901f..cdeca56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 85ecff1..a848b85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,3 +1,3 @@ [workspace] resolver = "2" -members = ["crates/*"] \ No newline at end of file +members = ["crates/*"] diff --git a/crates/validate_package_name/Cargo.toml b/crates/validate_package_name/Cargo.toml new file mode 100644 index 0000000..9aa70c0 --- /dev/null +++ b/crates/validate_package_name/Cargo.toml @@ -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" diff --git a/crates/validate_package_name/src/banned_names.rs b/crates/validate_package_name/src/banned_names.rs new file mode 100644 index 0000000..49e5058 --- /dev/null +++ b/crates/validate_package_name/src/banned_names.rs @@ -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()) +} diff --git a/crates/validate_package_name/src/lib.rs b/crates/validate_package_name/src/lib.rs new file mode 100644 index 0000000..6afe500 --- /dev/null +++ b/crates/validate_package_name/src/lib.rs @@ -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("") + } +}