133 lines
3.8 KiB
Rust
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}");
|
|
}
|
|
}
|
|
}
|