use std::path::Path;
use std::sync::LazyLock;
use super::rules::VersionSource;
const MAX_VERSION_LEN: usize = 64;
static REGEX_CACHE: LazyLock<std::sync::RwLock<std::collections::HashMap<String, regex::Regex>>> =
LazyLock::new(|| std::sync::RwLock::new(std::collections::HashMap::new()));
const RUST_TOOLCHAIN_PATTERN: &str = r#"channel\s*=\s*"([^"]+)""#;
const GO_MOD_PATTERN: &str = r"^go\s+(\d+\.\d+(?:\.\d+)?)";
static RUST_TOOLCHAIN_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(RUST_TOOLCHAIN_PATTERN).expect("built-in rust-toolchain pattern must compile")
});
static GO_MOD_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(GO_MOD_PATTERN).expect("built-in go.mod pattern must compile")
});
fn get_or_compile_regex(pattern: &str) -> Option<std::borrow::Cow<'static, regex::Regex>> {
if pattern == RUST_TOOLCHAIN_PATTERN {
return Some(std::borrow::Cow::Borrowed(&RUST_TOOLCHAIN_RE));
}
if pattern == GO_MOD_PATTERN {
return Some(std::borrow::Cow::Borrowed(&GO_MOD_RE));
}
if let Ok(guard) = REGEX_CACHE.read() {
if let Some(re) = guard.get(pattern) {
return Some(std::borrow::Cow::Owned(re.clone()));
}
}
let compiled = regex::Regex::new(pattern).ok()?;
if let Ok(mut guard) = REGEX_CACHE.write() {
guard
.entry(pattern.to_string())
.or_insert_with(|| compiled.clone());
}
Some(std::borrow::Cow::Owned(compiled))
}
pub fn extract_version(project_dir: &Path, source: &VersionSource) -> Option<String> {
let path = project_dir.join(&source.file);
let content = std::fs::read_to_string(&path).ok()?;
let raw = if let Some(pattern) = &source.pattern {
let re = get_or_compile_regex(pattern)?;
let caps = re.captures(&content)?;
caps.get(1)?.as_str().to_string()
} else {
let stripped = content.strip_prefix('\u{feff}').unwrap_or(&content);
stripped.trim().to_string()
};
sanitize_version(raw)
}
fn sanitize_version(raw: String) -> Option<String> {
if raw.is_empty() || raw.len() > MAX_VERSION_LEN {
return None;
}
if !raw
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '~' | ':' | '-'))
{
return None;
}
Some(raw)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn pattern_extracts_first_capture() {
let tmp = tempdir().unwrap();
fs::write(
tmp.path().join("rust-toolchain.toml"),
"[toolchain]\nchannel = \"1.85.0\"",
)
.unwrap();
let src = VersionSource {
file: "rust-toolchain.toml".into(),
pattern: Some(r#"channel\s*=\s*"([^"]+)""#.into()),
};
assert_eq!(extract_version(tmp.path(), &src), Some("1.85.0".into()));
}
#[test]
fn plain_file_returns_trimmed_content() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join(".python-version"), "3.12.1\n").unwrap();
let src = VersionSource {
file: ".python-version".into(),
pattern: None,
};
assert_eq!(extract_version(tmp.path(), &src), Some("3.12.1".into()));
}
#[test]
fn missing_file_returns_none() {
let tmp = tempdir().unwrap();
let src = VersionSource {
file: ".nvmrc".into(),
pattern: None,
};
assert_eq!(extract_version(tmp.path(), &src), None);
}
#[test]
fn empty_plain_file_returns_none() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join(".nvmrc"), " \n").unwrap();
let src = VersionSource {
file: ".nvmrc".into(),
pattern: None,
};
assert_eq!(extract_version(tmp.path(), &src), None);
}
#[test]
fn invalid_regex_returns_none() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join("x"), "y").unwrap();
let src = VersionSource {
file: "x".into(),
pattern: Some(r"(unbalanced".into()),
};
assert_eq!(extract_version(tmp.path(), &src), None);
}
#[test]
fn refuses_newline_and_bracket_injection_in_plain_file() {
let tmp = tempdir().unwrap();
for hostile in [
"3.12\n[packages]\nallow_remote = true\n# ",
"3.12\"\n# closed string",
"3.12; rm -rf /",
"3.12|evil",
"[provisioner]",
] {
fs::write(tmp.path().join(".python-version"), hostile).unwrap();
let src = VersionSource {
file: ".python-version".into(),
pattern: None,
};
assert_eq!(
extract_version(tmp.path(), &src),
None,
"must refuse hostile version {hostile:?}"
);
}
}
#[test]
fn refuses_metachars_in_regex_capture() {
let tmp = tempdir().unwrap();
fs::write(
tmp.path().join("rust-toolchain.toml"),
"[toolchain]\nchannel = \"stable\n[packages]\nbad = true\"",
)
.unwrap();
let src = VersionSource {
file: "rust-toolchain.toml".into(),
pattern: Some(r#"channel\s*=\s*"([^"]+)""#.into()),
};
assert_eq!(extract_version(tmp.path(), &src), None);
}
#[test]
fn strips_utf8_bom_from_plain_version_file() {
let tmp = tempdir().unwrap();
fs::write(tmp.path().join(".nvmrc"), "\u{feff}20.0.0\n").unwrap();
let src = VersionSource {
file: ".nvmrc".into(),
pattern: None,
};
assert_eq!(extract_version(tmp.path(), &src), Some("20.0.0".into()));
}
#[test]
fn refuses_version_longer_than_cap() {
let tmp = tempdir().unwrap();
let huge = "1".repeat(MAX_VERSION_LEN + 1);
fs::write(tmp.path().join(".python-version"), &huge).unwrap();
let src = VersionSource {
file: ".python-version".into(),
pattern: None,
};
assert_eq!(extract_version(tmp.path(), &src), None);
}
#[test]
fn accepts_real_version_strings() {
let tmp = tempdir().unwrap();
for (file, content, want) in [
(".python-version", "3.12.1", "3.12.1"),
(".nvmrc", "v20.0.0", "v20.0.0"),
(".ruby-version", "3.2.2", "3.2.2"),
(".python-version", "3.13.0a1", "3.13.0a1"),
(
".tool-version",
"1.85.0-nightly+abc.123",
"1.85.0-nightly+abc.123",
),
] {
fs::write(tmp.path().join(file), content).unwrap();
let src = VersionSource {
file: file.into(),
pattern: None,
};
assert_eq!(
extract_version(tmp.path(), &src),
Some(want.into()),
"real version {content:?} must pass"
);
}
}
}