use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationError {
pub input: String,
pub reason: &'static str,
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"invalid {input:?}: {reason}",
input = self.input,
reason = self.reason
)
}
}
impl std::error::Error for ValidationError {}
pub fn validate_package_name(name: &str) -> Result<(), ValidationError> {
let reject = |reason: &'static str| ValidationError {
input: name.to_owned(),
reason,
};
if name.is_empty() {
return Err(reject("name must not be empty"));
}
if name == "." || name == ".." {
return Err(reject("name must not be `.` or `..`"));
}
if name.starts_with('.') {
return Err(reject("name must not start with `.`"));
}
if name.contains("..") {
return Err(reject("name must not contain `..`"));
}
for c in name.chars() {
if c == '/' || c == '\\' {
return Err(reject("name must not contain `/` or `\\`"));
}
if c.is_control() {
return Err(reject("name must not contain control characters"));
}
}
Ok(())
}
pub const MAX_VERSION_LEN: usize = 64;
pub fn validate_version(version: &str) -> Result<(), ValidationError> {
let reject = |reason: &'static str| ValidationError {
input: version.to_owned(),
reason,
};
if version.is_empty() {
return Err(reject("version must not be empty"));
}
if version.len() > MAX_VERSION_LEN {
return Err(reject("version exceeds 64-character limit"));
}
if version == "." || version == ".." {
return Err(reject("version must not be `.` or `..`"));
}
if version.starts_with('.') {
return Err(reject("version must not start with `.`"));
}
if version.starts_with('-') {
return Err(reject("version must not start with `-`"));
}
if version.contains("..") {
return Err(reject("version must not contain `..`"));
}
for c in version.chars() {
let ok = c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-');
if !ok {
return Err(reject(
"version must match [A-Za-z0-9._+-] (semver-friendly set)",
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_accepts_plain() {
assert!(validate_package_name("foo").is_ok());
assert!(validate_package_name("foo-bar_baz.qux").is_ok());
}
#[test]
fn name_rejects_double_dot() {
assert_eq!(
validate_package_name("..").unwrap_err().reason,
"name must not be `.` or `..`"
);
}
#[test]
fn name_rejects_embedded_traversal() {
assert!(validate_package_name("foo..bar").is_err());
assert!(validate_package_name("foo/../bar").is_err());
}
#[test]
fn name_rejects_leading_dot_and_slash() {
assert!(validate_package_name(".hidden").is_err());
assert!(validate_package_name("a/b").is_err());
assert!(validate_package_name("a\\b").is_err());
}
#[test]
fn version_accepts_exact_semver() {
assert!(validate_version("0.1.0").is_ok());
assert!(validate_version("1.0.0-rc.1").is_ok());
assert!(validate_version("1.0.0+build.7").is_ok());
assert!(validate_version("v1.0.0").is_ok());
assert!(validate_version("0.1.0-alpha.2").is_ok());
}
#[test]
fn version_rejects_empty_and_double_dot() {
assert_eq!(
validate_version("").unwrap_err().reason,
"version must not be empty"
);
assert_eq!(
validate_version("..").unwrap_err().reason,
"version must not be `.` or `..`"
);
assert!(validate_version("../etc").is_err());
assert!(validate_version("1..0").is_err());
}
#[test]
fn version_rejects_leading_dot_or_dash() {
assert!(validate_version(".5").is_err());
assert!(validate_version("-1.0.0").is_err());
}
#[test]
fn version_rejects_disallowed_chars() {
assert!(validate_version("1.0.0 ").is_err());
assert!(validate_version("1.0.0/x").is_err());
assert!(validate_version("1.0.0%2F").is_err());
assert!(validate_version("1.0.0~rc").is_err());
assert!(validate_version("1.0.0\n").is_err());
}
#[test]
fn version_rejects_overlong_input() {
let too_long: String = "1".repeat(MAX_VERSION_LEN + 1);
assert_eq!(
validate_version(&too_long).unwrap_err().reason,
"version exceeds 64-character limit"
);
let at_cap: String = "1".repeat(MAX_VERSION_LEN);
assert!(validate_version(&at_cap).is_ok());
}
}