use super::{REDACTED_MARKER, RedactionContext};
pub(super) fn redact_url_in_str(s: &str, context: &RedactionContext) -> Option<String> {
redact_url_in_str_depth(s, context, 0)
}
fn redact_url_in_str_depth(s: &str, context: &RedactionContext, depth: usize) -> Option<String> {
if !s.contains("://") || !is_single_url(s) {
return None;
}
if depth >= 16 {
return Some(REDACTED_MARKER.to_string());
}
let scheme_sep = s.find("://")?;
let scheme = &s[..scheme_sep];
let rest = &s[scheme_sep + 3..];
let mut auth_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
let first_slash = rest.find('/').unwrap_or(rest.len());
if rest[..auth_end].contains(':')
&& let Some(at) = rest[..first_slash].rfind('@')
&& at > auth_end
{
auth_end = rest[at..]
.find(['/', '?', '#'])
.map_or(rest.len(), |end| at + end);
}
let authority = &rest[..auth_end];
let remainder = &rest[auth_end..];
if !authority.contains('@') && has_non_numeric_port(authority) && remainder.contains('@') {
return Some(REDACTED_MARKER.to_string());
}
let new_authority = redact_userinfo_password(authority);
let (before_fragment, fragment) = match remainder.split_once('#') {
Some((before, fragment)) => (before, Some(fragment)),
None => (remainder, None),
};
let new_before_fragment = match before_fragment.split_once('?') {
Some((path, query)) => format!("{path}?{}", redact_query(query, context, depth)),
None => before_fragment.to_string(),
};
let new_fragment = match fragment {
Some(fragment) => {
let params = redact_query(fragment, context, depth);
match params.split_once('?') {
Some((route, query)) => {
format!("#{route}?{}", redact_query(query, context, depth))
}
None => format!("#{params}"),
}
}
None => String::new(),
};
Some(format!(
"{scheme}://{new_authority}{new_before_fragment}{new_fragment}"
))
}
pub(super) fn redact_urls_in_text_with_context(text: &str, context: &RedactionContext) -> String {
let mut output = String::with_capacity(text.len());
let mut copied_through = 0;
let mut search_from = 0;
let mut found = false;
while let Some((start, end)) = next_scheme_url_span(text, search_from) {
found = true;
output.push_str(&text[copied_through..start]);
let url = &text[start..end];
output.push_str(&redact_url_in_str(url, context).unwrap_or_else(|| url.to_string()));
copied_through = end;
search_from = end;
}
if !found {
return text.to_string();
}
output.push_str(&text[copied_through..]);
output
}
fn next_scheme_url_span(text: &str, from: usize) -> Option<(usize, usize)> {
let bytes = text.as_bytes();
let mut start = from;
while start < bytes.len() {
if start == 0 || !is_scheme_byte(bytes[start - 1]) {
let mut scheme_end = start;
while scheme_end < bytes.len() && is_scheme_byte(bytes[scheme_end]) {
scheme_end += 1;
}
let scheme_start = (start..scheme_end).find(|&i| bytes[i].is_ascii_alphabetic());
if let Some(scheme_start) = scheme_start
&& bytes
.get(scheme_end..scheme_end.saturating_add(3))
.is_some_and(|separator| separator == b"://")
{
let mut end = scheme_end + 3;
while end < bytes.len() {
let Some(character) = text[end..].chars().next() else {
break;
};
if is_span_terminator_whitespace(character) || is_text_url_delimiter(character)
{
break;
}
end += character.len_utf8();
}
while end > scheme_end + 3 {
let Some(character) = text[..end].chars().next_back() else {
break;
};
if !is_trailing_text_url_punctuation(character) {
break;
}
end -= character.len_utf8();
}
if end > scheme_end + 3 {
return Some((scheme_start, end));
}
}
}
start += 1;
}
None
}
fn is_scheme_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'-' | b'.')
}
fn is_span_terminator_whitespace(character: char) -> bool {
matches!(
character,
'\u{0009}'..='\u{000d}' | '\u{0020}'
| '\u{0085}' | '\u{00a0}' | '\u{1680}'
| '\u{2000}'..='\u{200a}'
| '\u{2028}' | '\u{2029}' | '\u{202f}' | '\u{205f}' | '\u{3000}'
)
}
fn is_text_url_delimiter(character: char) -> bool {
matches!(
character,
'"' | '\''
| '<'
| '>'
| '`'
| ','
| '。'
| ';'
| '!'
| '?'
| ')'
| '》'
| '】'
| '」'
| '』'
)
}
fn is_trailing_text_url_punctuation(character: char) -> bool {
matches!(
character,
'.' | ',' | ';' | '!' | ')' | ']' | '}' | ',' | '。' | ';' | '!' | ')' | '》' | '】'
)
}
pub(super) fn redact_url_field_value(s: &str, context: &RedactionContext) -> String {
if let Some(redacted) = redact_url_in_str(s, context) {
return redacted;
}
let trimmed = s.trim_matches(is_span_terminator_whitespace);
if trimmed != s
&& let Some(redacted) = redact_url_in_str(trimmed, context)
{
return redacted;
}
if s.chars().any(is_span_terminator_whitespace) || s.contains('@') || s.contains("://") {
return REDACTED_MARKER.to_string();
}
s.to_string()
}
fn redact_userinfo_password(authority: &str) -> String {
let Some(at) = authority.rfind('@') else {
return authority.to_string();
};
let userinfo = &authority[..at];
match userinfo.find(':') {
Some(colon) => format!(
"{}:{REDACTED_MARKER}{}",
&authority[..colon],
&authority[at..]
),
None => authority.to_string(),
}
}
fn has_non_numeric_port(authority: &str) -> bool {
match authority.rfind(':') {
Some(colon) if !authority.ends_with(']') => {
let port = &authority[colon + 1..];
!port.bytes().all(|b| b.is_ascii_digit())
}
_ => false,
}
}
fn redact_query(query: &str, context: &RedactionContext, depth: usize) -> String {
query
.split('&')
.map(|segment| {
let Some(eq) = segment.find('=') else {
return segment.to_string();
};
let raw_key = &segment[..eq];
let (name, value) = url::form_urlencoded::parse(segment.as_bytes())
.next()
.map(|(k, v)| (k.into_owned(), v.into_owned()))
.unwrap_or_default();
if context.is_secret_key(&name) {
return format!("{raw_key}={REDACTED_MARKER}");
}
let raw_value = &segment[eq + 1..];
match redact_url_in_str_depth(&value, context, depth + 1) {
Some(redacted) if redacted != value && value == raw_value => {
format!("{raw_key}={redacted}")
}
Some(redacted) if redacted != value => format!("{raw_key}={REDACTED_MARKER}"),
_ => segment.to_string(),
}
})
.collect::<Vec<_>>()
.join("&")
}
fn is_single_url(s: &str) -> bool {
if s.bytes().any(|b| b.is_ascii_whitespace() || b == 0x0b) {
return false;
}
let bytes = s.as_bytes();
if !bytes.first().is_some_and(|b| b.is_ascii_alphabetic()) {
return false;
}
let mut i = 1;
while i < bytes.len() {
let c = bytes[i];
if c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.') {
i += 1;
} else {
break;
}
}
s[i..].starts_with("://")
}