const SECRET_WORDS: &[&str] = &[
"key", "apikey", "sig", "pwd", "pass", "jwt", "auth", "sid", "bearer",
];
const SECRET_SUFFIXES: &[&str] = &[
"token",
"secret",
"password",
"passwd",
"signature",
"session",
"sessionid",
"sessid",
"credential",
"credentials",
"authorization",
];
const QUALIFIERS: &[&str] = &[
"value", "val", "str", "string", "id", "data", "raw", "hex", "b64", "base64", "text",
];
const CODE_GRANTS: &[&str] = &[
"auth",
"oauth",
"access",
"authorization",
"device",
"verification",
];
const NAME_BREAKS: &[char] = &['/', '?', '&', '#', '=', ';', '+', ','];
const DECODE_ROUNDS: usize = 4;
pub(crate) fn check_text(text: &str) -> Result<(), &'static str> {
for word in text.split_whitespace() {
let word = word
.trim_matches(|c: char| matches!(c, '(' | ')' | '<' | '>' | '"' | '\''))
.trim_end_matches(['.', ',', ';', ':', '!', '?']);
check_word(word)?;
}
Ok(())
}
fn check_word(word: &str) -> Result<(), &'static str> {
let decoded = decode(word)?.replace('\\', "/");
if carries_userinfo(&decoded.to_lowercase()) {
return Err("shared plans cannot carry URL credentials");
}
for piece in decoded.split(['?', '&', ';', '/', '#']) {
if let Some((name, value)) = piece.split_once('=')
&& !value.is_empty()
&& is_secret_name(name)
{
return Err("shared plans cannot carry secret parameters");
}
}
Ok(())
}
fn carries_userinfo(word: &str) -> bool {
if let Some(address) = word.strip_prefix("mailto:") {
return !is_email(address);
}
if is_email(word) || word.strip_prefix('@').is_some_and(is_email) {
return false;
}
let (has_scheme, rest) = match scheme_split(word) {
Some(rest) => (true, rest),
None => (false, word),
};
let after_slashes = rest.trim_start_matches('/');
if !has_scheme && after_slashes.len() == rest.len() {
return embedded_userinfo(word);
}
let end = after_slashes
.find(['/', '?', '#'])
.unwrap_or(after_slashes.len());
let (authority, remainder) = after_slashes.split_at(end);
authority
.rsplit_once('@')
.is_some_and(|(userinfo, _)| userinfo != "git")
|| embedded_userinfo(remainder)
}
fn embedded_userinfo(text: &str) -> bool {
text.match_indices('@').any(|(at, _)| {
let (before, after) = text.split_at(at);
let in_authority = before
.rfind("//")
.and_then(|start| before.get(start + 2..))
.is_some_and(|authority| {
!authority.is_empty() && !authority.contains(['/', '?', '#', '\\'])
});
if in_authority {
return true;
}
let name = before.rsplit(NAME_BREAKS).next().unwrap_or(before);
let after = after.get(1..).unwrap_or_default();
let end = after.find(['/', '?', '#']).unwrap_or(after.len());
let host = after.get(..end).unwrap_or(after);
let host = host.split(NAME_BREAKS).next().unwrap_or(host);
let address = !name.contains(':')
&& !host.contains(':')
&& host.contains('.')
&& !after
.get(host.len()..)
.unwrap_or_default()
.starts_with(['/', '?', '#']);
!name.is_empty() && name != "git" && is_host(host) && !address
})
}
fn scheme_split(word: &str) -> Option<&str> {
let (scheme, rest) = word.split_once(':')?;
let mut chars = scheme.chars();
let scheme_like = chars.next().is_some_and(|c| c.is_ascii_alphabetic())
&& chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'));
let port = rest.starts_with(|c: char| c.is_ascii_digit());
(scheme_like && !port).then_some(rest)
}
fn is_email(word: &str) -> bool {
let Some((local, domain)) = word.split_once('@') else {
return false;
};
!local.is_empty()
&& !local.contains([':', '/', '?', '#'])
&& !domain.contains([':', '/', '?', '#', '@', '['])
&& domain.contains('.')
&& is_host(domain)
}
fn is_host(token: &str) -> bool {
if token.starts_with('[') {
return true;
}
let (host, port) = token.split_once(':').unwrap_or((token, ""));
let dotted = host.contains('.') && host.split('.').all(|label| !label.is_empty());
let has_port = !port.is_empty() && port.chars().all(|c| c.is_ascii_digit());
let named = host == "localhost" || dotted || has_port;
!host.is_empty()
&& named
&& host
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
}
fn is_secret_name(name: &str) -> bool {
let mut words = Vec::new();
let mut current = String::new();
let mut previous_lower = false;
for c in name.chars() {
if matches!(c, '_' | '-' | '.' | ':') || (c.is_uppercase() && previous_lower) {
words.push(std::mem::take(&mut current));
}
if !matches!(c, '_' | '-' | '.' | ':') {
current.extend(c.to_lowercase());
}
previous_lower = c.is_lowercase() || c.is_ascii_digit();
}
words.push(current);
let mut words: Vec<String> = words
.iter()
.map(|word| {
word.trim_end_matches(|c: char| c.is_ascii_digit())
.to_owned()
})
.filter(|word| !word.is_empty())
.collect();
while words.len() > 1
&& words
.last()
.is_some_and(|last| QUALIFIERS.contains(&last.as_str()))
{
words.pop();
}
let Some(last) = words.last() else {
return false;
};
if last == "code" {
return words.len() == 1
|| words
.iter()
.rev()
.nth(1)
.is_some_and(|grant| CODE_GRANTS.contains(&grant.as_str()));
}
SECRET_WORDS.contains(&last.as_str())
|| SECRET_SUFFIXES.iter().any(|suffix| last.ends_with(suffix))
}
pub(crate) fn decode(text: &str) -> Result<String, &'static str> {
let mut current = text.to_owned();
for _ in 0..DECODE_ROUNDS {
let next = decode_once(¤t);
if next == current {
return Ok(current);
}
current = next;
}
Err("shared plans cannot carry repeatedly percent-encoded URL parts")
}
fn decode_once(text: &str) -> String {
let mut decoded = Vec::with_capacity(text.len());
let mut rest = text.as_bytes();
while let Some((&first, tail)) = rest.split_first() {
let escaped = tail
.get(..2)
.filter(|_| first == b'%')
.and_then(|hex| std::str::from_utf8(hex).ok())
.and_then(|hex| u8::from_str_radix(hex, 16).ok());
match escaped {
Some(byte) => {
decoded.push(byte);
rest = tail.get(2..).unwrap_or_default();
}
None => {
decoded.push(first);
rest = tail;
}
}
}
String::from_utf8_lossy(&decoded).into_owned()
}
pub(crate) fn check_remote(remote: &str) -> Result<(), &'static str> {
let remote = decode(remote)?.replace('\\', "/");
let (scheme, address) = remote.split_once("://").unwrap_or(("", &remote));
let ssh = matches!(
scheme.to_ascii_lowercase().as_str(),
"ssh" | "git+ssh" | "ssh+git"
) || (scheme.is_empty()
&& address.split_once('@').is_some_and(|(user, host)| {
!user.contains(['/', ':', '?', '#']) && host.contains(':')
}));
if ssh && let Some((user, host)) = address.split_once('@') {
if user.is_empty() || user.contains(['/', ':', '?', '#']) {
return Err("shared plans cannot carry remote credentials");
}
return check_word(&format!("ssh://git@{host}"));
}
check_word(&remote)
}