use anyhow::{Context, Result, bail};
use semver::Version;
use std::ffi::OsStr;
use std::path::{Component, Path};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstallSpec {
pub name: String,
pub version: Option<Version>,
}
impl InstallSpec {
pub fn parse_all(specs: &[String]) -> Result<Vec<Self>> {
let parsed = specs
.iter()
.map(|s| Self::parse(s))
.collect::<Result<Vec<_>>>()?;
let mut seen = std::collections::HashSet::new();
for spec in &parsed {
if !seen.insert(spec.name.as_str()) {
bail!("crate `{}` is given more than once", spec.name);
}
}
Ok(parsed)
}
pub fn parse(spec: &str) -> Result<Self> {
let (name, version) = match spec.split_once('@') {
Some((name, version)) => (name, Some(version)),
None => (spec, None),
};
validate_name(name)?;
let version = match version {
None => None,
Some("") => bail!("`{spec}`: empty version after `@`"),
Some(v) => Some(Version::parse(v).with_context(|| {
format!("`{spec}`: version must be an exact semver version such as 1.2.3")
})?),
};
Ok(Self {
name: name.to_owned(),
version,
})
}
}
pub fn validate_name(name: &str) -> Result<()> {
let valid = !name.is_empty()
&& name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
&& name.chars().next().is_some_and(|c| c.is_ascii_alphabetic());
if !valid {
bail!("`{name}` is not a valid crates.io package name");
}
Ok(())
}
pub fn validate_bin_name(name: &str) -> Result<()> {
let mut components = Path::new(name).components();
match (components.next(), components.next()) {
(Some(Component::Normal(component)), None) if component == OsStr::new(name) => Ok(()),
_ => bail!("`{name}` is not a valid binary name"),
}
}
pub fn validate_bin_list(bins: &[String]) -> Result<()> {
if bins.is_empty() {
bail!("crate provides no binaries");
}
let mut seen = std::collections::BTreeSet::new();
for bin in bins {
validate_bin_name(bin)?;
if !seen.insert(bin.as_str()) {
bail!("binary `{bin}` is listed twice");
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_spec_parses_name_and_exact_version() {
let spec = InstallSpec::parse("scx_beerland@1.1.2").unwrap();
assert_eq!(spec.name, "scx_beerland");
assert_eq!(spec.version, Some(Version::parse("1.1.2").unwrap()));
let spec = InstallSpec::parse("bat").unwrap();
assert_eq!(spec.version, None);
assert!(InstallSpec::parse("foo@2.0.0-rc.1").is_ok());
}
#[test]
fn install_specs_refuse_a_crate_named_twice() {
let specs = |s: &[&str]| s.iter().map(|x| (*x).to_owned()).collect::<Vec<_>>();
for dup in [
&["foo", "foo"][..],
&["foo", "foo@1.2.3"],
&["foo@1.2.3", "foo"],
&["foo@1.2.3", "foo@1.3.0"],
&["bar", "foo@1.2.3", "baz", "foo"],
] {
let err = InstallSpec::parse_all(&specs(dup)).unwrap_err().to_string();
assert!(err.contains("`foo`"), "{dup:?}: {err}");
}
let ok = InstallSpec::parse_all(&specs(&["foo@1.2.3", "bar", "baz@0.1.0"])).unwrap();
assert_eq!(ok.len(), 3);
}
#[test]
fn install_spec_refuses_requirements_and_junk() {
for bad in [
"foo@",
"foo@^1",
"foo@~1.2",
"foo@1",
"foo@1.2",
"foo@=1.2.3",
"foo@ 1.2.3",
"@1.2.3",
"../x@1.2.3",
"foo@1.2.3@4",
] {
assert!(InstallSpec::parse(bad).is_err(), "{bad}");
}
}
#[test]
fn name_validation_matches_crates_io_rules() {
assert!(validate_name("ripgrep").is_ok());
assert!(validate_name("scx-tools").is_ok());
assert!(validate_name("serde_json").is_ok());
assert!(validate_name("b2").is_ok());
assert!(validate_name("1foo").is_err());
assert!(validate_name("_foo").is_err());
assert!(validate_name("-foo").is_err());
assert!(validate_name("../../etc").is_err());
assert!(validate_name("a/b").is_err());
assert!(validate_name("żółć").is_err());
assert!(validate_name("").is_err());
}
#[test]
fn bin_names_are_single_filenames() {
assert!(validate_bin_name("scxtop").is_ok());
assert!(validate_bin_name("cargo-watch").is_ok());
assert!(validate_bin_name("foo_bar").is_ok());
assert!(validate_bin_name("foo.exe").is_ok());
assert!(validate_bin_name("../foo").is_err());
assert!(validate_bin_name("foo/bar").is_err());
assert!(validate_bin_name("/var/tmp/foo").is_err());
assert!(validate_bin_name("/etc/something").is_err());
assert!(validate_bin_name(".").is_err());
assert!(validate_bin_name("..").is_err());
assert!(validate_bin_name("").is_err());
}
#[test]
fn bin_lists_reject_duplicates() {
let ok = vec!["foo".to_owned(), "fooctl".to_owned()];
assert!(validate_bin_list(&ok).is_ok());
let dup = vec!["foo".to_owned(), "foo".to_owned()];
let err = validate_bin_list(&dup).unwrap_err().to_string();
assert!(err.contains("listed twice"), "{err}");
let bad = vec!["../foo".to_owned()];
assert!(validate_bin_list(&bad).is_err());
assert!(validate_bin_list(&[]).is_err());
}
}