#[derive(Debug, thiserror::Error)]
pub enum IdentifierError {
#[error("Identifier is empty")]
Empty,
#[error("Identifier exceeds maximum length of {max_length} characters")]
TooLong {
max_length: usize,
},
#[error("Identifier contains invalid character: '{ch}'")]
InvalidCharacter {
ch: char,
},
#[error("Identifier must start with alphanumeric or underscore, got: '{ch}'")]
InvalidStartCharacter {
ch: char,
},
}
pub fn validate_redirect_url(url: &str) -> bool {
let trimmed = url.trim();
if trimmed.is_empty() {
return false;
}
if trimmed.starts_with("../") || trimmed.contains("/../") || trimmed.ends_with("/..") {
return false;
}
if trimmed.starts_with('#') {
return true;
}
if trimmed.starts_with("./") {
return true;
}
if trimmed.starts_with('/') {
return !trimmed.starts_with("//");
}
let lower = trimmed.to_lowercase();
let dangerous_protocols = ["javascript:", "data:", "vbscript:"];
for proto in &dangerous_protocols {
if lower.starts_with(proto) {
return false;
}
}
if lower.starts_with("http://") || lower.starts_with("https://") {
let after_scheme = if lower.starts_with("https://") {
&trimmed[8..]
} else {
&trimmed[7..]
};
if let Some(path_start) = after_scheme.find('/') {
let authority = &after_scheme[..path_start];
if authority.contains('@') {
return false;
}
} else if after_scheme.contains('@') {
return false;
}
return true;
}
false
}
pub fn sanitize_log_input(input: &str, max_length: usize) -> String {
let mut result = String::with_capacity(input.len().min(max_length));
for (char_count, ch) in input.chars().enumerate() {
if char_count >= max_length {
break;
}
match ch {
'\n' | '\r' => result.push(' '),
'\t' => result.push(' '),
c if c.is_control() => result.push('\u{FFFD}'),
c => result.push(c),
}
}
result
}
pub fn validate_identifier(input: &str, max_length: usize) -> Result<(), IdentifierError> {
if input.is_empty() {
return Err(IdentifierError::Empty);
}
if input.len() > max_length {
return Err(IdentifierError::TooLong { max_length });
}
let first = input.chars().next().expect("non-empty string");
if !first.is_ascii_alphanumeric() && first != '_' {
return Err(IdentifierError::InvalidStartCharacter { ch: first });
}
for ch in input.chars() {
if !ch.is_ascii_alphanumeric() && ch != '-' && ch != '_' {
return Err(IdentifierError::InvalidCharacter { ch });
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case("/dashboard", true)]
#[case("/path/to/page", true)]
#[case("./relative", true)]
#[case("#section", true)]
#[case("#", true)]
#[case("https://example.com", true)]
#[case("http://example.com/page", true)]
#[case("https://example.com/path?q=1", true)]
fn test_validate_redirect_url_allows_safe_urls(#[case] url: &str, #[case] expected: bool) {
let result = validate_redirect_url(url);
assert_eq!(result, expected, "URL {:?} should be allowed", url);
}
#[rstest]
#[case("javascript:alert(1)", false)]
#[case("JAVASCRIPT:alert(1)", false)]
#[case("data:text/html,<script>", false)]
#[case("vbscript:msgbox", false)]
#[case("../secret", false)]
#[case("/path/../secret", false)]
#[case("/path/..", false)]
#[case("//evil.com", false)]
#[case("", false)]
#[case(" ", false)]
#[case("ftp://files.example.com", false)]
#[case("http://user:pass@host.com", false)]
#[case("https://admin:secret@host.com/path", false)]
fn test_validate_redirect_url_rejects_unsafe_urls(#[case] url: &str, #[case] expected: bool) {
let result = validate_redirect_url(url);
assert_eq!(result, expected, "URL {:?} should be rejected", url);
}
#[rstest]
fn test_validate_redirect_url_trims_whitespace() {
let url = " /dashboard ";
let result = validate_redirect_url(url);
assert!(result);
}
#[rstest]
fn test_sanitize_log_input_replaces_newlines() {
let input = "line1\nline2\rline3\r\nline4";
let result = sanitize_log_input(input, 100);
assert_eq!(result, "line1 line2 line3 line4");
}
#[rstest]
fn test_sanitize_log_input_replaces_tabs() {
let input = "col1\tcol2\tcol3";
let result = sanitize_log_input(input, 100);
assert_eq!(result, "col1 col2 col3");
}
#[rstest]
fn test_sanitize_log_input_replaces_control_characters() {
let input = "before\x00\x01\x07after";
let result = sanitize_log_input(input, 100);
assert_eq!(result, "before\u{FFFD}\u{FFFD}\u{FFFD}after");
}
#[rstest]
fn test_sanitize_log_input_truncates_to_max_length() {
let input = "a".repeat(200);
let result = sanitize_log_input(&input, 50);
assert_eq!(result.len(), 50);
}
#[rstest]
fn test_sanitize_log_input_preserves_normal_text() {
let input = "Hello, World! 123 @#$";
let result = sanitize_log_input(input, 100);
assert_eq!(result, input);
}
#[rstest]
fn test_sanitize_log_input_empty_input() {
let result = sanitize_log_input("", 100);
assert_eq!(result, "");
}
#[rstest]
fn test_sanitize_log_input_zero_max_length() {
let result = sanitize_log_input("some text", 0);
assert_eq!(result, "");
}
#[rstest]
#[case("my-plugin", 64)]
#[case("MyPlugin", 64)]
#[case("plugin_v2", 64)]
#[case("_internal", 64)]
#[case("a", 64)]
#[case("A123-test_name", 64)]
fn test_validate_identifier_accepts_valid(#[case] input: &str, #[case] max_len: usize) {
let result = validate_identifier(input, max_len);
assert!(result.is_ok(), "Identifier {:?} should be valid", input);
}
#[rstest]
fn test_validate_identifier_rejects_empty() {
let result = validate_identifier("", 64);
assert!(matches!(result, Err(IdentifierError::Empty)));
}
#[rstest]
fn test_validate_identifier_rejects_too_long() {
let input = "a".repeat(65);
let result = validate_identifier(&input, 64);
assert!(matches!(
result,
Err(IdentifierError::TooLong { max_length: 64 })
));
}
#[rstest]
#[case("-starts-with-hyphen")]
fn test_validate_identifier_rejects_invalid_start(#[case] input: &str) {
let result = validate_identifier(input, 64);
assert!(matches!(
result,
Err(IdentifierError::InvalidStartCharacter { .. })
));
}
#[rstest]
#[case("has space", ' ')]
#[case("has.dot", '.')]
#[case("has/slash", '/')]
#[case("has@at", '@')]
fn test_validate_identifier_rejects_invalid_characters(
#[case] input: &str,
#[case] expected_ch: char,
) {
let result = validate_identifier(input, 64);
match result {
Err(IdentifierError::InvalidCharacter { ch }) => {
assert_eq!(ch, expected_ch);
}
other => panic!("Expected InvalidCharacter, got {:?}", other),
}
}
#[rstest]
fn test_sanitize_log_input_multibyte_truncation_does_not_panic() {
let input = "あいうえおかきくけこ";
let result = sanitize_log_input(input, 5);
assert_eq!(result.chars().count(), 5);
assert_eq!(result, "あいうえお");
}
#[rstest]
fn test_sanitize_log_input_mixed_multibyte_truncation() {
let input = "aあbいcうdえeお";
let result = sanitize_log_input(input, 6);
assert_eq!(result.chars().count(), 6);
assert_eq!(result, "aあbいcう");
}
#[rstest]
fn test_identifier_error_display_messages() {
assert_eq!(IdentifierError::Empty.to_string(), "Identifier is empty");
assert_eq!(
IdentifierError::TooLong { max_length: 32 }.to_string(),
"Identifier exceeds maximum length of 32 characters"
);
assert_eq!(
IdentifierError::InvalidCharacter { ch: '@' }.to_string(),
"Identifier contains invalid character: '@'"
);
assert_eq!(
IdentifierError::InvalidStartCharacter { ch: '-' }.to_string(),
"Identifier must start with alphanumeric or underscore, got: '-'"
);
}
}