Readur/src/services/webdav/validation.rs

352 lines
13 KiB
Rust

use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tracing::{debug, info, warn};
use super::config::WebDAVConfig;
use super::connection::WebDAVConnection;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationReport {
pub overall_health_score: i32, // 0-100
pub issues: Vec<ValidationIssue>,
pub recommendations: Vec<ValidationRecommendation>,
pub summary: ValidationSummary,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationIssue {
pub issue_type: ValidationIssueType,
pub severity: ValidationSeverity,
pub directory_path: String,
pub description: String,
pub details: Option<serde_json::Value>,
pub detected_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ValidationIssueType {
/// Directory exists on server but not in our tracking
Untracked,
/// Directory in our tracking but missing on server
Missing,
/// ETag mismatch between server and our cache
ETagMismatch,
/// Directory hasn't been scanned in a very long time
Stale,
/// Server errors when accessing directory
Inaccessible,
/// ETag support seems unreliable for this directory
ETagUnreliable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ValidationSeverity {
Info, // No action needed, just FYI
Warning, // Should investigate but not urgent
Error, // Needs immediate attention
Critical, // System integrity at risk
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationRecommendation {
pub action: ValidationAction,
pub reason: String,
pub affected_directories: Vec<String>,
pub priority: ValidationSeverity,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ValidationAction {
/// Run a deep scan of specific directories
DeepScanRequired,
/// Clear and rebuild directory tracking
RebuildTracking,
/// ETag support is unreliable, switch to periodic scans
DisableETagOptimization,
/// Clean up orphaned database entries
CleanupDatabase,
/// Server configuration issue needs attention
CheckServerConfiguration,
/// No action needed, system is healthy
NoActionRequired,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationSummary {
pub total_directories_checked: usize,
pub healthy_directories: usize,
pub directories_with_issues: usize,
pub critical_issues: usize,
pub warning_issues: usize,
pub info_issues: usize,
pub validation_duration_ms: u64,
}
pub struct WebDAVValidator {
connection: WebDAVConnection,
config: WebDAVConfig,
}
impl WebDAVValidator {
pub fn new(connection: WebDAVConnection, config: WebDAVConfig) -> Self {
Self { connection, config }
}
/// Performs comprehensive validation of WebDAV setup and directory tracking
pub async fn validate_system(&self) -> Result<ValidationReport> {
let start_time = std::time::Instant::now();
info!("🔍 Starting WebDAV system validation");
let mut issues = Vec::new();
let mut total_checked = 0;
// Test basic connectivity
match self.connection.test_connection().await {
Ok(result) if !result.success => {
issues.push(ValidationIssue {
issue_type: ValidationIssueType::Inaccessible,
severity: ValidationSeverity::Critical,
directory_path: "/".to_string(),
description: format!("WebDAV server connection failed: {}", result.message),
details: None,
detected_at: chrono::Utc::now(),
});
}
Err(e) => {
issues.push(ValidationIssue {
issue_type: ValidationIssueType::Inaccessible,
severity: ValidationSeverity::Critical,
directory_path: "/".to_string(),
description: format!("WebDAV server connectivity test failed: {}", e),
details: None,
detected_at: chrono::Utc::now(),
});
}
_ => {
debug!("✅ Basic connectivity test passed");
}
}
// Validate each watch folder
for folder in &self.config.watch_folders {
total_checked += 1;
if let Err(e) = self.validate_watch_folder(folder, &mut issues).await {
warn!("Failed to validate watch folder '{}': {}", folder, e);
}
}
// Test ETag reliability
self.validate_etag_support(&mut issues).await?;
// Generate recommendations based on issues
let recommendations = self.generate_recommendations(&issues);
let validation_duration = start_time.elapsed().as_millis() as u64;
let health_score = self.calculate_health_score(&issues);
let summary = ValidationSummary {
total_directories_checked: total_checked,
healthy_directories: total_checked - issues.len(),
directories_with_issues: issues.len(),
critical_issues: issues.iter().filter(|i| matches!(i.severity, ValidationSeverity::Critical)).count(),
warning_issues: issues.iter().filter(|i| matches!(i.severity, ValidationSeverity::Warning)).count(),
info_issues: issues.iter().filter(|i| matches!(i.severity, ValidationSeverity::Info)).count(),
validation_duration_ms: validation_duration,
};
info!("✅ WebDAV validation completed in {}ms. Health score: {}/100",
validation_duration, health_score);
Ok(ValidationReport {
overall_health_score: health_score,
issues,
recommendations,
summary,
})
}
/// Validates a specific watch folder
async fn validate_watch_folder(&self, folder: &str, issues: &mut Vec<ValidationIssue>) -> Result<()> {
debug!("Validating watch folder: {}", folder);
// Test PROPFIND access
match self.connection.test_propfind(folder).await {
Ok(_) => {
debug!("✅ Watch folder '{}' is accessible", folder);
}
Err(e) => {
issues.push(ValidationIssue {
issue_type: ValidationIssueType::Inaccessible,
severity: ValidationSeverity::Error,
directory_path: folder.to_string(),
description: format!("Cannot access watch folder: {}", e),
details: Some(serde_json::json!({
"error": e.to_string(),
"folder": folder
})),
detected_at: chrono::Utc::now(),
});
}
}
Ok(())
}
/// Tests ETag support reliability
async fn validate_etag_support(&self, issues: &mut Vec<ValidationIssue>) -> Result<()> {
debug!("Testing ETag support reliability");
// Test ETag consistency across multiple requests
for folder in &self.config.watch_folders {
if let Err(e) = self.test_etag_consistency(folder, issues).await {
warn!("ETag consistency test failed for '{}': {}", folder, e);
}
}
Ok(())
}
/// Tests ETag consistency for a specific folder
async fn test_etag_consistency(&self, folder: &str, issues: &mut Vec<ValidationIssue>) -> Result<()> {
// Make two consecutive PROPFIND requests and compare ETags
let etag1 = self.get_folder_etag(folder).await?;
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let etag2 = self.get_folder_etag(folder).await?;
if etag1 != etag2 && etag1.is_some() && etag2.is_some() {
issues.push(ValidationIssue {
issue_type: ValidationIssueType::ETagUnreliable,
severity: ValidationSeverity::Warning,
directory_path: folder.to_string(),
description: "ETag values are inconsistent across requests".to_string(),
details: Some(serde_json::json!({
"etag1": etag1,
"etag2": etag2,
"folder": folder
})),
detected_at: chrono::Utc::now(),
});
}
Ok(())
}
/// Gets the ETag for a folder
async fn get_folder_etag(&self, folder: &str) -> Result<Option<String>> {
let url = self.connection.get_url_for_path(folder);
let propfind_body = r#"<?xml version="1.0" encoding="utf-8"?>
<D:propfind xmlns:D="DAV:">
<D:prop>
<D:getetag/>
</D:prop>
</D:propfind>"#;
let response = self.connection
.authenticated_request(
reqwest::Method::from_bytes(b"PROPFIND")?,
&url,
Some(propfind_body.to_string()),
Some(vec![
("Depth", "0"),
("Content-Type", "application/xml"),
]),
)
.await?;
let body = response.text().await?;
// Parse ETag from XML response (simplified)
if let Some(start) = body.find("<D:getetag>") {
if let Some(end) = body[start..].find("</D:getetag>") {
let etag = &body[start + 11..start + end];
return Ok(Some(etag.trim_matches('"').to_string()));
}
}
Ok(None)
}
/// Generates recommendations based on detected issues
fn generate_recommendations(&self, issues: &Vec<ValidationIssue>) -> Vec<ValidationRecommendation> {
let mut recommendations = Vec::new();
let mut directories_by_issue: HashMap<ValidationIssueType, Vec<String>> = HashMap::new();
// Group directories by issue type
for issue in issues {
directories_by_issue
.entry(issue.issue_type.clone())
.or_insert_with(Vec::new)
.push(issue.directory_path.clone());
}
// Generate recommendations for each issue type
for (issue_type, directories) in directories_by_issue {
let recommendation = match issue_type {
ValidationIssueType::Inaccessible => ValidationRecommendation {
action: ValidationAction::CheckServerConfiguration,
reason: "Some directories are inaccessible. Check server configuration and permissions.".to_string(),
affected_directories: directories,
priority: ValidationSeverity::Critical,
},
ValidationIssueType::ETagUnreliable => ValidationRecommendation {
action: ValidationAction::DisableETagOptimization,
reason: "ETag support appears unreliable. Consider disabling ETag optimization.".to_string(),
affected_directories: directories,
priority: ValidationSeverity::Warning,
},
ValidationIssueType::Missing => ValidationRecommendation {
action: ValidationAction::CleanupDatabase,
reason: "Some tracked directories no longer exist on the server.".to_string(),
affected_directories: directories,
priority: ValidationSeverity::Warning,
},
ValidationIssueType::Stale => ValidationRecommendation {
action: ValidationAction::DeepScanRequired,
reason: "Some directories haven't been scanned recently.".to_string(),
affected_directories: directories,
priority: ValidationSeverity::Info,
},
_ => ValidationRecommendation {
action: ValidationAction::DeepScanRequired,
reason: "General validation issues detected.".to_string(),
affected_directories: directories,
priority: ValidationSeverity::Warning,
},
};
recommendations.push(recommendation);
}
if recommendations.is_empty() {
recommendations.push(ValidationRecommendation {
action: ValidationAction::NoActionRequired,
reason: "System validation passed successfully.".to_string(),
affected_directories: Vec::new(),
priority: ValidationSeverity::Info,
});
}
recommendations
}
/// Calculates overall health score based on issues
fn calculate_health_score(&self, issues: &Vec<ValidationIssue>) -> i32 {
if issues.is_empty() {
return 100;
}
let mut penalty = 0;
for issue in issues {
let issue_penalty = match issue.severity {
ValidationSeverity::Critical => 30,
ValidationSeverity::Error => 20,
ValidationSeverity::Warning => 10,
ValidationSeverity::Info => 5,
};
penalty += issue_penalty;
}
std::cmp::max(0, 100 - penalty)
}
}