// SPDX-License-Identifier: AGPL-3.0-or-later use serde_json::Value; #[derive(Clone, Debug, Eq, PartialEq)] struct Attribute { name: String, value: Option, } pub fn normalize_body(content_type: Option<&str>, body: &str) -> String { let content_type = content_type.unwrap_or("").to_ascii_lowercase(); if content_type.contains("css") { return normalize_css_body(body); } if content_type.contains("html") { return normalize_html(body); } if content_type.contains("json") && let Ok(value) = serde_json::from_str::(body) { return serde_json::to_string(&value).unwrap_or_else(|_| normalize_text(body)); } normalize_text(body) } fn normalize_css_body(body: &str) -> String { if body.trim().is_empty() || body.contains("not available") { "__CSS_EMPTY__".to_owned() } else { "__CSS_OK__".to_owned() } } pub fn normalize_html(input: &str) -> String { let input = normalize_text(input); let mut output = String::with_capacity(input.len()); let mut cursor = 0; while let Some(relative_start) = input[cursor..].find('<') { let start = cursor + relative_start; output.push_str(&input[cursor..start]); let Some(end) = find_tag_end(&input, start) else { output.push_str(&input[start..]); return finish_html(output); }; let raw_tag = &input[start..=end]; output.push_str(&normalize_tag(raw_tag)); cursor = end + 1; } output.push_str(&input[cursor..]); finish_html(output) } pub fn normalize_text(input: &str) -> String { let mut value = input.replace("\r\n", "\n").replace('\r', "\n"); value = normalize_host_ports(&value); value = strip_static_asset_queries(&value); value = strip_htmx_script_tags(&value); value = strip_rust_runtime_script_blocks(&value); value = normalize_intentional_rust_improvements(&value); value = replace_query_parameter_values(&value, "state", "__OAUTH_STATE__"); value = replace_query_parameter_values(&value, "_csrf", "__CSRF_TOKEN__"); value = replace_script_csrf_values(&value); value = normalize_cookie_tokens(&value); value.trim().to_owned() } pub fn normalize_header_value(name: &str, value: &str) -> String { match name.to_ascii_lowercase().as_str() { "content-type" => normalize_content_type(value), "location" => normalize_text(value), "cookie" | "set-cookie" => normalize_cookie_header(value), _ => normalize_text(value), } } pub fn normalize_content_type(value: &str) -> String { value .split(';') .map(|part| part.trim().to_ascii_lowercase()) .filter(|part| !part.is_empty()) .collect::>() .join("; ") } pub fn normalize_cookie_header(value: &str) -> String { normalize_cookie_tokens(value) .split(';') .map(str::trim) .filter(|part| !part.is_empty()) .collect::>() .join("; ") } fn replace_script_csrf_values(input: &str) -> String { let pattern = "var csrf=\""; let mut output = String::with_capacity(input.len()); let mut cursor = 0; while let Some(relative_start) = input[cursor..].find(pattern) { let start = cursor + relative_start; let token_start = start + pattern.len(); output.push_str(&input[cursor..token_start]); let Some(relative_end) = input[token_start..].find('"') else { cursor = token_start; break; }; let token_end = token_start + relative_end; let token = &input[token_start..token_end]; if token.len() == 64 && token.chars().all(|ch| ch.is_ascii_hexdigit()) { output.push_str("__CSRF_TOKEN__"); } else { output.push_str(token); } cursor = token_end; } output.push_str(&input[cursor..]); output } fn finish_html(output: String) -> String { let output = remove_empty_flash_container(&output); let output = decode_html_entities(&output); remove_between_tag_whitespace(&output).trim().to_owned() } fn remove_empty_flash_container(input: &str) -> String { input .replace( r#"
"#, "", ) .replace( r#"
"#, "", ) } fn find_tag_end(input: &str, start: usize) -> Option { let mut quote: Option = None; for (offset, ch) in input[start + 1..].char_indices() { match (quote, ch) { (Some(active), current) if current == active => quote = None, (None, '"' | '\'') => quote = Some(ch), (None, '>') => return Some(start + 1 + offset), _ => {} } } None } fn normalize_tag(raw: &str) -> String { if raw.starts_with("