use super::{GeneratedCss, GeneratedHtml, GeneratedJs};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct HtmlValidationResult {
pub valid: bool,
pub errors: Vec<String>,
pub warnings: Vec<String>,
}
impl HtmlValidationResult {
#[must_use]
pub fn is_valid(&self) -> bool {
self.valid && self.errors.is_empty()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CssLintResult {
pub valid: bool,
pub errors: Vec<String>,
pub warnings: Vec<String>,
}
impl CssLintResult {
#[must_use]
pub fn is_valid(&self) -> bool {
self.valid && self.errors.is_empty()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct JsLintResult {
pub valid: bool,
pub errors: Vec<String>,
pub warnings: Vec<String>,
pub security_issues: Vec<SecurityIssue>,
}
impl JsLintResult {
#[must_use]
pub fn is_valid(&self) -> bool {
self.valid && self.errors.is_empty() && self.security_issues.is_empty()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityIssue {
pub severity: Severity,
pub description: String,
pub line: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Severity {
Low,
Medium,
High,
Critical,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AccessibilityIssue {
pub severity: Severity,
pub description: String,
pub element_id: Option<String>,
pub wcag_ref: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ValidationReport {
pub html: HtmlValidationResult,
pub css: CssLintResult,
pub js: JsLintResult,
pub accessibility: Vec<AccessibilityIssue>,
}
impl ValidationReport {
#[must_use]
pub fn is_valid(&self) -> bool {
self.html.is_valid()
&& self.css.is_valid()
&& self.js.is_valid()
&& self
.accessibility
.iter()
.all(|a| a.severity != Severity::Critical)
}
#[must_use]
pub fn error_count(&self) -> usize {
self.html.errors.len() + self.css.errors.len() + self.js.errors.len()
}
#[must_use]
pub fn warning_count(&self) -> usize {
self.html.warnings.len() + self.css.warnings.len() + self.js.warnings.len()
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct WebValidator;
impl WebValidator {
#[must_use]
pub fn validate_html(html: &GeneratedHtml) -> HtmlValidationResult {
let mut result = HtmlValidationResult {
valid: true,
errors: Vec::new(),
warnings: Vec::new(),
};
if !html.content.contains("<!DOCTYPE html>") {
result
.errors
.push("Missing DOCTYPE declaration".to_string());
result.valid = false;
}
if !html.content.contains("<html") {
result.errors.push("Missing <html> tag".to_string());
result.valid = false;
}
if !html.content.contains("<head>") {
result.errors.push("Missing <head> tag".to_string());
result.valid = false;
}
if !html.content.contains("<body>") {
result.errors.push("Missing <body> tag".to_string());
result.valid = false;
}
if !html.content.contains("charset=") {
result.warnings.push("Missing charset meta tag".to_string());
}
if !html.content.contains("viewport") {
result
.warnings
.push("Missing viewport meta tag".to_string());
}
if !html.content.contains("<title>") || html.title.is_empty() {
result
.errors
.push("Missing or empty <title> tag".to_string());
result.valid = false;
}
if !html.content.contains("lang=") {
result
.warnings
.push("Missing lang attribute on <html>".to_string());
}
result
}
#[must_use]
pub fn lint_css(css: &GeneratedCss) -> CssLintResult {
let mut result = CssLintResult {
valid: true,
errors: Vec::new(),
warnings: Vec::new(),
};
if css.content.trim().is_empty() && css.rules.is_empty() {
result.warnings.push("Empty stylesheet".to_string());
}
for rule in &css.rules {
if rule.selector.trim().is_empty() {
result.errors.push("Empty CSS selector".to_string());
result.valid = false;
}
for (_, value) in &rule.declarations {
if value.contains("!important") {
result
.warnings
.push(format!("Use of !important in {}", rule.selector));
}
}
}
if css.content.contains("-webkit-") && !css.content.contains("webkit") {
result
.warnings
.push("Vendor prefix -webkit- used".to_string());
}
result
}
#[must_use]
pub fn lint_js(js: &GeneratedJs) -> JsLintResult {
let mut result = JsLintResult {
valid: true,
errors: Vec::new(),
warnings: Vec::new(),
security_issues: Vec::new(),
};
if js.line_count > super::js_builder::MAX_JS_LINES {
result.errors.push(format!(
"JavaScript exceeds {} line limit: {} lines",
super::js_builder::MAX_JS_LINES,
js.line_count
));
result.valid = false;
}
Self::check_js_security(js, &mut result);
result
}
fn check_js_security(js: &GeneratedJs, result: &mut JsLintResult) {
if js.content.contains("eval(") {
result.security_issues.push(SecurityIssue {
severity: Severity::Critical,
description: "Use of eval() is forbidden".to_string(),
line: None,
});
result.valid = false;
}
if js.content.contains("new Function(") {
result.security_issues.push(SecurityIssue {
severity: Severity::Critical,
description: "Use of Function constructor is forbidden".to_string(),
line: None,
});
result.valid = false;
}
if js.content.contains("innerHTML") {
result.security_issues.push(SecurityIssue {
severity: Severity::High,
description: "Use of innerHTML can lead to XSS".to_string(),
line: None,
});
}
if js.content.contains("document.write") {
result.security_issues.push(SecurityIssue {
severity: Severity::Medium,
description: "Use of document.write is deprecated".to_string(),
line: None,
});
}
if js.content.contains("setTimeout(\"") || js.content.contains("setInterval(\"") {
result.security_issues.push(SecurityIssue {
severity: Severity::High,
description: "String argument to setTimeout/setInterval is eval-like".to_string(),
line: None,
});
}
}
#[must_use]
pub fn check_accessibility(html: &GeneratedHtml) -> Vec<AccessibilityIssue> {
let mut issues = Vec::new();
for element in &html.elements {
if let super::Element::Canvas {
id,
role,
aria_label,
..
} = element
{
if role.is_empty() {
issues.push(AccessibilityIssue {
severity: Severity::Medium,
description: "Canvas element missing role attribute".to_string(),
element_id: Some(id.clone()),
wcag_ref: Some("WCAG 4.1.2".to_string()),
});
}
if aria_label.is_empty() {
issues.push(AccessibilityIssue {
severity: Severity::Medium,
description: "Canvas element missing aria-label".to_string(),
element_id: Some(id.clone()),
wcag_ref: Some("WCAG 1.1.1".to_string()),
});
}
}
if let super::Element::Button { id, aria_label, .. } = element {
if aria_label.is_empty() {
issues.push(AccessibilityIssue {
severity: Severity::Medium,
description: "Button missing aria-label".to_string(),
element_id: Some(id.clone()),
wcag_ref: Some("WCAG 4.1.2".to_string()),
});
}
}
if let super::Element::Input { id, aria_label, .. } = element {
if aria_label.is_empty() {
issues.push(AccessibilityIssue {
severity: Severity::Medium,
description: "Input missing aria-label".to_string(),
element_id: Some(id.clone()),
wcag_ref: Some("WCAG 1.3.1".to_string()),
});
}
}
}
if !html.content.contains("lang=") {
issues.push(AccessibilityIssue {
severity: Severity::High,
description: "Missing lang attribute on <html>".to_string(),
element_id: None,
wcag_ref: Some("WCAG 3.1.1".to_string()),
});
}
issues
}
#[must_use]
pub fn validate_all(
html: &GeneratedHtml,
css: &GeneratedCss,
js: &GeneratedJs,
) -> ValidationReport {
ValidationReport {
html: Self::validate_html(html),
css: Self::lint_css(css),
js: Self::lint_js(js),
accessibility: Self::check_accessibility(html),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::web::{CssBuilder, HtmlBuilder, JsBuilder};
#[test]
fn h0_val_01_valid_html() {
let html = HtmlBuilder::new()
.title("Test")
.canvas("c", 100, 100)
.build()
.unwrap();
let result = WebValidator::validate_html(&html);
assert!(result.is_valid());
}
#[test]
fn h0_val_02_missing_doctype() {
let html = GeneratedHtml {
title: "Test".to_string(),
body_content: String::new(),
content: "<html><head></head><body></body></html>".to_string(),
elements: vec![],
};
let result = WebValidator::validate_html(&html);
assert!(!result.is_valid());
assert!(result.errors.iter().any(|e| e.contains("DOCTYPE")));
}
#[test]
fn h0_val_03_missing_title() {
let html = GeneratedHtml {
title: String::new(),
body_content: String::new(),
content: "<!DOCTYPE html><html><head></head><body></body></html>".to_string(),
elements: vec![],
};
let result = WebValidator::validate_html(&html);
assert!(!result.is_valid());
assert!(result.errors.iter().any(|e| e.contains("title")));
}
#[test]
fn h0_val_04_valid_css() {
let css = CssBuilder::new().reset().build().unwrap();
let result = WebValidator::lint_css(&css);
assert!(result.is_valid());
}
#[test]
fn h0_val_05_empty_css_warning() {
let css = GeneratedCss {
content: String::new(),
rules: vec![],
variables: vec![],
};
let result = WebValidator::lint_css(&css);
assert!(result.is_valid()); assert!(result.warnings.iter().any(|w| w.contains("Empty")));
}
#[test]
fn h0_val_06_important_warning() {
let css = GeneratedCss {
content: ".test { color: red !important; }".to_string(),
rules: vec![super::super::CssRule {
selector: ".test".to_string(),
declarations: vec![("color".to_string(), "red !important".to_string())],
}],
variables: vec![],
};
let result = WebValidator::lint_css(&css);
assert!(result.warnings.iter().any(|w| w.contains("!important")));
}
#[test]
fn h0_val_07_valid_js() {
let js = JsBuilder::new("app.wasm", "canvas").build().unwrap();
let result = WebValidator::lint_js(&js);
assert!(result.is_valid());
}
#[test]
fn h0_val_08_js_eval_blocked() {
let js = GeneratedJs {
content: "eval('code')".to_string(),
line_count: 1,
functions: vec![],
};
let result = WebValidator::lint_js(&js);
assert!(!result.is_valid());
assert!(result
.security_issues
.iter()
.any(|s| s.severity == Severity::Critical));
}
#[test]
fn h0_val_09_js_function_constructor_blocked() {
let js = GeneratedJs {
content: "new Function('return 1')".to_string(),
line_count: 1,
functions: vec![],
};
let result = WebValidator::lint_js(&js);
assert!(!result.is_valid());
}
#[test]
fn h0_val_10_js_innerhtml_warning() {
let js = GeneratedJs {
content: "el.innerHTML = 'test'".to_string(),
line_count: 1,
functions: vec![],
};
let result = WebValidator::lint_js(&js);
assert!(result
.security_issues
.iter()
.any(|s| s.severity == Severity::High));
}
#[test]
fn h0_val_11_canvas_accessibility() {
let html = HtmlBuilder::new()
.title("Test")
.canvas("c", 100, 100)
.build()
.unwrap();
let issues = WebValidator::check_accessibility(&html);
assert!(issues.is_empty() || issues.iter().all(|i| i.severity != Severity::Critical));
}
#[test]
fn h0_val_12_missing_role_warning() {
let html = GeneratedHtml {
title: "Test".to_string(),
body_content: String::new(),
content: "<!DOCTYPE html><html lang=\"en\"><head><title>Test</title></head><body></body></html>".to_string(),
elements: vec![super::super::Element::Canvas {
id: "c".to_string(),
width: 100,
height: 100,
role: String::new(), aria_label: "Test".to_string(),
}],
};
let issues = WebValidator::check_accessibility(&html);
assert!(issues.iter().any(|i| i.description.contains("role")));
}
#[test]
fn h0_val_13_validate_all() {
let html = HtmlBuilder::new()
.title("Test")
.canvas("c", 100, 100)
.build()
.unwrap();
let css = CssBuilder::new().reset().build().unwrap();
let js = JsBuilder::new("app.wasm", "c").build().unwrap();
let report = WebValidator::validate_all(&html, &css, &js);
assert!(report.is_valid());
}
#[test]
fn h0_val_14_error_count() {
let report = ValidationReport {
html: HtmlValidationResult {
valid: false,
errors: vec!["e1".to_string(), "e2".to_string()],
warnings: vec![],
},
css: CssLintResult {
valid: false,
errors: vec!["e3".to_string()],
warnings: vec!["w1".to_string()],
},
js: JsLintResult::default(),
accessibility: vec![],
};
assert_eq!(report.error_count(), 3);
assert_eq!(report.warning_count(), 1);
}
#[test]
fn h0_val_15_severity_comparison() {
assert_ne!(Severity::Low, Severity::Critical);
assert_eq!(Severity::High, Severity::High);
}
#[test]
fn h0_val_16_validation_result_is_valid() {
let valid = HtmlValidationResult {
valid: true,
errors: vec![],
warnings: vec!["warning".to_string()],
};
assert!(valid.is_valid());
let invalid = HtmlValidationResult {
valid: true, errors: vec!["error".to_string()], warnings: vec![],
};
assert!(!invalid.is_valid());
}
#[test]
fn h0_val_17_js_document_write_warning() {
let js = GeneratedJs {
content: "document.write('test')".to_string(),
line_count: 1,
functions: vec![],
};
let result = WebValidator::lint_js(&js);
assert!(result
.security_issues
.iter()
.any(|s| s.severity == Severity::Medium && s.description.contains("document.write")));
}
#[test]
fn h0_val_18_js_settimeout_string_warning() {
let js = GeneratedJs {
content: r#"setTimeout("alert(1)", 100)"#.to_string(),
line_count: 1,
functions: vec![],
};
let result = WebValidator::lint_js(&js);
assert!(result
.security_issues
.iter()
.any(|s| s.severity == Severity::High && s.description.contains("setTimeout")));
}
#[test]
fn h0_val_19_js_setinterval_string_warning() {
let js = GeneratedJs {
content: r#"setInterval("tick()", 1000)"#.to_string(),
line_count: 1,
functions: vec![],
};
let result = WebValidator::lint_js(&js);
assert!(result
.security_issues
.iter()
.any(|s| s.description.contains("setInterval")));
}
#[test]
fn h0_val_20_css_empty_selector() {
let css = GeneratedCss {
content: String::new(),
rules: vec![super::super::CssRule {
selector: " ".to_string(), declarations: vec![("color".to_string(), "red".to_string())],
}],
variables: vec![],
};
let result = WebValidator::lint_css(&css);
assert!(!result.is_valid());
assert!(result
.errors
.iter()
.any(|e| e.contains("Empty CSS selector")));
}
#[test]
fn h0_val_21_css_vendor_prefix_warning() {
let css = GeneratedCss {
content: "-webkit-transform: rotate(45deg);".to_string(),
rules: vec![],
variables: vec![],
};
let result = WebValidator::lint_css(&css);
assert!(result.is_valid());
}
#[test]
fn h0_val_22_button_missing_aria_label() {
let html = GeneratedHtml {
title: "Test".to_string(),
body_content: String::new(),
content: "<!DOCTYPE html><html lang=\"en\"><head><title>Test</title></head><body></body></html>".to_string(),
elements: vec![super::super::Element::Button {
id: "btn".to_string(),
text: "Click".to_string(),
aria_label: String::new(), }],
};
let issues = WebValidator::check_accessibility(&html);
assert!(issues
.iter()
.any(|i| i.description.contains("Button") && i.description.contains("aria-label")));
}
#[test]
fn h0_val_23_input_missing_aria_label() {
let html = GeneratedHtml {
title: "Test".to_string(),
body_content: String::new(),
content: "<!DOCTYPE html><html lang=\"en\"><head><title>Test</title></head><body></body></html>".to_string(),
elements: vec![super::super::Element::Input {
id: "input1".to_string(),
input_type: "text".to_string(),
placeholder: "Enter text".to_string(),
aria_label: String::new(), }],
};
let issues = WebValidator::check_accessibility(&html);
assert!(issues
.iter()
.any(|i| i.description.contains("Input") && i.description.contains("aria-label")));
}
#[test]
fn h0_val_24_canvas_missing_aria_label() {
let html = GeneratedHtml {
title: "Test".to_string(),
body_content: String::new(),
content: "<!DOCTYPE html><html lang=\"en\"><head><title>Test</title></head><body></body></html>".to_string(),
elements: vec![super::super::Element::Canvas {
id: "c".to_string(),
width: 100,
height: 100,
role: "img".to_string(),
aria_label: String::new(), }],
};
let issues = WebValidator::check_accessibility(&html);
assert!(issues
.iter()
.any(|i| i.description.contains("Canvas") && i.description.contains("aria-label")));
}
#[test]
fn h0_val_25_missing_lang_attribute() {
let html = GeneratedHtml {
title: "Test".to_string(),
body_content: String::new(),
content: "<!DOCTYPE html><html><head><title>Test</title></head><body></body></html>"
.to_string(),
elements: vec![],
};
let issues = WebValidator::check_accessibility(&html);
assert!(issues.iter().any(
|i| i.description.contains("lang") && i.wcag_ref == Some("WCAG 3.1.1".to_string())
));
}
#[test]
fn h0_val_26_js_lint_result_with_errors_only() {
let result = JsLintResult {
valid: true, errors: vec!["error".to_string()],
warnings: vec![],
security_issues: vec![],
};
assert!(!result.is_valid()); }
#[test]
fn h0_val_27_js_lint_result_with_security_only() {
let result = JsLintResult {
valid: true,
errors: vec![],
warnings: vec![],
security_issues: vec![SecurityIssue {
severity: Severity::Low,
description: "test".to_string(),
line: Some(1),
}],
};
assert!(!result.is_valid()); }
#[test]
fn h0_val_28_css_lint_result_with_errors() {
let result = CssLintResult {
valid: true,
errors: vec!["error".to_string()],
warnings: vec![],
};
assert!(!result.is_valid());
}
#[test]
fn h0_val_29_report_with_critical_accessibility() {
let report = ValidationReport {
html: HtmlValidationResult::default(),
css: CssLintResult::default(),
js: JsLintResult::default(),
accessibility: vec![AccessibilityIssue {
severity: Severity::Critical,
description: "Critical issue".to_string(),
element_id: None,
wcag_ref: None,
}],
};
assert!(!report.is_valid()); }
#[test]
fn h0_val_30_report_with_non_critical_accessibility() {
let report = ValidationReport {
html: HtmlValidationResult {
valid: true,
errors: vec![],
warnings: vec![],
},
css: CssLintResult {
valid: true,
errors: vec![],
warnings: vec![],
},
js: JsLintResult {
valid: true,
errors: vec![],
warnings: vec![],
security_issues: vec![],
},
accessibility: vec![AccessibilityIssue {
severity: Severity::Medium,
description: "Medium issue".to_string(),
element_id: Some("el1".to_string()),
wcag_ref: Some("WCAG 1.1.1".to_string()),
}],
};
assert!(report.is_valid()); }
#[test]
fn h0_val_31_js_line_count_exceeded() {
let js = GeneratedJs {
content: "// code".to_string(),
line_count: 1000, functions: vec![],
};
let result = WebValidator::lint_js(&js);
assert!(!result.is_valid());
assert!(result.errors.iter().any(|e| e.contains("line limit")));
}
#[test]
fn h0_val_32_html_missing_html_tag() {
let html = GeneratedHtml {
title: "Test".to_string(),
body_content: String::new(),
content: "<!DOCTYPE html><head><title>Test</title></head><body></body>".to_string(),
elements: vec![],
};
let result = WebValidator::validate_html(&html);
assert!(!result.is_valid());
assert!(result.errors.iter().any(|e| e.contains("<html>")));
}
#[test]
fn h0_val_33_html_missing_head_tag() {
let html = GeneratedHtml {
title: "Test".to_string(),
body_content: String::new(),
content: "<!DOCTYPE html><html><body></body></html>".to_string(),
elements: vec![],
};
let result = WebValidator::validate_html(&html);
assert!(!result.is_valid());
assert!(result.errors.iter().any(|e| e.contains("<head>")));
}
#[test]
fn h0_val_34_html_missing_body_tag() {
let html = GeneratedHtml {
title: "Test".to_string(),
body_content: String::new(),
content: "<!DOCTYPE html><html><head><title>Test</title></head></html>".to_string(),
elements: vec![],
};
let result = WebValidator::validate_html(&html);
assert!(!result.is_valid());
assert!(result.errors.iter().any(|e| e.contains("<body>")));
}
}