use crate::error::{ArchToolkitError, Result};
#[must_use]
pub fn shell_single_quote(s: &str) -> String {
if s.is_empty() {
return "''".to_string();
}
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for ch in s.chars() {
if ch == '\'' {
out.push_str("'\"'\"'");
} else {
out.push(ch);
}
}
out.push('\'');
out
}
#[must_use]
pub fn is_safe_package_name(name: &str) -> bool {
let mut bytes = name.bytes();
let Some(first) = bytes.next() else {
return false;
};
if !first.is_ascii_lowercase() && !first.is_ascii_digit() {
return false;
}
bytes.all(|byte| {
byte.is_ascii_lowercase()
|| byte.is_ascii_digit()
|| matches!(byte, b'@' | b'.' | b'_' | b'+' | b'-')
})
}
pub fn validate_package_names<S: AsRef<str>>(names: &[S], context: &str) -> Result<()> {
if let Some(invalid) = names
.iter()
.find(|name| !is_safe_package_name(name.as_ref()))
{
return Err(ArchToolkitError::InvalidPackageName {
name: invalid.as_ref().to_string(),
reason: format!(
"invalid name for {context}; allowed pattern: ^[a-z0-9][a-z0-9@._+-]*$"
),
});
}
Ok(())
}
#[must_use]
pub fn command_on_path(cmd: &str) -> bool {
resolve_command_on_path(cmd).is_some()
}
#[must_use]
pub fn resolve_command_on_path(cmd: &str) -> Option<std::path::PathBuf> {
use std::path::Path;
if cmd.contains(std::path::MAIN_SEPARATOR) {
let p = Path::new(cmd);
return path_is_executable(p).then(|| p.to_path_buf());
}
let paths = std::env::var_os("PATH")?;
for dir in std::env::split_paths(&paths) {
let candidate = dir.join(cmd);
if path_is_executable(&candidate) {
return Some(candidate);
}
}
None
}
fn path_is_executable(path: &std::path::Path) -> bool {
let Ok(metadata) = std::fs::metadata(path) else {
return false;
};
if !metadata.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
metadata.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quoting() {
assert_eq!(shell_single_quote("abc"), "'abc'");
assert_eq!(shell_single_quote(""), "''");
assert_eq!(shell_single_quote("a'b"), r#"'a'"'"'b'"#);
}
#[test]
fn safe_names() {
for good in [
"ripgrep",
"gcc12+libs",
"lib32-glibc",
"python3.12",
"a@b_c",
"0ad",
] {
assert!(is_safe_package_name(good), "{good} should be valid");
}
for bad in [
"",
"Upper",
"a b",
"x;y",
"$(rm)",
"a`b`",
"name'quote",
"-S",
"--help",
"-",
".hidden",
".",
"@scoped",
"_leading",
"+plus",
] {
assert!(!is_safe_package_name(bad), "{bad} should be invalid");
}
}
#[test]
fn validation() {
assert!(validate_package_names(&["vim", "git"], "test").is_ok());
let leading = validate_package_names(&["vim", "--help"], "test install");
assert!(leading.is_err(), "leading option names must be rejected");
let err = validate_package_names(&["vim", "bad;name"], "test install")
.expect_err("should reject");
match err {
crate::error::ArchToolkitError::InvalidPackageName { name, reason } => {
assert_eq!(name, "bad;name");
assert!(reason.contains("test install"));
assert!(reason.contains("^[a-z0-9][a-z0-9@._+-]*$"));
}
other => panic!("unexpected error: {other:?}"),
}
}
#[test]
fn path_lookup() {
assert!(command_on_path("sh"));
assert!(!command_on_path("definitely-not-a-real-binary-xyz"));
}
}