diff --git a/src-tauri/src/cmd/app.rs b/src-tauri/src/cmd/app.rs index 77a98ab69..5c32aa0c3 100644 --- a/src-tauri/src/cmd/app.rs +++ b/src-tauri/src/cmd/app.rs @@ -1,17 +1,10 @@ use super::CmdResult; use crate::core::sysopt::Sysopt; use crate::utils::resolve::ui::{self, UiReadyStage}; -use crate::{ - cmd::StringifyErr as _, - feat, - utils::dirs::{self, PathBufExec as _}, -}; +use crate::{cmd::StringifyErr as _, feat, utils::dirs}; use clash_verge_logging::{Type, logging}; use smartstring::alias::String; -use std::path::Path; use tauri::{AppHandle, Manager as _}; -use tokio::fs; -use tokio::io::AsyncWriteExt as _; /// 打开应用程序所在目录 #[tauri::command] @@ -108,131 +101,13 @@ pub fn get_auto_launch_status() -> CmdResult { /// 下载图标缓存 #[tauri::command] pub async fn download_icon_cache(url: String, name: String) -> CmdResult { - let icon_cache_dir = dirs::app_home_dir().stringify_err()?.join("icons").join("cache"); - let icon_path = icon_cache_dir.join(name.as_str()); - - if icon_path.exists() { - return Ok(icon_path.to_string_lossy().into()); - } - - if !icon_cache_dir.exists() { - let _ = fs::create_dir_all(&icon_cache_dir).await; - } - - let temp_path = icon_cache_dir.join(format!("{}.downloading", name.as_str())); - - let response = reqwest::get(url.as_str()).await.stringify_err()?; - - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - - let is_image = content_type.starts_with("image/"); - - let content = response.bytes().await.stringify_err()?; - - let is_html = content.len() > 15 - && (content.starts_with(b" file, - Err(_) => { - if icon_path.exists() { - return Ok(icon_path.to_string_lossy().into()); - } - return Err("Failed to create temporary file".into()); - } - }; - file.write_all(content.as_ref()).await.stringify_err()?; - file.flush().await.stringify_err()?; - } - - if !icon_path.exists() { - match fs::rename(&temp_path, &icon_path).await { - Ok(_) => {} - Err(_) => { - let _ = temp_path.remove_if_exists().await; - if icon_path.exists() { - return Ok(icon_path.to_string_lossy().into()); - } - } - } - } else { - let _ = temp_path.remove_if_exists().await; - } - - Ok(icon_path.to_string_lossy().into()) - } else { - let _ = temp_path.remove_if_exists().await; - Err(format!("下载的内容不是有效图片: {}", url.as_str()).into()) - } -} - -#[derive(Debug, serde::Serialize, serde::Deserialize)] -pub struct IconInfo { - name: String, - previous_t: String, - current_t: String, + feat::download_icon_cache(url, name).await } /// 复制图标文件 #[tauri::command] -pub async fn copy_icon_file(path: String, icon_info: IconInfo) -> CmdResult { - let file_path = Path::new(path.as_str()); - - let icon_dir = dirs::app_home_dir().stringify_err()?.join("icons"); - if !icon_dir.exists() { - let _ = fs::create_dir_all(&icon_dir).await; - } - let ext: String = match file_path.extension() { - Some(e) => e.to_string_lossy().into(), - None => "ico".into(), - }; - - let dest_path = icon_dir.join(format!( - "{0}-{1}.{ext}", - icon_info.name.as_str(), - icon_info.current_t.as_str() - )); - if file_path.exists() { - if icon_info.previous_t.trim() != "" { - icon_dir - .join(format!( - "{0}-{1}.png", - icon_info.name.as_str(), - icon_info.previous_t.as_str() - )) - .remove_if_exists() - .await - .unwrap_or_default(); - icon_dir - .join(format!( - "{0}-{1}.ico", - icon_info.name.as_str(), - icon_info.previous_t.as_str() - )) - .remove_if_exists() - .await - .unwrap_or_default(); - } - logging!( - info, - Type::Cmd, - "Copying icon file path: {:?} -> file dist: {:?}", - path, - dest_path - ); - match fs::copy(file_path, &dest_path).await { - Ok(_) => Ok(dest_path.to_string_lossy().into()), - Err(err) => Err(err.to_string().into()), - } - } else { - Err("file not found".into()) - } +pub async fn copy_icon_file(path: String, icon_info: feat::IconInfo) -> CmdResult { + feat::copy_icon_file(path, icon_info).await } /// 通知UI已准备就绪 diff --git a/src-tauri/src/feat/icon.rs b/src-tauri/src/feat/icon.rs new file mode 100644 index 000000000..e1423c40f --- /dev/null +++ b/src-tauri/src/feat/icon.rs @@ -0,0 +1,275 @@ +use crate::{ + cmd::{CmdResult, StringifyErr as _}, + utils::dirs::{self, PathBufExec as _}, +}; +use clash_verge_logging::{Type, logging}; +use smartstring::alias::String; +use std::path::{Component, Path, PathBuf}; +use tokio::fs; +use tokio::io::AsyncWriteExt as _; + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct IconInfo { + name: String, + previous_t: String, + current_t: String, +} + +fn normalize_icon_segment(name: &str) -> CmdResult { + let trimmed = name.trim(); + if trimmed.is_empty() || trimmed.contains('/') || trimmed.contains('\\') || trimmed.contains("..") { + return Err("invalid icon cache file name".into()); + } + + let mut components = Path::new(trimmed).components(); + match (components.next(), components.next()) { + (Some(Component::Normal(_)), None) => Ok(trimmed.into()), + _ => Err("invalid icon cache file name".into()), + } +} + +fn ensure_icon_cache_target(icon_cache_dir: &Path, file_name: &str) -> CmdResult { + let icon_path = icon_cache_dir.join(file_name); + let is_direct_child = + icon_path.parent().is_some_and(|parent| parent == icon_cache_dir) && icon_path.starts_with(icon_cache_dir); + + if !is_direct_child { + return Err("invalid icon cache file name".into()); + } + + Ok(icon_path) +} + +fn normalized_text_prefix(content: &[u8]) -> std::string::String { + let content = content.strip_prefix(&[0xEF, 0xBB, 0xBF]).unwrap_or(content); + let start = content + .iter() + .position(|byte| !byte.is_ascii_whitespace()) + .unwrap_or(content.len()); + let end = content.len().min(start.saturating_add(2048)); + let prefix = &content[start..end]; + std::string::String::from_utf8_lossy(prefix).to_ascii_lowercase() +} + +fn looks_like_html(content: &[u8]) -> bool { + let prefix = normalized_text_prefix(content); + prefix.starts_with(" bool { + let prefix = normalized_text_prefix(content); + prefix.starts_with(" bool { + if looks_like_html(content) { + return false; + } + + tauri::image::Image::from_bytes(content).is_ok() || looks_like_svg(content) +} + +pub async fn download_icon_cache(url: String, name: String) -> CmdResult { + let icon_cache_dir = dirs::app_home_dir().stringify_err()?.join("icons").join("cache"); + let icon_name = normalize_icon_segment(name.as_str())?; + let icon_path = ensure_icon_cache_target(&icon_cache_dir, icon_name.as_str())?; + + if icon_path.exists() { + return Ok(icon_path.to_string_lossy().into()); + } + + if !icon_cache_dir.exists() { + fs::create_dir_all(&icon_cache_dir).await.stringify_err()?; + } + + let temp_name = format!("{icon_name}.downloading"); + let temp_path = ensure_icon_cache_target(&icon_cache_dir, temp_name.as_str())?; + + let response = reqwest::get(url.as_str()).await.stringify_err()?; + let response = response.error_for_status().stringify_err()?; + let content = response.bytes().await.stringify_err()?; + + if !is_supported_icon_content(&content) { + let _ = temp_path.remove_if_exists().await; + return Err(format!("Downloaded content is not a valid image: {}", url.as_str()).into()); + } + + { + let mut file = match fs::File::create(&temp_path).await { + Ok(file) => file, + Err(_) => { + if icon_path.exists() { + return Ok(icon_path.to_string_lossy().into()); + } + return Err("Failed to create temporary file".into()); + } + }; + file.write_all(content.as_ref()).await.stringify_err()?; + file.flush().await.stringify_err()?; + } + + if !icon_path.exists() { + match fs::rename(&temp_path, &icon_path).await { + Ok(_) => {} + Err(_) => { + let _ = temp_path.remove_if_exists().await; + if icon_path.exists() { + return Ok(icon_path.to_string_lossy().into()); + } + } + } + } else { + let _ = temp_path.remove_if_exists().await; + } + + Ok(icon_path.to_string_lossy().into()) +} + +pub async fn copy_icon_file(path: String, icon_info: IconInfo) -> CmdResult { + let file_path = Path::new(path.as_str()); + let icon_name = normalize_icon_segment(icon_info.name.as_str())?; + let current_t = normalize_icon_segment(icon_info.current_t.as_str())?; + let previous_t = if icon_info.previous_t.trim().is_empty() { + None + } else { + Some(normalize_icon_segment(icon_info.previous_t.as_str())?) + }; + + let icon_dir = dirs::app_home_dir().stringify_err()?.join("icons"); + if !icon_dir.exists() { + fs::create_dir_all(&icon_dir).await.stringify_err()?; + } + + let ext: String = match file_path.extension() { + Some(e) => e.to_string_lossy().into(), + None => "ico".into(), + }; + + let dest_file_name = format!("{icon_name}-{current_t}.{ext}"); + let dest_path = ensure_icon_cache_target(&icon_dir, dest_file_name.as_str())?; + + if file_path.exists() { + if let Some(previous_t) = previous_t { + let previous_png = ensure_icon_cache_target(&icon_dir, format!("{icon_name}-{previous_t}.png").as_str())?; + previous_png.remove_if_exists().await.unwrap_or_default(); + let previous_ico = ensure_icon_cache_target(&icon_dir, format!("{icon_name}-{previous_t}.ico").as_str())?; + previous_ico.remove_if_exists().await.unwrap_or_default(); + } + + logging!( + info, + Type::Cmd, + "Copying icon file path: {:?} -> file dist: {:?}", + path, + dest_path + ); + + match fs::copy(file_path, &dest_path).await { + Ok(_) => Ok(dest_path.to_string_lossy().into()), + Err(err) => Err(err.to_string().into()), + } + } else { + Err("file not found".into()) + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn normalize_icon_segment_accepts_single_name() { + assert!(normalize_icon_segment("group-icon.png").is_ok()); + assert!(normalize_icon_segment("alpha_1.webp").is_ok()); + } + + #[test] + fn normalize_icon_segment_rejects_traversal_and_separators() { + for name in ["../x", "..\\x", "a/b", "a\\b", "..", "a..b"] { + assert!(normalize_icon_segment(name).is_err(), "name should be rejected: {name}"); + } + } + + #[test] + fn normalize_icon_segment_rejects_empty() { + assert!(normalize_icon_segment("").is_err()); + assert!(normalize_icon_segment(" ").is_err()); + } + + #[cfg(target_os = "windows")] + #[test] + fn normalize_icon_segment_rejects_windows_absolute_names() { + for name in [r"C:\temp\icon.png", r"\\server\share\icon.png"] { + assert!(normalize_icon_segment(name).is_err(), "name should be rejected: {name}"); + } + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn normalize_icon_segment_rejects_unix_absolute_names() { + assert!(normalize_icon_segment("/tmp/icon.png").is_err()); + } + + #[test] + fn ensure_icon_cache_target_accepts_direct_child_only() { + let base = PathBuf::from("icons").join("cache"); + let valid = ensure_icon_cache_target(&base, "ok.png"); + assert_eq!(valid.unwrap(), base.join("ok.png")); + + let nested = base.join("nested").join("ok.png"); + assert!(ensure_icon_cache_target(&base, nested.to_string_lossy().as_ref()).is_err()); + assert!(ensure_icon_cache_target(&base, "../ok.png").is_err()); + } + + #[test] + fn looks_like_svg_accepts_plain_svg() { + assert!(looks_like_svg(br#""#)); + } + + #[test] + fn looks_like_svg_accepts_xml_prefixed_svg() { + assert!(looks_like_svg( + br#""# + )); + } + + #[test] + fn looks_like_svg_accepts_doctype_svg() { + assert!(looks_like_svg( + br#""# + )); + } + + #[test] + fn looks_like_svg_accepts_bom_and_leading_whitespace() { + assert!(looks_like_svg( + b"\xEF\xBB\xBF \n\t" + )); + } + + #[test] + fn looks_like_svg_rejects_non_svg_payloads() { + assert!(!looks_like_svg(br#"{"status":"ok"}"#)); + assert!(!looks_like_svg(br"text/plain")); + } + + #[test] + fn looks_like_html_detects_common_html_prefixes() { + assert!(looks_like_html(br"")); + assert!(looks_like_html(br"oops")); + assert!(looks_like_html(br"oops")); + assert!(looks_like_html( + b"\xEF\xBB\xBF \n\toops" + )); + } + + #[test] + fn is_supported_icon_content_rejects_html_and_accepts_svg() { + assert!(!is_supported_icon_content(br"")); + assert!(is_supported_icon_content( + br#""# + )); + } +} diff --git a/src-tauri/src/feat/mod.rs b/src-tauri/src/feat/mod.rs index 02a65a442..725878664 100644 --- a/src-tauri/src/feat/mod.rs +++ b/src-tauri/src/feat/mod.rs @@ -1,6 +1,7 @@ mod backup; mod clash; mod config; +mod icon; mod profile; mod proxy; mod window; @@ -9,6 +10,7 @@ mod window; pub use backup::*; pub use clash::*; pub use config::*; +pub use icon::*; pub use profile::*; pub use proxy::*; pub use window::*;