133 lines
3.8 KiB
Rust

const ALLOWED_REMOTE_URL_MESSAGE: &str =
"Repository URL must start with https://, http://, ssh://, or git@host:path.";
const HIERARCHICAL_REMOTE_SCHEMES: [&str; 3] = ["https://", "http://", "ssh://"];
pub(crate) fn validate_user_remote_url(remote_url: &str) -> Result<&str, String> {
let trimmed = remote_url.trim();
if let Some(message) = invalid_remote_url_message(trimmed) {
return Err(message.to_string());
}
if is_supported_remote_url(trimmed) {
return Ok(trimmed);
}
Err(ALLOWED_REMOTE_URL_MESSAGE.to_string())
}
fn invalid_remote_url_message(remote_url: &str) -> Option<&'static str> {
if remote_url.is_empty() {
return Some("Enter a repository URL before continuing.");
}
if remote_url.starts_with('-') {
return Some("Repository URL cannot start with '-'.");
}
if contains_unsafe_url_character(remote_url) {
return Some(ALLOWED_REMOTE_URL_MESSAGE);
}
None
}
fn contains_unsafe_url_character(remote_url: &str) -> bool {
remote_url
.chars()
.any(|ch| ch.is_control() || ch.is_whitespace())
}
fn is_supported_remote_url(remote_url: &str) -> bool {
HIERARCHICAL_REMOTE_SCHEMES
.iter()
.any(|scheme| is_hierarchical_remote_url(remote_url, scheme))
|| is_git_scp_remote_url(remote_url)
}
fn is_hierarchical_remote_url(url: &str, scheme: &str) -> bool {
let Some(rest) = strip_ascii_prefix(url, scheme) else {
return false;
};
let Some((authority, path)) = rest.split_once('/') else {
return false;
};
has_valid_remote_host(authority) && !path.is_empty()
}
fn strip_ascii_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
value
.get(..prefix.len())
.filter(|candidate| candidate.eq_ignore_ascii_case(prefix))
.map(|_| &value[prefix.len()..])
}
fn is_git_scp_remote_url(url: &str) -> bool {
let Some(rest) = url.strip_prefix("git@") else {
return false;
};
let Some((host, path)) = rest.split_once(':') else {
return false;
};
has_valid_remote_host(host) && !path.is_empty()
}
fn has_valid_remote_host(authority: &str) -> bool {
let host = host_from_authority(authority);
!host.is_empty() && !host.starts_with('-')
}
fn host_from_authority(authority: &str) -> &str {
let host = authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host);
host.split_once(':').map_or(host, |(host, _)| host)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_user_remote_url_accepts_supported_remote_forms() {
for url in [
"https://github.com/refactoringhq/tolaria.git",
"http://git.example.test/org/repo.git",
"ssh://git@git.example.test/org/repo.git",
"git@github.com:refactoringhq/tolaria.git",
] {
assert_eq!(validate_user_remote_url(url).unwrap(), url);
}
}
#[test]
fn validate_user_remote_url_trims_supported_urls() {
assert_eq!(
validate_user_remote_url(" https://github.com/refactoringhq/tolaria.git ").unwrap(),
"https://github.com/refactoringhq/tolaria.git"
);
}
#[test]
fn validate_user_remote_url_rejects_dangerous_or_unsupported_inputs() {
for url in [
"",
"--upload-pack=touch-pwned",
"ext::sh -c touch-pwned %0.git",
"file:///Users/luca/private.git",
"/Users/luca/private.git",
"github.com:refactoringhq/tolaria.git",
"git@-oProxyCommand=touch-pwned:repo.git",
"https://",
"ssh://git@example.com",
"https://github.com/refactoringhq/tolaria with space.git",
] {
assert!(validate_user_remote_url(url).is_err(), "{url}");
}
}
}