use super::tokens;
const CURL_LOGINS: &[&str] = &["-u", "--user", "-U", "--proxy-user"];
const CURL_MAILBOXES: &[&str] = &["--mail-from", "--mail-rcpt"];
const CURL_DATA: &[&str] = &[
"-d",
"--data",
"--data-ascii",
"--data-binary",
"--data-raw",
"--data-urlencode",
"-F",
"--form",
"--form-string",
"-H",
"--header",
];
pub(crate) fn names_no_host(argv: &[String], i: usize) -> bool {
let Some(option) = i.checked_sub(1).and_then(|j| argv.get(j)) else {
return false;
};
let value = &argv[i];
match tokens::basename(&argv[0]) {
"git" => {
option == "-c"
&& value.split_once('=').is_some_and(|(key, address)| {
key.to_ascii_lowercase().ends_with(".email") && is_mailbox(address)
})
}
"curl" => {
CURL_LOGINS.contains(&option.as_str())
|| (CURL_MAILBOXES.contains(&option.as_str())
&& is_mailbox(value.trim_start_matches('<').trim_end_matches('>')))
|| (CURL_DATA.contains(&option.as_str()) && ends_in_mailbox(value))
}
_ => false,
}
}
pub(crate) fn is_mailbox(text: &str) -> bool {
text.rsplit_once('@').is_some_and(|(local, domain)| {
!local.is_empty() && local.chars().all(is_local_char) && is_domain(domain)
})
}
fn ends_in_mailbox(text: &str) -> bool {
let text = text.trim_end_matches(['>', '"', '\'']);
text.rsplit_once('@').is_some_and(|(before, domain)| {
let local = before
.rsplit(|c: char| !is_local_char(c))
.next()
.unwrap_or("");
!local.is_empty() && is_domain(domain)
})
}
fn is_local_char(c: char) -> bool {
c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '%' | '+' | '-')
}
fn is_domain(text: &str) -> bool {
text.contains('.')
&& !text.starts_with(['.', '-'])
&& !text.ends_with(['.', '-'])
&& text
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-'))
}
#[cfg(test)]
mod tests {
use super::names_no_host;
fn argv(words: &[&str]) -> Vec<String> {
words.iter().map(|w| (*w).to_owned()).collect()
}
#[test]
fn mail_addresses_and_logins_in_their_options_name_no_host() {
for (words, i) in [
(
&[
"git",
"-c",
"user.email=1+a@users.noreply.github.com",
"push",
][..],
2,
),
(&["git", "-c", "Committer.Email=a@example.com", "push"], 2),
(
&[
"curl",
"-u",
"me@example.com:token",
"https://api.example.org",
],
2,
),
(
&[
"curl",
"--user",
"me@example.com",
"https://api.example.org",
],
2,
),
(
&[
"curl",
"--mail-rcpt",
"<a@example.com>",
"smtp://mail.example.org",
],
2,
),
(
&[
"curl",
"-d",
"email=a@example.com",
"https://api.example.org",
],
2,
),
(
&[
"curl",
"-H",
"From: a@example.com",
"https://api.example.org",
],
2,
),
(
&[
"curl",
"-F",
"to=\"a@example.com\"",
"https://api.example.org",
],
2,
),
] {
assert!(names_no_host(&argv(words), i), "{words:?}");
}
}
#[test]
fn a_word_that_is_or_may_be_a_destination_still_names_a_host() {
for (words, i) in [
(&["curl", "a=b@evil.com"][..], 1),
(&["curl", "-v", "a=b@evil.com"], 2),
(&["ssh", "-c", "me@evil.com"], 2),
(&["git", "-c", "http.proxy=me@evil.com", "push"], 2),
(
&["git", "-c", "remote.origin.url=git@evil.com:r", "push"],
2,
),
(&["git", "-c", "user.email=a@evil.com:443", "push"], 2),
(&["curl", "-d", "u=a@evil.com:443", "https://ok.example"], 2),
(
&["curl", "-d", "u=https://a@evil.com/x", "https://ok.example"],
2,
),
(&["curl", "-H", "Host: evil.com", "https://ok.example"], 2),
(
&["curl", "--mail-rcpt", "a@evil.com:25", "smtp://ok.example"],
2,
),
(&["curl", "-F", "f=@/etc/passwd", "https://ok.example"], 2),
] {
assert!(!names_no_host(&argv(words), i), "{words:?}");
}
}
}