use std::path::Path;
use std::process::Command;
use once_cell::sync::Lazy;
use regex::Regex;
use crate::error::{Result, ScoopError};
static ENV_NAME_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^[a-zA-Z][a-zA-Z0-9_-]*$").expect("ENV_NAME_REGEX is a valid pattern")
});
static VERSION_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^\d+(\.\d+)*((a|b|rc)\d+)?$").expect("VERSION_REGEX is a valid pattern")
});
const RESERVED_NAMES: &[&str] = &[
"activate",
"base",
"completions",
"create",
"deactivate",
"default",
"delete",
"global",
"help",
"init",
"install",
"list",
"local",
"remove",
"resolve",
"root",
"system",
"uninstall",
"use",
"version",
"versions",
];
const MAX_ENV_NAME_LENGTH: usize = 64;
pub fn is_valid_env_name(name: &str) -> bool {
if name.is_empty() || name.len() > MAX_ENV_NAME_LENGTH {
return false;
}
if RESERVED_NAMES.contains(&name.to_lowercase().as_str()) {
return false;
}
if VERSION_REGEX.is_match(name) {
return false;
}
ENV_NAME_REGEX.is_match(name)
}
pub fn validate_env_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(ScoopError::InvalidEnvName {
name: name.to_string(),
reason: "name cannot be empty".to_string(),
});
}
if name.len() > MAX_ENV_NAME_LENGTH {
return Err(ScoopError::InvalidEnvName {
name: name.to_string(),
reason: format!("name exceeds maximum length of {MAX_ENV_NAME_LENGTH} characters"),
});
}
if RESERVED_NAMES.contains(&name.to_lowercase().as_str()) {
return Err(ScoopError::InvalidEnvName {
name: name.to_string(),
reason: "name is reserved".to_string(),
});
}
if VERSION_REGEX.is_match(name) {
return Err(ScoopError::InvalidEnvName {
name: name.to_string(),
reason: "name looks like a version string (must start with a letter)".to_string(),
});
}
if !ENV_NAME_REGEX.is_match(name) {
return Err(ScoopError::InvalidEnvName {
name: name.to_string(),
reason: "name must start with a letter and contain only letters, numbers, hyphens, and underscores".to_string(),
});
}
Ok(())
}
pub fn is_valid_python_version(version: &str) -> bool {
if version.is_empty() {
return false;
}
VERSION_REGEX.is_match(version) || version.chars().all(|c| c.is_ascii_digit() || c == '.')
}
pub fn normalize_python_version(version: &str) -> String {
version.trim().to_string()
}
pub fn validate_python_version(version: &str) -> Result<()> {
let trimmed = version.trim();
if trimmed.is_empty() {
return Err(ScoopError::InvalidPythonVersion {
version: version.to_string(),
});
}
if matches!(trimmed.to_lowercase().as_str(), "latest" | "stable") {
return Ok(());
}
if !is_valid_python_version(trimmed) {
return Err(ScoopError::InvalidPythonVersion {
version: version.to_string(),
});
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PythonVersion {
pub major: u32,
pub minor: Option<u32>,
pub patch: Option<u32>,
pub suffix: Option<String>,
}
impl PythonVersion {
pub fn parse(version: &str) -> Option<Self> {
let trimmed = version.trim();
if trimmed.is_empty() {
return None;
}
if matches!(trimmed.to_lowercase().as_str(), "latest" | "stable") {
return None;
}
let mut parts = trimmed.split('.');
let major_str = parts.next()?;
let major: u32 = major_str.parse().ok()?;
let minor = parts.next().and_then(|s| {
let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse().ok()
});
let patch = parts.next().and_then(|s| {
let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse().ok()
});
let suffix = if let Some(minor_str) = trimmed.split('.').nth(1) {
let suffix_start = minor_str.chars().position(|c| c.is_ascii_alphabetic());
suffix_start.map(|pos| minor_str[pos..].to_string())
} else {
None
};
Some(Self {
major,
minor,
patch,
suffix,
})
}
pub fn matches(&self, other: &Self) -> bool {
if self.major != other.major {
return false;
}
if let Some(minor) = self.minor {
if other.minor != Some(minor) {
return false;
}
}
if let Some(patch) = self.patch {
if other.patch != Some(patch) {
return false;
}
}
true
}
}
impl std::fmt::Display for PythonVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.major)?;
if let Some(minor) = self.minor {
write!(f, ".{minor}")?;
}
if let Some(patch) = self.patch {
write!(f, ".{patch}")?;
}
if let Some(ref suffix) = self.suffix {
write!(f, "{suffix}")?;
}
Ok(())
}
}
impl Ord for PythonVersion {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
use std::cmp::Ordering;
match self.major.cmp(&other.major) {
Ordering::Equal => {}
ord => return ord,
}
match self.minor.unwrap_or(0).cmp(&other.minor.unwrap_or(0)) {
Ordering::Equal => {}
ord => return ord,
}
match self.patch.unwrap_or(0).cmp(&other.patch.unwrap_or(0)) {
Ordering::Equal => {}
ord => return ord,
}
match (&self.suffix, &other.suffix) {
(None, None) => Ordering::Equal,
(None, Some(_)) => Ordering::Greater, (Some(_), None) => Ordering::Less,
(Some(a), Some(b)) => a.cmp(b),
}
}
}
impl PartialOrd for PythonVersion {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
pub fn is_version_alias(version: &str) -> bool {
matches!(version.trim().to_lowercase().as_str(), "latest" | "stable")
}
pub fn validate_python_path(path: &Path) -> Result<()> {
if !path.exists() {
return Err(ScoopError::InvalidPythonPath {
path: path.to_path_buf(),
reason: "file not found".to_string(),
});
}
if !path.is_file() {
return Err(ScoopError::InvalidPythonPath {
path: path.to_path_buf(),
reason: "not a file".to_string(),
});
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = std::fs::metadata(path)?;
if metadata.permissions().mode() & 0o111 == 0 {
return Err(ScoopError::InvalidPythonPath {
path: path.to_path_buf(),
reason: "not executable".to_string(),
});
}
}
Ok(())
}
pub fn detect_python_version(path: &Path) -> Option<String> {
let output = Command::new(path).arg("--version").output().ok()?;
if !output.status.success() {
return None;
}
parse_python_version_output(
&String::from_utf8_lossy(&output.stdout),
&String::from_utf8_lossy(&output.stderr),
)
}
fn parse_python_version_output(stdout: &str, stderr: &str) -> Option<String> {
let version_text = if stdout.starts_with("Python ") {
stdout
} else if stderr.starts_with("Python ") {
stderr
} else {
return None;
};
version_text
.trim()
.strip_prefix("Python ")
.map(|v| v.trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case::simple("myenv", true)]
#[case::mixed_case("MyEnv", true)]
#[case::hyphen("my-env", true)]
#[case::underscore("my_env", true)]
#[case::trailing_digits("env123", true)]
#[case::single_char("a", true)]
#[case::empty("", false)]
#[case::digit_start("123", false)]
#[case::version_like("3.12", false)]
#[case::hyphen_start("-env", false)]
#[case::underscore_start("_env", false)]
#[case::space("my env", false)]
#[case::dot("my.env", false)]
#[case::reserved_activate("activate", false)]
#[case::reserved_activate_upper("ACTIVATE", false)]
#[case::reserved_list("list", false)]
#[case::reserved_version("version", false)]
fn is_valid_env_name_cases(#[case] input: &str, #[case] expected: bool) {
assert_eq!(is_valid_env_name(input), expected);
}
#[rstest]
#[case::major_only("3", true)]
#[case::major_minor("3.12", true)]
#[case::major_minor_patch("3.12.0", true)]
#[case::alpha_suffix("3.12.0a1", true)]
#[case::empty("", false)]
#[case::non_numeric("abc", false)]
#[case::v_prefix("v3.12", false)]
#[case::hyphen_beta("3.12-beta", false)]
fn is_valid_python_version_cases(#[case] input: &str, #[case] expected: bool) {
assert_eq!(is_valid_python_version(input), expected);
}
#[test]
fn test_validate_python_version() {
assert!(validate_python_version("3.12").is_ok());
assert!(validate_python_version("3.12.0").is_ok());
assert!(validate_python_version("latest").is_ok());
assert!(validate_python_version("LATEST").is_ok());
assert!(validate_python_version("stable").is_ok());
assert!(validate_python_version("STABLE").is_ok());
assert!(validate_python_version("").is_err());
}
#[test]
fn test_python_version_parse() {
let v = PythonVersion::parse("3").unwrap();
assert_eq!(v.major, 3);
assert_eq!(v.minor, None);
assert_eq!(v.patch, None);
let v = PythonVersion::parse("3.12").unwrap();
assert_eq!(v.major, 3);
assert_eq!(v.minor, Some(12));
assert_eq!(v.patch, None);
let v = PythonVersion::parse("3.12.0").unwrap();
assert_eq!(v.major, 3);
assert_eq!(v.minor, Some(12));
assert_eq!(v.patch, Some(0));
assert!(PythonVersion::parse("latest").is_none());
assert!(PythonVersion::parse("").is_none());
}
#[test]
fn test_python_version_parse_with_suffix() {
let v = PythonVersion::parse("3.12.0a1").unwrap();
assert_eq!(v.major, 3);
assert_eq!(v.minor, Some(12));
assert_eq!(v.patch, Some(0));
let v = PythonVersion::parse("3.13a1").unwrap();
assert_eq!(v.major, 3);
assert_eq!(v.minor, Some(13));
assert!(v.suffix.is_some());
}
#[test]
fn test_python_version_matches() {
let v312 = PythonVersion::parse("3.12").unwrap();
let v3120 = PythonVersion::parse("3.12.0").unwrap();
let v3121 = PythonVersion::parse("3.12.1").unwrap();
let v311 = PythonVersion::parse("3.11").unwrap();
assert!(v312.matches(&v3120));
assert!(v312.matches(&v3121));
assert!(!v312.matches(&v311));
assert!(!v3120.matches(&v3121));
}
#[test]
fn test_python_version_display() {
assert_eq!(PythonVersion::parse("3").unwrap().to_string(), "3");
assert_eq!(PythonVersion::parse("3.12").unwrap().to_string(), "3.12");
assert_eq!(
PythonVersion::parse("3.12.0").unwrap().to_string(),
"3.12.0"
);
}
#[test]
fn test_python_version_ordering() {
let v3_9 = PythonVersion::parse("3.9").unwrap();
let v3_10 = PythonVersion::parse("3.10").unwrap();
let v3_11 = PythonVersion::parse("3.11").unwrap();
let v3_12 = PythonVersion::parse("3.12").unwrap();
let v3_12_0 = PythonVersion::parse("3.12.0").unwrap();
let v3_12_1 = PythonVersion::parse("3.12.1").unwrap();
let v3_13a1 = PythonVersion::parse("3.13a1").unwrap();
let v3_13rc1 = PythonVersion::parse("3.13rc1").unwrap();
let v3_13 = PythonVersion::parse("3.13").unwrap();
assert!(v3_9 < v3_10);
assert!(v3_10 < v3_11);
assert!(v3_11 < v3_12);
assert!(v3_12_0 < v3_12_1);
assert!(v3_13a1 < v3_13rc1);
assert!(v3_13rc1 < v3_13);
assert!(v3_13a1 < v3_13);
use std::collections::BTreeSet;
let mut versions = BTreeSet::new();
versions.insert(v3_12.clone());
versions.insert(v3_10.clone());
versions.insert(v3_11.clone());
versions.insert(v3_9.clone());
let sorted: Vec<_> = versions.iter().collect();
assert_eq!(sorted, vec![&v3_9, &v3_10, &v3_11, &v3_12]);
}
#[test]
fn test_is_version_alias() {
assert!(is_version_alias("latest"));
assert!(is_version_alias("LATEST"));
assert!(is_version_alias("stable"));
assert!(is_version_alias(" stable "));
assert!(!is_version_alias("3.12"));
assert!(!is_version_alias(""));
}
#[test]
fn test_validate_env_name_errors() {
let err = validate_env_name("").unwrap_err();
assert!(err.to_string().contains("empty"));
let err = validate_env_name("activate").unwrap_err();
assert!(err.to_string().contains("reserved"));
let err = validate_env_name("3.12").unwrap_err();
assert!(err.to_string().contains("version"));
let err = validate_env_name("-invalid").unwrap_err();
assert!(err.to_string().contains("must start with a letter"));
}
#[test]
fn test_env_name_max_length() {
let long_name = "a".repeat(64);
assert!(is_valid_env_name(&long_name));
let too_long = "a".repeat(65);
assert!(!is_valid_env_name(&too_long));
}
#[test]
fn test_env_name_boundary_values() {
assert!(is_valid_env_name("a"));
assert!(is_valid_env_name("Z"));
let just_under = "a".repeat(63);
assert!(is_valid_env_name(&just_under));
let at_max = "a".repeat(64);
assert!(is_valid_env_name(&at_max));
let just_over = "a".repeat(65);
assert!(!is_valid_env_name(&just_over));
let way_over = "a".repeat(1000);
assert!(!is_valid_env_name(&way_over));
}
#[test]
fn test_env_name_whitespace_handling() {
assert!(!is_valid_env_name(" "));
assert!(!is_valid_env_name(" "));
assert!(!is_valid_env_name("\t"));
assert!(!is_valid_env_name("\n"));
assert!(!is_valid_env_name(" \t\n "));
assert!(!is_valid_env_name(" myenv"));
assert!(!is_valid_env_name("myenv "));
assert!(!is_valid_env_name(" myenv "));
}
#[test]
fn test_python_version_boundary_values() {
assert!(is_valid_python_version("3"));
assert!(is_valid_python_version("2"));
assert!(is_valid_python_version("12"));
assert!(is_valid_python_version("99"));
assert!(is_valid_python_version("3.12.0"));
assert!(is_valid_python_version("3.12.999"));
assert!(is_valid_python_version("3.12.0a1"));
assert!(is_valid_python_version("3.12.0b99"));
assert!(is_valid_python_version("3.12.0rc1"));
}
#[test]
fn test_reserved_names_case_insensitivity() {
for reserved in ["activate", "deactivate", "list", "create", "remove", "use"] {
assert!(
!is_valid_env_name(reserved),
"lowercase '{}' should be rejected",
reserved
);
assert!(
!is_valid_env_name(&reserved.to_uppercase()),
"uppercase '{}' should be rejected",
reserved
);
let mixed: String = reserved
.chars()
.enumerate()
.map(|(i, c)| {
if i % 2 == 0 {
c.to_uppercase().next().unwrap()
} else {
c
}
})
.collect();
assert!(
!is_valid_env_name(&mixed),
"mixed case '{}' should be rejected",
mixed
);
}
}
#[test]
fn test_env_name_path_traversal_rejected() {
assert!(!is_valid_env_name("../"));
assert!(!is_valid_env_name(".."));
assert!(!is_valid_env_name("../etc"));
assert!(!is_valid_env_name("../../../etc/passwd"));
assert!(!is_valid_env_name("foo/../bar"));
assert!(!is_valid_env_name("foo/.."));
}
#[test]
fn test_env_name_hidden_file_rejected() {
assert!(!is_valid_env_name(".hidden"));
assert!(!is_valid_env_name("."));
assert!(!is_valid_env_name(".."));
assert!(!is_valid_env_name(".gitignore"));
assert!(!is_valid_env_name(".env"));
}
#[test]
fn test_env_name_absolute_path_rejected() {
assert!(!is_valid_env_name("/etc/passwd"));
assert!(!is_valid_env_name("/tmp/evil"));
assert!(!is_valid_env_name("/"));
}
#[test]
fn test_env_name_special_chars_rejected() {
assert!(!is_valid_env_name("env;ls"));
assert!(!is_valid_env_name("env|cat"));
assert!(!is_valid_env_name("env&bg"));
assert!(!is_valid_env_name("env`cmd`"));
assert!(!is_valid_env_name("env$(cmd)"));
assert!(!is_valid_env_name("env>file"));
assert!(!is_valid_env_name("env<file"));
assert!(!is_valid_env_name("env\nwhoami"));
assert!(!is_valid_env_name("env\twhoami"));
}
#[test]
fn test_env_name_null_byte_rejected() {
assert!(!is_valid_env_name("env\0hidden"));
assert!(!is_valid_env_name("\0"));
}
#[test]
fn test_env_name_unicode_rejected() {
assert!(!is_valid_env_name("еnv")); assert!(!is_valid_env_name("환경")); assert!(!is_valid_env_name("環境")); assert!(!is_valid_env_name("env™"));
assert!(!is_valid_env_name("env🐍")); }
#[test]
fn test_validate_env_name_path_traversal_error_message() {
let result = validate_env_name("../etc");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("must start with a letter"));
}
#[test]
fn validate_env_name_length_boundary() {
assert!(validate_env_name(&"a".repeat(MAX_ENV_NAME_LENGTH)).is_ok());
let too_long = validate_env_name(&"a".repeat(MAX_ENV_NAME_LENGTH + 1));
assert!(too_long.is_err());
assert!(too_long.unwrap_err().to_string().contains("maximum length"));
}
#[test]
fn normalize_python_version_trims_whitespace() {
assert_eq!(normalize_python_version(" 3.12 "), "3.12");
assert_eq!(normalize_python_version("3.12.0"), "3.12.0");
assert_eq!(normalize_python_version("\t3.13\n"), "3.13");
}
#[test]
fn is_valid_python_version_digit_dot_fallback() {
assert!(!VERSION_REGEX.is_match("3."), "precondition: regex rejects");
assert!(is_valid_python_version("3."));
assert!(is_valid_python_version("3.12.4.5"));
}
#[test]
fn validate_python_path_rejects_nonexistent() {
let err = validate_python_path(Path::new("/no/such/python")).unwrap_err();
assert!(err.to_string().contains("file not found"));
}
#[test]
fn validate_python_path_rejects_directory() {
let dir = tempfile::tempdir().unwrap();
let err = validate_python_path(dir.path()).unwrap_err();
assert!(err.to_string().contains("not a file"));
}
#[cfg(unix)]
#[test]
fn validate_python_path_rejects_non_executable_file() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("python");
std::fs::write(&file, "").unwrap();
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o644)).unwrap();
let err = validate_python_path(&file).unwrap_err();
assert!(err.to_string().contains("not executable"));
}
#[cfg(unix)]
#[test]
fn validate_python_path_accepts_executable_file() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("python");
std::fs::write(&file, "").unwrap();
std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(validate_python_path(&file).is_ok());
}
#[test]
fn parse_python_version_output_stdout() {
assert_eq!(
parse_python_version_output("Python 3.12.7\n", ""),
Some("3.12.7".to_string())
);
}
#[test]
fn parse_python_version_output_stderr_fallback() {
assert_eq!(
parse_python_version_output("", "Python 2.7.18\n"),
Some("2.7.18".to_string())
);
}
#[test]
fn parse_python_version_output_none_on_non_python() {
assert_eq!(parse_python_version_output("bash 5.2\n", ""), None);
assert_eq!(parse_python_version_output("", ""), None);
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
fn valid_env_name_strategy() -> impl Strategy<Value = String> {
"[a-zA-Z][a-zA-Z0-9_-]{0,62}".prop_filter("not reserved", |s| {
!super::RESERVED_NAMES.contains(&s.to_lowercase().as_str())
})
}
fn valid_python_version_strategy() -> impl Strategy<Value = String> {
prop_oneof![
(1u32..20).prop_map(|m| m.to_string()),
(2u32..4, 0u32..20).prop_map(|(m, n)| format!("{}.{}", m, n)),
(2u32..4, 0u32..20, 0u32..100).prop_map(|(m, n, p)| format!("{}.{}.{}", m, n, p)),
(
2u32..4,
0u32..20,
0u32..10,
prop_oneof!["a", "b", "rc"],
1u32..10
)
.prop_map(|(m, n, p, pre, num)| format!("{}.{}.{}{}{}", m, n, p, pre, num)),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(500))]
#[test]
fn prop_valid_env_names_accepted(name in valid_env_name_strategy()) {
prop_assert!(is_valid_env_name(&name), "Valid name '{}' was rejected", name);
prop_assert!(validate_env_name(&name).is_ok(), "validate_env_name failed for '{}'", name);
}
#[test]
fn prop_digit_prefix_rejected(
digit in "[0-9]",
rest in "[a-zA-Z0-9_-]{0,10}"
) {
let name = format!("{}{}", digit, rest);
prop_assert!(!is_valid_env_name(&name), "Digit-prefixed '{}' was accepted", name);
}
#[test]
fn prop_path_separators_rejected(
prefix in "[a-zA-Z]{1,5}",
suffix in "[a-zA-Z]{1,5}"
) {
let with_slash = format!("{}/{}", prefix, suffix);
let with_backslash = format!("{}\\{}", prefix, suffix);
let with_dots = format!("{}/../{}", prefix, suffix);
prop_assert!(!is_valid_env_name(&with_slash), "Path '{}' was accepted", with_slash);
prop_assert!(!is_valid_env_name(&with_backslash), "Path '{}' was accepted", with_backslash);
prop_assert!(!is_valid_env_name(&with_dots), "Path '{}' was accepted", with_dots);
}
#[test]
fn prop_valid_python_versions_accepted(version in valid_python_version_strategy()) {
prop_assert!(
is_valid_python_version(&version),
"Valid version '{}' was rejected", version
);
}
#[test]
fn prop_python_version_parse_roundtrip(
major in 2u32..4,
minor in 0u32..20,
patch in 0u32..100
) {
let version_str = format!("{}.{}.{}", major, minor, patch);
let parsed = PythonVersion::parse(&version_str);
prop_assert!(parsed.is_some(), "Failed to parse '{}'", version_str);
let pv = parsed.unwrap();
prop_assert_eq!(pv.major, major);
prop_assert_eq!(pv.minor, Some(minor));
prop_assert_eq!(pv.patch, Some(patch));
}
#[test]
fn prop_empty_always_invalid(spaces in " {0,10}") {
prop_assert!(!is_valid_env_name(&spaces));
prop_assert!(!is_valid_python_version(&spaces));
}
#[test]
fn prop_reserved_names_rejected(
reserved in prop::sample::select(super::RESERVED_NAMES.to_vec()),
upper in prop::bool::ANY
) {
let name = if upper { reserved.to_uppercase() } else { reserved.to_string() };
prop_assert!(!is_valid_env_name(&name), "Reserved '{}' was accepted", name);
}
}
}