use serde::{Deserialize, Serialize}; use tauri::Manager; use url::Url; const MAX_LABEL_LEN: usize = 80; const MAX_ISSUER_LEN: usize = 80; const MIN_TOTP_SECRET_LEN: usize = 16; const MAX_TOTP_SECRET_LEN: usize = 256; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] struct ValidationResult { ok: bool, value: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct TotpProfileInput { issuer: String, label: String, secret: String, digits: Option, period: Option, } #[tauri::command] fn validate_qr_session_payload(payload: String) -> Result { let trimmed = payload.trim(); if trimmed.is_empty() { return Err("QR-код пустой".into()); } let session_id = match Url::parse(trimmed) { Ok(url) => url .query_pairs() .find(|(key, _)| key == "sessionId" || key == "session_id") .map(|(_, value)| value.to_string()) .or_else(|| { url.path_segments() .and_then(|segments| segments.last().map(ToString::to_string)) }) .unwrap_or_default(), Err(_) => trimmed.to_string(), }; validate_uuid_like(&session_id)?; Ok(ValidationResult { ok: true, value: session_id, }) } #[tauri::command] fn validate_api_base_url(url: String) -> Result { let parsed = Url::parse(url.trim()).map_err(|_| "Некорректный URL API".to_string())?; match parsed.scheme() { "http" | "https" => Ok(ValidationResult { ok: true, value: parsed.as_str().trim_end_matches('/').to_string(), }), _ => Err("URL API должен начинаться с http:// или https://".into()), } } #[tauri::command] fn validate_totp_profile_input(input: TotpProfileInput) -> Result { let issuer = input.issuer.trim(); let label = input.label.trim(); let secret = normalize_base32_secret(&input.secret); let digits = input.digits.unwrap_or(6); let period = input.period.unwrap_or(30); if issuer.is_empty() || issuer.len() > MAX_ISSUER_LEN { return Err("Название сервиса должно содержать от 1 до 80 символов".into()); } if label.is_empty() || label.len() > MAX_LABEL_LEN { return Err("Название аккаунта должно содержать от 1 до 80 символов".into()); } if secret.len() < MIN_TOTP_SECRET_LEN || secret.len() > MAX_TOTP_SECRET_LEN { return Err("TOTP secret должен содержать от 16 до 256 символов base32".into()); } if !secret.chars().all(|ch| matches!(ch, 'A'..='Z' | '2'..='7' | '=')) { return Err("TOTP secret должен быть в формате base32".into()); } if digits != 6 && digits != 8 { return Err("TOTP поддерживает только 6 или 8 цифр".into()); } if !(15..=120).contains(&period) { return Err("Период TOTP должен быть от 15 до 120 секунд".into()); } Ok(ValidationResult { ok: true, value: secret, }) } fn validate_uuid_like(value: &str) -> Result<(), String> { let normalized = value.trim(); if normalized.len() != 36 { return Err("QR-код не содержит корректный UUID сессии".into()); } for (index, ch) in normalized.chars().enumerate() { let dash = matches!(index, 8 | 13 | 18 | 23); if dash && ch != '-' { return Err("QR-код не содержит корректный UUID сессии".into()); } if !dash && !ch.is_ascii_hexdigit() { return Err("QR-код не содержит корректный UUID сессии".into()); } } Ok(()) } fn normalize_base32_secret(secret: &str) -> String { secret .trim() .chars() .filter(|ch| !ch.is_whitespace() && *ch != '-') .map(|ch| ch.to_ascii_uppercase()) .collect() } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_stronghold::Builder::new(|password| password.as_bytes().to_vec()).build()) .invoke_handler(tauri::generate_handler![ validate_qr_session_payload, validate_api_base_url, validate_totp_profile_input ]) .setup(|app| { let _ = app.path().app_data_dir().map(std::fs::create_dir_all); Ok(()) }) .run(tauri::generate_context!()) .expect("ошибка запуска Lendry ID Super App"); }