pub(crate) fn split_host_port(input: &str) -> Result<(&str, Option<u16>), String> {
if input.starts_with('[') {
let Some(end) = input.find(']') else {
return Err("unbalanced '[' in IPv6 literal".to_owned());
};
let host = &input[..=end];
let rest = &input[end + 1..];
if rest.is_empty() {
return Ok((host, None));
}
let Some(port) = rest.strip_prefix(':') else {
return Err("unexpected trailing text after IPv6 literal".to_owned());
};
return Ok((host, parse_port(port)?));
}
match input.rfind(':') {
Some(idx) if !input[..idx].contains(':') => {
Ok((&input[..idx], parse_port(&input[idx + 1..])?))
}
_ => Ok((input, None)),
}
}
fn parse_port(text: &str) -> Result<Option<u16>, String> {
if text.is_empty() {
return Ok(None);
}
port_from_digits(text)
.map(Some)
.ok_or_else(|| invalid_port_reason(text))
}
pub(crate) fn port_from_digits(text: &str) -> Option<u16> {
if text.is_empty() || !text.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
text.parse::<u16>().ok()
}
fn invalid_port_reason(text: &str) -> String {
let len = text.chars().count();
let digits = text.chars().filter(char::is_ascii_digit).count();
if digits == len {
format!("invalid port: {len}-digit number is out of the 0-65535 range")
} else {
format!(
"invalid port: {len} character{} ({digits} ASCII digit{}, {} other), expected only ASCII digits 0-9",
if len == 1 { "" } else { "s" },
if digits == 1 { "" } else { "s" },
len - digits,
)
}
}
pub(crate) fn quote_if_not_credential_shaped(text: &str) -> String {
if userinfo_delimiter_end(text).is_none() {
return format!("{text:?}");
}
withheld_description(text)
}
fn withheld_description(text: &str) -> String {
let len = text.chars().count();
format!("<{len} characters, withheld: it may hold a user:password fragment>")
}
pub(crate) fn strip_brackets(host: &str) -> &str {
host.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))
.unwrap_or(host)
}
pub(crate) fn glob_match(pattern: &str, text: &str) -> bool {
let Some((first, after_first)) = pattern.split_once('*') else {
return pattern == text;
};
let (middle, last) = after_first.rsplit_once('*').unwrap_or(("", after_first));
let Some(mut rest) = text.strip_prefix(first) else {
return false;
};
for part in middle.split('*') {
match rest.find(part) {
Some(idx) => rest = &rest[idx + part.len()..],
None => return false,
}
}
rest.ends_with(last)
}
pub(crate) fn percent_decode(input: &str) -> String {
if !input.contains('%') {
return input.to_owned();
}
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
let hi = (bytes[i + 1] as char).to_digit(16);
let lo = (bytes[i + 2] as char).to_digit(16);
if let (Some(hi), Some(lo)) = (hi, lo) {
out.push((hi * 16 + lo) as u8);
i += 3;
continue;
}
}
out.push(bytes[i]);
i += 1;
}
String::from_utf8(out).unwrap_or_else(|_| input.to_owned())
}
pub(crate) const MASK: &str = "***";
pub(crate) fn redact_userinfo(input: &str) -> std::borrow::Cow<'_, str> {
if !input.contains('@') {
return std::borrow::Cow::Borrowed(input);
}
let mut out = String::with_capacity(input.len());
let mut copied_up_to = 0usize;
let bytes = input.as_bytes();
let mut at_positions: Vec<usize> = Vec::new();
let mut boundaries: Vec<usize> = Vec::new();
let mut colons: Vec<usize> = Vec::new();
let mut encoded_colons: Vec<usize> = Vec::new();
let mut schemes: Vec<usize> = Vec::new();
for (i, &byte) in bytes.iter().enumerate() {
match byte {
b'@' => at_positions.push(i),
b' ' | b'\t' | b'\n' => boundaries.push(i),
b':' => {
colons.push(i);
if bytes.get(i + 1) == Some(&b'/') && bytes.get(i + 2) == Some(&b'/') {
schemes.push(i);
}
}
b'%' if bytes.get(i + 1) == Some(&b'3')
&& bytes
.get(i + 2)
.is_some_and(|h| h.eq_ignore_ascii_case(&b'a')) =>
{
encoded_colons.push(i);
}
_ => {}
}
}
let first_delimiter_end = |from: usize, upto: usize| -> Option<usize> {
let literal = colons
.get(colons.partition_point(|&c| c < from))
.copied()
.filter(|&c| c < upto);
let encoded = encoded_colons
.get(encoded_colons.partition_point(|&e| e < from))
.copied()
.filter(|&e| e + 3 <= upto);
match (literal, encoded) {
(Some(literal), Some(encoded)) => Some(if literal <= encoded {
literal + 1
} else {
encoded + 3
}),
(Some(literal), None) => Some(literal + 1),
(None, Some(encoded)) => Some(encoded + 3),
(None, None) => None,
}
};
for (i, &at) in at_positions.iter().enumerate() {
if let Some(&next) = at_positions.get(i + 1) {
let between = &input[at + 1..next];
if !between
.bytes()
.any(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\r'))
&& !between.contains("//")
{
continue;
}
}
let crossed = boundaries.partition_point(|&b| b < at);
let after_marker = if crossed == 0 {
0
} else {
boundaries[crossed - 1] + 1
};
let base = after_marker.max(copied_up_to);
let segment_start = schemes
.get(schemes.partition_point(|&s| s < base))
.copied()
.filter(|&s| s + 3 <= at && first_delimiter_end(base, s).is_none())
.map_or(base, |s| s + 3);
if let Some(delimiter_end) = first_delimiter_end(segment_start, at) {
out.push_str(&input[copied_up_to..delimiter_end]);
out.push_str(MASK);
copied_up_to = at;
}
}
out.push_str(&input[copied_up_to..]);
std::borrow::Cow::Owned(out)
}
pub(crate) fn redact_offending_token(input: &str) -> String {
let masked = redact_userinfo(input);
if stranded_userinfo_shaped(&masked) || residual_credential_shaped(&masked) {
return withheld_description(&masked);
}
masked.into_owned()
}
fn residual_credential_shaped(token: &str) -> bool {
if mask_boundary_stranded_a_tail(token) {
return true;
}
token.split([' ', '\t', '\n']).any(|piece| {
let Some((before_at, _)) = piece.rsplit_once('@') else {
return stranded_userinfo_shaped(piece);
};
let userinfo = after_scheme(before_at);
userinfo_delimiter_end(userinfo).is_some_and(|end| &userinfo[end..] != MASK)
&& stranded_userinfo_shaped(before_at)
})
}
fn mask_boundary_stranded_a_tail(token: &str) -> bool {
let mut past_a_boundary = false;
for piece in token.split([' ', '\t', '\n']) {
if let Some((before_at, _)) = piece.rsplit_once('@')
&& past_a_boundary
&& userinfo_delimiter_end(after_scheme(before_at)).is_none()
{
return true;
}
past_a_boundary |= carries_a_mask_boundary(piece);
}
false
}
fn carries_a_mask_boundary(piece: &str) -> bool {
piece.match_indices("***@").any(|(at, _)| {
match piece.as_bytes()[..at] {
[.., b'%', b'3', hex] if hex.eq_ignore_ascii_case(&b'a') => true,
[.., b':'] => true,
_ => false,
}
})
}
fn stranded_userinfo_shaped(token: &str) -> bool {
let rest = after_scheme(token);
let rest = rest
.rsplit_once('@')
.map_or(rest, |(_, host_port)| host_port);
let head = rest.split(['/', '?', '#']).next().unwrap_or(rest);
let rest = if head.is_empty() { rest } else { head }.trim();
if rest.parse::<std::net::Ipv6Addr>().is_ok() {
return false;
}
let host = match rest.rsplit_once(':') {
Some((head, port)) => {
let digits = port.strip_prefix('-').unwrap_or(port);
if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) {
head
} else {
rest
}
}
None => rest,
};
let host = strip_brackets(host);
if host.parse::<std::net::Ipv6Addr>().is_ok() {
return false;
}
host.contains(':') || encoded_colon_at(host).is_some()
}
pub(crate) fn userinfo_delimiter_end(segment: &str) -> Option<usize> {
let literal = segment.find(':').map(|at| (at, at + 1));
let encoded = encoded_colon_at(segment).map(|at| (at, at + 3));
match (literal, encoded) {
(Some(literal), Some(encoded)) => Some(if literal.0 <= encoded.0 {
literal.1
} else {
encoded.1
}),
(found, None) | (None, found) => found.map(|(_, end)| end),
}
}
fn after_scheme(token: &str) -> &str {
scheme_delimiter_end(token).map_or(token, |end| &token[end..])
}
fn scheme_delimiter_end(token: &str) -> Option<usize> {
let at = token.find("://")?;
userinfo_delimiter_end(&token[..at])
.is_none()
.then_some(at + 3)
}
fn encoded_colon_at(text: &str) -> Option<usize> {
let bytes = text.as_bytes();
(0..bytes.len().saturating_sub(2)).find(|&at| {
bytes[at] == b'%' && bytes[at + 1] == b'3' && bytes[at + 2].eq_ignore_ascii_case(&b'a')
})
}
#[cfg_attr(
not(any(
pac_quickjs,
feature = "tracing",
all(target_os = "linux", feature = "linux-gnome")
)),
allow(dead_code)
)]
pub(crate) const MAX_UNTRUSTED: usize = 256;
#[cfg_attr(
not(any(
pac_quickjs,
feature = "tracing",
all(target_os = "linux", feature = "linux-gnome")
)),
allow(dead_code)
)]
pub(crate) fn sanitized_char(c: char) -> char {
if c.is_control() { '.' } else { c }
}
#[cfg_attr(
not(any(
pac_quickjs,
feature = "tracing",
all(target_os = "linux", feature = "linux-gnome")
)),
allow(dead_code)
)]
pub(crate) fn sanitize_untrusted(input: &str) -> String {
let mut out = String::with_capacity(input.len().min(MAX_UNTRUSTED));
for character in input.chars().take(MAX_UNTRUSTED) {
out.push(sanitized_char(character));
}
if input.chars().nth(MAX_UNTRUSTED).is_some() {
out.push('…');
}
out
}
#[cfg_attr(
not(any(feature = "pac", all(target_os = "linux", feature = "linux-gnome"))),
allow(dead_code)
)]
pub(crate) fn redact_and_sanitize_untrusted(input: &str) -> String {
let controls_replaced: String = input.chars().map(sanitized_char).collect();
sanitize_untrusted(&redact_userinfo(&controls_replaced))
}
pub(crate) fn fnv1a(bytes: &[u8]) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
for byte in bytes {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_host_port_cases() {
assert_eq!(split_host_port("h:80"), Ok(("h", Some(80))));
assert_eq!(split_host_port("h:"), Ok(("h", None)));
assert_eq!(split_host_port("h"), Ok(("h", None)));
assert_eq!(split_host_port("[::1]:8080"), Ok(("[::1]", Some(8080))));
assert_eq!(split_host_port("[::1]"), Ok(("[::1]", None)));
assert_eq!(split_host_port("::1"), Ok(("::1", None)));
assert!(split_host_port("[::1").is_err());
assert!(split_host_port("h:99999").is_err());
assert!(split_host_port("h:abc").is_err());
}
#[test]
fn a_signed_port_is_not_a_port_but_a_zero_padded_one_is() {
assert!(split_host_port("h:+80").is_err());
assert!(split_host_port("h:-80").is_err());
assert_eq!(split_host_port("h:080"), Ok(("h", Some(80))));
assert_eq!(split_host_port("h:00000"), Ok(("h", Some(0))));
}
#[test]
fn invalid_port_reason_never_leaks_fragments_and_still_diagnoses() {
let secret = "secr3t-must-never-appear";
let at_secret = "hunter2";
let reason = split_host_port(&format!("bob:{secret}")).unwrap_err();
assert!(!reason.contains(secret), "{reason}");
assert!(reason.contains("character"), "{reason}");
let reason = split_host_port(&format!("alice:{at_secret}@proxy.example")).unwrap_err();
assert!(!reason.contains(at_secret), "{reason}");
assert!(!reason.contains('@'), "{reason}");
for (input, must_not_contain, must_contain) in [
(
"h:99999999999999999999",
&["99999999999999999999"][..],
&["20-digit", "out of the 0-65535 range"][..],
),
(
"h:abc",
&[][..],
&["3 characters", "0 ASCII digits", "3 other"][..],
),
] {
let reason = split_host_port(input).unwrap_err();
for fragment in must_not_contain {
assert!(!reason.contains(fragment), "{reason}");
}
for fragment in must_contain {
assert!(reason.contains(fragment), "{reason}");
}
}
}
#[test]
fn glob_cases() {
assert!(glob_match("*", "anything"));
assert!(glob_match("192.168.*", "192.168.0.1"));
assert!(!glob_match("192.168.*", "10.0.0.1"));
assert!(glob_match("www.*.com", "www.example.com"));
assert!(!glob_match("www.*.com", "www.example.org"));
assert!(glob_match("exact", "exact"));
assert!(!glob_match("exact", "exacts"));
}
#[test]
fn percent_decode_cases() {
assert_eq!(percent_decode("plain"), "plain");
assert_eq!(percent_decode("p%40ss"), "p@ss");
assert_eq!(percent_decode("100%"), "100%");
assert_eq!(percent_decode("%4"), "%4");
assert_eq!(percent_decode("p%4"), "p%4");
assert_eq!(percent_decode("%zz"), "%zz");
assert_eq!(percent_decode("p%FFss"), "p%FFss");
assert_eq!(percent_decode("%E3%81%82"), "あ");
}
#[test]
fn redact_userinfo_without_at_sign_is_borrowed_unchanged() {
let input = "http://proxy.example:8080/pac.js";
match redact_userinfo(input) {
std::borrow::Cow::Borrowed(s) => assert_eq!(s, input),
std::borrow::Cow::Owned(s) => panic!("expected a borrow, got an allocation: {s:?}"),
}
}
#[test]
fn redact_userinfo_masks_a_scheme_prefixed_url() {
assert_eq!(
redact_userinfo("http://alice:hunter2@proxy.corp:8080"),
"http://alice:***@proxy.corp:8080"
);
}
#[test]
fn redact_userinfo_masks_without_a_scheme() {
assert_eq!(
redact_userinfo("alice:hunter2@proxy.corp:8080"),
"alice:***@proxy.corp:8080"
);
}
#[test]
fn redact_userinfo_masks_a_password_holding_a_scheme_delimiter() {
for (input, expected) in [
(
"http://alice:aa://bb@proxy.corp:8080",
"http://alice:***@proxy.corp:8080",
),
("alice:aa://bb@proxy.corp:8080", "alice:***@proxy.corp:8080"),
(
"http://alice:aa://bb@cc@proxy.corp:8080",
"http://alice:***@proxy.corp:8080",
),
] {
assert_eq!(redact_userinfo(input), expected, "{input}");
assert_eq!(redact_offending_token(input), expected, "{input}");
}
}
#[test]
fn redact_userinfo_masks_a_scheme_bearing_address_after_a_credential_fragment() {
assert_eq!(
redact_userinfo("alice:pw http://bob:secret@proxy.corp:8080"),
"alice:pw http://bob:***@proxy.corp:8080"
);
}
#[test]
fn redact_userinfo_masks_a_password_holding_a_double_slash() {
assert_eq!(
redact_userinfo("http://alice:aa//bb@proxy.corp:8080"),
"http://alice:***@proxy.corp:8080"
);
}
#[test]
fn redact_offending_token_withholds_a_stranded_user_password_fragment() {
for input in [
"http://[bob:hunter2]:8080",
"http://bob:hunter2",
"bob:hunter2:more",
] {
let masked = redact_offending_token(input);
assert!(!masked.contains("hunter2"), "{masked}");
assert!(masked.contains("withheld"), "{masked}");
}
}
#[test]
fn redact_offending_token_withholds_when_a_password_holds_a_boundary() {
for (input, secret) in [
("alice:aa://bb", "aa://bb"),
("http://alice:my pass@proxy.corp:8080", "my pass"),
("//alice:my pass@proxy.corp:8080", "my pass"),
("//alice:hunter2", "hunter2"),
("alice%3Ahunter2", "hunter2"),
] {
let masked = redact_offending_token(input);
assert!(!masked.contains(secret), "{masked}");
assert!(masked.contains("withheld"), "{masked}");
}
}
#[test]
fn redact_offending_token_still_names_an_ordinary_address() {
for input in [
"http://proxy.corp:99999",
"https=h:-1",
"http://[::1]:8080",
"proxy.corp",
"::1",
"//proxy.corp:8080",
"/pac.js",
"10.0.0.0/8",
"bob@example.com",
] {
assert_eq!(redact_offending_token(input), input);
}
assert_eq!(
redact_offending_token("http://alice:hunter2@proxy.corp:99999"),
"http://alice:***@proxy.corp:99999"
);
}
#[test]
fn redact_userinfo_leaves_a_colon_less_userinfo_alone() {
let input = "alice@proxy.corp:8080";
assert_eq!(redact_userinfo(input), input);
}
#[test]
fn redact_userinfo_masks_every_occurrence() {
let input = "http://alice:hunter2@proxy.corp/x?next=http://bob:swordfish@evil.example";
assert_eq!(
redact_userinfo(input),
"http://alice:***@proxy.corp/x?next=http://bob:***@evil.example"
);
}
#[test]
fn redact_userinfo_masks_space_separated_occurrences() {
let input = "alice:hunter2@proxy1 bob:swordfish@proxy2";
assert_eq!(redact_userinfo(input), "alice:***@proxy1 bob:***@proxy2");
}
#[test]
fn redact_userinfo_reads_a_carriage_return_as_a_separator_too() {
let input = "alice:hunter2@proxy1\rbob:swordfish@proxy2";
assert_eq!(redact_userinfo(input), "alice:***@proxy1\rbob:***@proxy2");
assert_eq!(redact_userinfo("alice:pa\rss@host"), "alice:***@host");
}
#[test]
fn redact_userinfo_masks_password_delimiters() {
for (input, expected) in [
("http://alice:hun/ter2@host", "http://alice:***@host"),
("http://alice:hun?ter2@host", "http://alice:***@host"),
("http://alice:hun&ter2@host", "http://alice:***@host"),
("http://alice:hun#ter2@host", "http://alice:***@host"),
("http://alice:aa//bb@host", "http://alice:***@host"),
(
"alice:hun/te?r&2#x@proxy.corp:8080",
"alice:***@proxy.corp:8080",
),
] {
assert_eq!(redact_userinfo(input), expected, "input = {input:?}");
}
}
#[test]
fn redact_offending_token_withholds_whitespace_inside_a_password() {
for input in [
"http://alice:my pass@proxy.corp:8080",
"http://alice:aa\tbb@proxy.corp:8080",
] {
let masked = redact_offending_token(input);
assert!(masked.contains("withheld"), "{masked}");
assert!(
!masked.contains('@') && !masked.contains("alice:"),
"the raw credential survived: {masked}"
);
}
}
#[test]
fn redact_offending_token_withholds_a_password_holding_both_an_at_sign_and_a_space() {
let masked = redact_offending_token("http://alice:se@cret pw@proxy.corp:8080");
assert!(!masked.contains("cret pw"), "{masked}");
assert!(masked.contains("withheld"), "{masked}");
}
#[test]
fn redact_offending_token_still_names_two_credentials_separated_by_a_space() {
assert_eq!(
redact_offending_token("alice:hunter2@proxy1 bob:swordfish@proxy2"),
"alice:***@proxy1 bob:***@proxy2"
);
}
#[test]
fn redact_offending_token_still_names_an_address_whose_host_holds_a_space() {
assert_eq!(
redact_offending_token("http://alice:hunter2@bad host:8080"),
"http://alice:***@bad host:8080"
);
}
#[test]
fn redact_offending_token_withholds_both_spellings_of_a_trailing_delimiter() {
assert_eq!(
redact_offending_token("bob::8080"),
withheld_description("bob::8080")
);
assert_eq!(
redact_offending_token("bob%3A:8080"),
withheld_description("bob%3A:8080")
);
}
#[test]
fn redact_offending_token_withholds_a_colon_the_mask_left_behind_the_at_sign() {
let masked = redact_offending_token("http://alice:pw@ho:st");
assert!(masked.contains("withheld"), "{masked}");
assert!(!masked.contains("ho:st"), "{masked}");
}
#[test]
fn redact_offending_token_withholds_a_second_credential_it_had_already_masked() {
let masked = redact_offending_token("alice:pw@//a:b@host");
assert!(masked.contains("withheld"), "{masked}");
}
#[test]
fn redact_offending_token_still_names_a_second_at_sign_the_mask_could_not_reach() {
assert_eq!(
redact_offending_token("alice:pw@a//b@host"),
"alice:***@a//b@host"
);
}
#[test]
fn redact_offending_token_still_names_a_padded_ipv6_literal() {
assert_eq!(redact_offending_token(" ::1 "), " ::1 ");
}
#[test]
fn redact_offending_token_masks_a_double_slash_inside_a_password() {
assert_eq!(
redact_offending_token("http://alice:aa//bb@proxy.corp:8080"),
"http://alice:***@proxy.corp:8080"
);
}
#[test]
fn redact_userinfo_masks_an_at_sign_in_the_password() {
assert_eq!(
redact_userinfo("http://alice:pa@ss@host"),
"http://alice:***@host"
);
assert_eq!(
redact_userinfo("alice:p@ssw@rd@proxy.corp:8080"),
"alice:***@proxy.corp:8080"
);
}
#[test]
fn redact_userinfo_leaves_an_email_like_string_alone() {
let input = "contact foo@example.com/path?x=1";
assert_eq!(redact_userinfo(input), input);
}
#[test]
fn redact_userinfo_masks_a_percent_encoded_delimiter() {
for input in [
"http://alice%3Ahunter2@wpad.corp/proxy.pac",
"http://alice%3ahunter2@wpad.corp/proxy.pac",
] {
let masked = redact_userinfo(input);
assert!(
!masked.contains("hunter2"),
"the secret survived masking: {masked}"
);
assert!(masked.contains("***"), "{masked}");
assert!(masked.ends_with("@wpad.corp/proxy.pac"), "{masked}");
}
}
#[test]
fn redact_userinfo_masks_from_the_earlier_of_the_two_delimiters() {
assert_eq!(
redact_userinfo("http://alice%3Ahunter2:second@host"),
"http://alice%3A***@host"
);
assert_eq!(
redact_userinfo("http://alice:hunter2%3Asecond@host"),
"http://alice:***@host"
);
}
#[test]
fn redact_userinfo_survives_a_truncated_percent_escape() {
for input in ["http://alice%3@host", "http://alice%@host", "http://a%@h"] {
assert_eq!(redact_userinfo(input), input);
}
}
#[test]
fn redact_userinfo_handles_multibyte_text_around_the_delimiter() {
assert_eq!(
redact_userinfo("http://アリス%3Aひみつ@host"),
"http://アリス%3A***@host"
);
}
#[test]
fn fnv1a_is_deterministic_and_sensitive_to_every_byte() {
assert_eq!(fnv1a(b"abc"), fnv1a(b"abc"));
assert_ne!(fnv1a(b"abc"), fnv1a(b"abd"));
assert_ne!(fnv1a(b""), fnv1a(b"a"));
}
#[test]
fn fnv1a_answers_the_published_vectors_for_the_algorithm_it_names() {
assert_eq!(fnv1a(b""), 0xcbf2_9ce4_8422_2325);
assert_eq!(fnv1a(b"a"), 0xaf63_dc4c_8601_ec8c);
assert_eq!(fnv1a(b"foobar"), 0x8594_4171_f739_67e8);
}
#[test]
fn redact_and_sanitize_masks_a_password_holding_a_control_character() {
for (input, secret) in [
("connect to alice:my\tpass@host failed", "my.pass"),
("PROXY alice:my\npass@host:8080", "my.pass"),
] {
let safe = redact_and_sanitize_untrusted(input);
assert!(!safe.contains(secret), "{safe}");
assert!(safe.contains("***"), "{safe}");
}
}
#[test]
fn redact_and_sanitize_masks_a_percent_encoded_space_inside_a_password() {
let safe = redact_and_sanitize_untrusted("PROXY http://alice:my%20pass@host:8080");
assert!(!safe.contains("my%20pass"), "{safe}");
assert!(!safe.contains("my pass"), "{safe}");
assert_eq!(safe, "PROXY http://alice:***@host:8080");
}
#[test]
fn redact_offending_token_reads_either_spelling_of_the_delimiter_as_a_boundary() {
for input in [
"http://alice:se@cret pw@proxy.corp:8080",
"http://alice%3Ase@cret pw@proxy.corp:8080",
"http://alice%3:se@cret pw@proxy.corp:8080",
] {
let masked = redact_offending_token(input);
assert!(masked.contains("withheld"), "{input} -> {masked}");
}
}
#[test]
fn redact_offending_token_still_names_a_value_whose_own_text_holds_three_stars() {
assert_eq!(
redact_offending_token("comment ***@ noreply@host"),
"comment ***@ noreply@host"
);
}
#[test]
fn redact_and_sanitize_masks_across_two_credentials_a_control_character_had_separated() {
assert_eq!(
redact_and_sanitize_untrusted("alice:secret1@host1\nbob:secret2@host2"),
"alice:***@host2"
);
assert_eq!(
redact_and_sanitize_untrusted("alice:secret1@host1 bob:secret2@host2"),
"alice:***@host1 bob:***@host2"
);
}
#[test]
fn a_password_that_reaches_past_the_bound_is_masked_before_the_cut_can_take_its_at_sign() {
let password = "s".repeat(MAX_UNTRUSTED);
let safe = redact_and_sanitize_untrusted(&format!("alice:{password}@host"));
assert_eq!(safe, "alice:***@host");
assert!(!safe.contains("ss"), "{safe}");
}
#[test]
fn sanitize_untrusted_replaces_control_characters() {
assert_eq!(sanitize_untrusted("a\nb\tc\rd"), "a.b.c.d");
assert_eq!(sanitize_untrusted("plain text"), "plain text");
}
#[test]
fn sanitize_untrusted_truncates_long_input() {
let long = "y".repeat(MAX_UNTRUSTED + 100);
let sanitized = sanitize_untrusted(&long);
assert_eq!(sanitized.chars().count(), MAX_UNTRUSTED + 1);
assert!(sanitized.ends_with('…'), "{sanitized}");
assert!(!sanitized.contains("yyy…y"), "{sanitized}");
}
#[test]
fn sanitize_untrusted_leaves_short_input_unmarked() {
let short = "y".repeat(MAX_UNTRUSTED);
assert_eq!(sanitize_untrusted(&short), short);
}
#[test]
fn a_pathological_token_does_not_stall_the_error_that_rejects_it() {
let input = "@// ".repeat(40_000);
let start = std::time::Instant::now();
let masked = redact_offending_token(&input);
let elapsed = start.elapsed();
assert_eq!(masked.len(), input.len(), "the token came back changed");
assert!(
elapsed < std::time::Duration::from_secs(20),
"masking {} bytes took {elapsed:?}",
input.len()
);
}
#[test]
fn the_same_token_without_spaces_does_not_stall_it_either() {
let input = "@//".repeat(40_000);
let start = std::time::Instant::now();
let masked = redact_offending_token(&input);
let elapsed = start.elapsed();
assert_eq!(masked.len(), input.len(), "the token came back changed");
assert!(
elapsed < std::time::Duration::from_secs(20),
"masking {} bytes took {elapsed:?}",
input.len()
);
let start = std::time::Instant::now();
let sentence = redact_and_sanitize_untrusted(&input);
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(20),
"sanitising {} bytes took {elapsed:?}",
input.len()
);
assert_eq!(sentence.chars().count(), MAX_UNTRUSTED + 1, "{sentence}");
}
}