diff --git a/VERSION b/VERSION index 6acdb44..2165f8f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.3 \ No newline at end of file +2.0.4 diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..ae23d52 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.92" +components = ["cargo", "rustfmt"] +profile = "minimal" diff --git a/varManager_backend/Cargo.lock b/varManager_backend/Cargo.lock index 3215d0d..2df7277 100644 --- a/varManager_backend/Cargo.lock +++ b/varManager_backend/Cargo.lock @@ -3522,7 +3522,7 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "varManager_backend" -version = "2.0.3" +version = "2.0.4" dependencies = [ "axum", "bytes", diff --git a/varManager_backend/Cargo.toml b/varManager_backend/Cargo.toml index 5914609..e034597 100644 --- a/varManager_backend/Cargo.toml +++ b/varManager_backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "varManager_backend" -version = "2.0.3" +version = "2.0.4" edition = "2021" [dependencies] diff --git a/varManager_backend/src/api/mod.rs b/varManager_backend/src/api/mod.rs index 8729725..a0f9f37 100644 --- a/varManager_backend/src/api/mod.rs +++ b/varManager_backend/src/api/mod.rs @@ -5,28 +5,27 @@ use axum::{ response::{IntoResponse, Response}, Json, }; -use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool}; use serde::de::{self, Deserializer, SeqAccess, Visitor}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use sqlx::{QueryBuilder, Row, Sqlite, SqlitePool}; use std::{ collections::{HashMap, HashSet}, - path::{Component, Path as StdPath, PathBuf}, + path::{Path as StdPath, PathBuf}, sync::atomic::Ordering, sync::Arc, }; use tokio::sync::Semaphore; use walkdir::WalkDir; +use crate::app::{app_root, data_dir, AppState, Config, APP_VERSION}; +use crate::infra::db; +use crate::infra::download_manager::{DownloadAction, DownloadEnqueueItem, DownloadListResponse}; +use crate::infra::paths::{is_safe_file_name, marker_path_for_file, safe_relative_path}; use crate::jobs::job_channel::{ min_job_log_level, JobLogsResponse, JobResultResponse, JobState, JobStatus, JobView, }; -use crate::infra::download_manager::{DownloadAction, DownloadEnqueueItem, DownloadListResponse}; -use crate::app::{app_root, data_dir, AppState, APP_VERSION, Config}; -use crate::infra::db; -use crate::services::image_cache::{ - CacheStats, ImageCacheError, ImageSource, ResolvedImageSource, -}; +use crate::services::image_cache::{CacheStats, ImageCacheError, ImageSource, ResolvedImageSource}; use crate::{jobs, scenes}; #[derive(Deserialize)] @@ -534,7 +533,11 @@ pub async fn get_job_logs( let request_from = query.from.unwrap_or(job.log_offset); let dropped = request_from < job.log_offset; - let from = if dropped { job.log_offset } else { request_from }; + let from = if dropped { + job.log_offset + } else { + request_from + }; let start = from.saturating_sub(job.log_offset); let entries = job .logs @@ -1053,15 +1056,30 @@ pub async fn list_vars( params.push(BindValue::Text(creator.to_string())); } } - if let Some(package) = query.package.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { + if let Some(package) = query + .package + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { conditions.push("v.packageName LIKE ?".to_string()); params.push(BindValue::Text(format!("%{}%", package))); } - if let Some(version) = query.version.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { + if let Some(version) = query + .version + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { conditions.push("v.version LIKE ?".to_string()); params.push(BindValue::Text(format!("%{}%", version))); } - if let Some(search) = query.search.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { + if let Some(search) = query + .search + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { conditions.push("(v.varName LIKE ? OR v.packageName LIKE ?)".to_string()); let like = format!("%{}%", search); params.push(BindValue::Text(like.clone())); @@ -1218,10 +1236,7 @@ pub async fn list_vars( BindValue::Float(value) => count_query.bind(*value), }; } - let total: i64 = count_query - .fetch_one(pool) - .await - .map_err(internal_error)?; + let total: i64 = count_query.fetch_one(pool).await.map_err(internal_error)?; let total = total as u64; let sql = format!( @@ -1271,14 +1286,8 @@ pub async fn list_vars( appearance: row.try_get(19).map_err(internal_error)?, dependency_cnt: row.try_get(20).map_err(internal_error)?, fsize: row.try_get(21).map_err(internal_error)?, - installed: row - .try_get::(22) - .map_err(internal_error)? - != 0, - disabled: row - .try_get::(23) - .map_err(internal_error)? - != 0, + installed: row.try_get::(22).map_err(internal_error)? != 0, + disabled: row.try_get::(23).map_err(internal_error)? != 0, }); } @@ -1299,10 +1308,9 @@ pub async fn resolve_vars( let mut resolved = HashMap::new(); for name in req.names { - let value = - crate::domain::var_logic::resolve_var_exist_name(pool, &name) - .await - .unwrap_or_else(|_| "missing".to_string()); + let value = crate::domain::var_logic::resolve_var_exist_name(pool, &name) + .await + .unwrap_or_else(|_| "missing".to_string()); resolved.insert(name, value); } Ok(Json(ResolveVarsResponse { resolved })) @@ -1338,7 +1346,10 @@ pub async fn validate_output_dir( reason: Some("directory not empty".to_string()), })); } - Ok(Json(ValidateOutputResponse { ok: true, reason: None })) + Ok(Json(ValidateOutputResponse { + ok: true, + reason: None, + })) } pub async fn save_missing_map( @@ -1384,7 +1395,8 @@ pub async fn load_missing_map( if !path.is_absolute() { path = app_root().join(&path); } - let contents = std::fs::read_to_string(&path).map_err(|err| ApiError::not_found(err.to_string()))?; + let contents = + std::fs::read_to_string(&path).map_err(|err| ApiError::not_found(err.to_string()))?; let mut links = Vec::new(); for line in contents.lines() { let line = line.trim(); @@ -1500,22 +1512,19 @@ pub async fn get_var_detail( appearance: row.try_get(19).map_err(internal_error)?, dependency_cnt: row.try_get(20).map_err(internal_error)?, fsize: row.try_get(21).map_err(internal_error)?, - installed: row - .try_get::(22) - .map_err(internal_error)? - != 0, - disabled: row - .try_get::(23) - .map_err(internal_error)? - != 0, + installed: row.try_get::(22).map_err(internal_error)? != 0, + disabled: row.try_get::(23).map_err(internal_error)? != 0, }; - let dependencies = - list_dependencies_with_status(pool, &name).await.map_err(internal_error)?; - let dependents = - list_dependents_conn(pool, &name).await.map_err(internal_error)?; - let dependent_saves = - list_dependent_saves(pool, &name).await.map_err(internal_error)?; + let dependencies = list_dependencies_with_status(pool, &name) + .await + .map_err(internal_error)?; + let dependents = list_dependents_conn(pool, &name) + .await + .map_err(internal_error)?; + let dependent_saves = list_dependent_saves(pool, &name) + .await + .map_err(internal_error)?; let scenes = list_var_scenes(pool, &name).await.map_err(internal_error)?; Ok(Json(VarDetailResponse { @@ -1544,7 +1553,12 @@ pub async fn list_scenes( let mut params: Vec = Vec::new(); let creator_list = parse_creator_list(query.creator.as_deref()); - if let Some(category) = query.category.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { + if let Some(category) = query + .category + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { conditions.push("s.atomType = ?".to_string()); params.push(BindValue::Text(category.to_string())); } @@ -1562,7 +1576,12 @@ pub async fn list_scenes( params.push(BindValue::Text(creator.to_string())); } } - if let Some(search) = query.search.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { + if let Some(search) = query + .search + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { conditions.push("(s.scenePath LIKE ? OR v.varName LIKE ?)".to_string()); let like = format!("%{}%", search); params.push(BindValue::Text(like.clone())); @@ -1625,7 +1644,13 @@ pub async fn list_scenes( for row in rows { let hide: i64 = row.try_get(13).map_err(internal_error)?; let fav: i64 = row.try_get(14).map_err(internal_error)?; - let hide_fav = if hide != 0 { -1 } else if fav != 0 { 1 } else { 0 }; + let hide_fav = if hide != 0 { + -1 + } else if fav != 0 { + 1 + } else { + 0 + }; let installed = row.try_get::(11).map_err(internal_error)? != 0; let location = if installed { "installed".to_string() @@ -1672,8 +1697,10 @@ pub async fn list_scenes( let installed_filter = parse_bool_filter(query.installed.as_deref()); let hide_fav_filter = parse_hide_fav_filter(query.hide_fav.as_deref()); let category_filter = query.category.as_ref().map(|s| s.trim().to_string()); - let creator_filter: HashSet = - creator_list.iter().map(|value| value.to_lowercase()).collect(); + let creator_filter: HashSet = creator_list + .iter() + .map(|value| value.to_lowercase()) + .collect(); let search_filter = query .search .as_ref() @@ -1829,7 +1856,11 @@ fn load_save_scenes(vampath: &StdPath) -> Vec { let mut items = Vec::new(); let groups = vec![ ("scenes", vampath.join("Saves").join("scene"), "json"), - ("looks", vampath.join("Saves").join("Person").join("full"), "json"), + ( + "looks", + vampath.join("Saves").join("Person").join("full"), + "json", + ), ( "looks", vampath.join("Saves").join("Person").join("appearance"), @@ -1837,7 +1868,11 @@ fn load_save_scenes(vampath: &StdPath) -> Vec { ), ( "looks", - vampath.join("Custom").join("Atom").join("Person").join("Appearance"), + vampath + .join("Custom") + .join("Atom") + .join("Person") + .join("Appearance"), "vap", ), ( @@ -1847,29 +1882,49 @@ fn load_save_scenes(vampath: &StdPath) -> Vec { ), ( "pose", - vampath.join("Custom").join("Atom").join("Person").join("Pose"), + vampath + .join("Custom") + .join("Atom") + .join("Person") + .join("Pose"), "vap", ), ( "clothing", - vampath.join("Custom").join("Atom").join("Person").join("Clothing"), + vampath + .join("Custom") + .join("Atom") + .join("Person") + .join("Clothing"), "vap", ), ("clothing", vampath.join("Custom").join("Clothing"), "vap"), ( "hairstyle", - vampath.join("Custom").join("Atom").join("Person").join("Hair"), + vampath + .join("Custom") + .join("Atom") + .join("Person") + .join("Hair"), "vap", ), ("hairstyle", vampath.join("Custom").join("Hair"), "vap"), ( "morphs", - vampath.join("Custom").join("Atom").join("Person").join("Morphs"), + vampath + .join("Custom") + .join("Atom") + .join("Person") + .join("Morphs"), "vap", ), ( "skin", - vampath.join("Custom").join("Atom").join("Person").join("Skin"), + vampath + .join("Custom") + .join("Atom") + .join("Person") + .join("Skin"), "vap", ), ]; @@ -1931,10 +1986,7 @@ fn load_save_scenes(vampath: &StdPath) -> Vec { items } -async fn load_missing_link_scenes( - pool: &SqlitePool, - vampath: &StdPath, -) -> Vec { +async fn load_missing_link_scenes(pool: &SqlitePool, vampath: &StdPath) -> Vec { let mut items = Vec::new(); let root = crate::infra::paths::missing_links_dir(vampath); if !root.exists() { @@ -1967,7 +2019,7 @@ async fn load_missing_link_scenes( for var_name in vars { let rows = match sqlx::query( - "SELECT s.varName, s.atomType, s.previewPic, s.scenePath, s.isPreset, s.isLoadable, + "SELECT s.varName, s.atomType, s.previewPic, s.scenePath, s.isPreset, s.isLoadable, v.creatorName, v.packageName, v.metaDate, v.varDate, v.version, COALESCE(i.installed, 0), COALESCE(i.disabled, 0) FROM scenes s @@ -1991,8 +2043,7 @@ async fn load_missing_link_scenes( Ok(value) => value, Err(_) => continue, }; - let (hide, fav, hide_fav) = - read_hide_fav_for_var(vampath, &var_name, &scene_path); + let (hide, fav, hide_fav) = read_hide_fav_for_var(vampath, &var_name, &scene_path); items.push(SceneListItem { var_name, atom_type: row.try_get(1).unwrap_or_default(), @@ -2019,11 +2070,17 @@ async fn load_missing_link_scenes( } fn read_hide_fav_for_var(vampath: &StdPath, var_name: &str, scene_path: &str) -> (bool, bool, i32) { - let scenepath = StdPath::new(scene_path) + if !is_safe_file_name(var_name) { + return (false, false, 0); + } + let Ok(scene_rel) = safe_relative_path(scene_path, "scene path") else { + return (false, false, 0); + }; + let scenepath = scene_rel .parent() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_default(); - let scenename = StdPath::new(scene_path) + let scenename = scene_rel .file_name() .and_then(|s| s.to_str()) .unwrap_or("") @@ -2035,22 +2092,28 @@ fn read_hide_fav_for_var(vampath: &StdPath, var_name: &str, scene_path: &str) -> let pathfav = base.join(format!("{}.fav", scenename)); let hide = pathhide.exists(); let fav = pathfav.exists(); - let hide_fav = if hide { -1 } else if fav { 1 } else { 0 }; + let hide_fav = if hide { + -1 + } else if fav { + 1 + } else { + 0 + }; (hide, fav, hide_fav) } fn read_hide_fav_for_save(path: &StdPath) -> (bool, bool, i32) { - let hide = path.with_extension(format!( - "{}.hide", - path.extension().and_then(|s| s.to_str()).unwrap_or("") - )); - let fav = path.with_extension(format!( - "{}.fav", - path.extension().and_then(|s| s.to_str()).unwrap_or("") - )); + let hide = marker_path_for_file(path, "hide"); + let fav = marker_path_for_file(path, "fav"); let hide_exists = hide.exists(); let fav_exists = fav.exists(); - let hide_fav = if hide_exists { -1 } else if fav_exists { 1 } else { 0 }; + let hide_fav = if hide_exists { + -1 + } else if fav_exists { + 1 + } else { + 0 + }; (hide_exists, fav_exists, hide_fav) } @@ -2165,7 +2228,10 @@ pub async fn list_packswitch( } } } - if !switches.iter().any(|name| name.eq_ignore_ascii_case("default")) { + if !switches + .iter() + .any(|name| name.eq_ignore_ascii_case("default")) + { switches.push("default".to_string()); } switches.sort_by_key(|a| a.to_ascii_lowercase()); @@ -2177,10 +2243,7 @@ pub async fn list_packswitch( let resolved = if target.is_absolute() { target } else { - link_root - .parent() - .unwrap_or(&addon_path) - .join(target) + link_root.parent().unwrap_or(&addon_path).join(target) }; if resolved.starts_with(&switch_root) { resolved @@ -2196,10 +2259,7 @@ pub async fn list_packswitch( let resolved = if target.is_absolute() { target } else { - addon_path - .parent() - .unwrap_or(&addon_path) - .join(target) + addon_path.parent().unwrap_or(&addon_path).join(target) }; if resolved.starts_with(&switch_root) { resolved @@ -2235,9 +2295,8 @@ pub async fn list_var_dependencies( let pool = &state.db_pool; - let mut builder = QueryBuilder::new( - "SELECT varName, dependency FROM dependencies WHERE varName IN (", - ); + let mut builder = + QueryBuilder::new("SELECT varName, dependency FROM dependencies WHERE varName IN ("); let mut separated = builder.separated(", "); for name in &names { separated.push_bind(name); @@ -2378,15 +2437,18 @@ pub async fn get_analysis_summary( Ok(Json(summary)) } -pub async fn list_saves_tree( - State(state): State, -) -> ApiResult> { +pub async fn list_saves_tree(State(state): State) -> ApiResult> { let (_, vampath) = crate::infra::paths::config_paths(&state).map_err(internal_error)?; - let vampath = vampath - .ok_or_else(|| ApiError::bad_request("vampath is required in config.json"))?; + let vampath = + vampath.ok_or_else(|| ApiError::bad_request("vampath is required in config.json"))?; let groups = vec![ - ("scenes", "[Scenes]: ./Saves/scene", vampath.join("Saves").join("scene"), "json"), + ( + "scenes", + "[Scenes]: ./Saves/scene", + vampath.join("Saves").join("scene"), + "json", + ), ( "appearance", "[Appearance]: ./Saves/Person/appearance", @@ -2396,7 +2458,11 @@ pub async fn list_saves_tree( ( "presets", "[Appearance Presets]: ./Custom/Atom/Person/Appearance", - vampath.join("Custom").join("Atom").join("Person").join("Appearance"), + vampath + .join("Custom") + .join("Atom") + .join("Person") + .join("Appearance"), "vap", ), ]; @@ -2453,9 +2519,7 @@ pub async fn list_saves_tree( })) } -pub async fn get_stats( - State(state): State, -) -> ApiResult> { +pub async fn get_stats(State(state): State) -> ApiResult> { let _cfg = read_config(&state).map_err(internal_error)?; let pool = &state.db_pool; @@ -2463,18 +2527,16 @@ pub async fn get_stats( .fetch_one(pool) .await .map_err(internal_error)? as u64; - let vars_installed: u64 = sqlx::query_scalar::<_, i64>( - "SELECT COUNT(1) FROM installStatus WHERE installed = 1", - ) - .fetch_one(pool) - .await - .map_err(internal_error)? as u64; - let vars_disabled: u64 = sqlx::query_scalar::<_, i64>( - "SELECT COUNT(1) FROM installStatus WHERE disabled = 1", - ) - .fetch_one(pool) - .await - .map_err(internal_error)? as u64; + let vars_installed: u64 = + sqlx::query_scalar::<_, i64>("SELECT COUNT(1) FROM installStatus WHERE installed = 1") + .fetch_one(pool) + .await + .map_err(internal_error)? as u64; + let vars_disabled: u64 = + sqlx::query_scalar::<_, i64>("SELECT COUNT(1) FROM installStatus WHERE disabled = 1") + .fetch_one(pool) + .await + .map_err(internal_error)? as u64; let scenes_total: u64 = sqlx::query_scalar::<_, i64>("SELECT COUNT(1) FROM scenes") .fetch_one(pool) .await @@ -2518,19 +2580,13 @@ pub async fn get_preview( } fn safe_join(base: &StdPath, relative: &str) -> Result { - let rel = PathBuf::from(relative); - for comp in rel.components() { - match comp { - Component::ParentDir | Component::Prefix(_) | Component::RootDir => { - return Err("invalid preview path".to_string()) - } - Component::CurDir | Component::Normal(_) => {} - } - } - Ok(base.join(rel)) + Ok(base.join(safe_relative_path(relative, "preview path")?)) } -fn parse_image_source(state: &AppState, query: PreviewQuery) -> Result { +fn parse_image_source( + state: &AppState, + query: PreviewQuery, +) -> Result { if let Some(source) = query .source .as_ref() @@ -2622,9 +2678,7 @@ fn resolve_local_source( fn map_image_cache_error(err: ImageCacheError) -> ApiError { match err { ImageCacheError::NotFound(message) => ApiError::not_found(message), - ImageCacheError::Network(message) => { - ApiError::new(StatusCode::BAD_GATEWAY, message) - } + ImageCacheError::Network(message) => ApiError::new(StatusCode::BAD_GATEWAY, message), ImageCacheError::HttpStatus { status, url } => ApiError::new( StatusCode::BAD_GATEWAY, format!("upstream status {} for {}", status, url), @@ -2641,9 +2695,7 @@ fn map_image_cache_error(err: ImageCacheError) -> ApiError { } } -pub async fn get_cache_stats( - State(state): State, -) -> ApiResult> { +pub async fn get_cache_stats(State(state): State) -> ApiResult> { let stats = state.image_cache.stats().await.map_err(internal_error)?; Ok(Json(stats)) } @@ -2680,8 +2732,7 @@ async fn list_dependencies_with_status( .try_get::, _>(0) .map_err(|err| err.to_string())? { - let mut resolved = - crate::domain::var_logic::resolve_var_exist_name(pool, &dep).await?; + let mut resolved = crate::domain::var_logic::resolve_var_exist_name(pool, &dep).await?; let mut closest = false; if resolved.ends_with('$') { closest = true; @@ -2699,10 +2750,7 @@ async fn list_dependencies_with_status( Ok(result) } -async fn list_dependents_conn( - pool: &SqlitePool, - var_name: &str, -) -> Result, String> { +async fn list_dependents_conn(pool: &SqlitePool, var_name: &str) -> Result, String> { let mut names = Vec::new(); let targets = dependency_targets(pool, var_name).await?; for dep in targets { @@ -2725,10 +2773,7 @@ async fn list_dependents_conn( Ok(names) } -async fn list_dependent_saves( - pool: &SqlitePool, - var_name: &str, -) -> Result, String> { +async fn list_dependent_saves(pool: &SqlitePool, var_name: &str) -> Result, String> { let mut names = Vec::new(); let targets = dependency_targets(pool, var_name).await?; for dep in targets { @@ -2765,32 +2810,19 @@ async fn list_var_scenes( let mut items = Vec::new(); for row in rows { items.push(ScenePreviewItem { - atom_type: row - .try_get::(0) - .map_err(|err| err.to_string())?, + atom_type: row.try_get::(0).map_err(|err| err.to_string())?, preview_pic: row .try_get::, _>(1) .map_err(|err| err.to_string())?, - scene_path: row - .try_get::(2) - .map_err(|err| err.to_string())?, - is_preset: row - .try_get::(3) - .map_err(|err| err.to_string())? - != 0, - is_loadable: row - .try_get::(4) - .map_err(|err| err.to_string())? - != 0, + scene_path: row.try_get::(2).map_err(|err| err.to_string())?, + is_preset: row.try_get::(3).map_err(|err| err.to_string())? != 0, + is_loadable: row.try_get::(4).map_err(|err| err.to_string())? != 0, }); } Ok(items) } -async fn dependency_targets( - pool: &SqlitePool, - var_name: &str, -) -> Result, String> { +async fn dependency_targets(pool: &SqlitePool, var_name: &str) -> Result, String> { let parts: Vec<&str> = var_name.split('.').collect(); if parts.len() != 3 { return Ok(vec![var_name.to_string()]); @@ -2824,3 +2856,20 @@ async fn is_var_latest( } Ok(max_ver.map(|max| current >= max).unwrap_or(true)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn safe_join_rejects_scheme_like_relative_paths() { + assert!(safe_join(StdPath::new("C:\\VaM"), "save:/Saves/scene/Foo.jpg").is_err()); + } + + #[test] + fn safe_join_accepts_normal_relative_paths() { + let path = safe_join(StdPath::new("C:\\VaM"), "Saves/scene/Foo.jpg").unwrap(); + + assert_eq!(path, PathBuf::from("C:\\VaM\\Saves\\scene\\Foo.jpg")); + } +} diff --git a/varManager_backend/src/app/mod.rs b/varManager_backend/src/app/mod.rs index 65eb9ca..7dd20f4 100644 --- a/varManager_backend/src/app/mod.rs +++ b/varManager_backend/src/app/mod.rs @@ -5,10 +5,7 @@ use sqlx::SqlitePool; use std::{ env, path::PathBuf, - sync::{ - atomic::AtomicU64, - Arc, RwLock, - }, + sync::{atomic::AtomicU64, Arc, RwLock}, }; use sysinfo::{Pid, ProcessesToUpdate, System}; use tokio::{ @@ -57,8 +54,16 @@ impl ProxyConfig { } else { host.to_string() }; - let username = self.username.as_ref().map(|v| v.trim()).filter(|v| !v.is_empty()); - let password = self.password.as_ref().map(|v| v.trim()).filter(|v| !v.is_empty()); + let username = self + .username + .as_ref() + .map(|v| v.trim()) + .filter(|v| !v.is_empty()); + let password = self + .password + .as_ref() + .map(|v| v.trim()) + .filter(|v| !v.is_empty()); let mut auth = String::new(); if let Some(user) = username { auth.push_str(user); diff --git a/varManager_backend/src/domain/var_logic.rs b/varManager_backend/src/domain/var_logic.rs index 9823235..c2006d7 100644 --- a/varManager_backend/src/domain/var_logic.rs +++ b/varManager_backend/src/domain/var_logic.rs @@ -2,10 +2,7 @@ use crate::infra::db::list_var_versions; use sqlx::{Row, SqlitePool}; use std::collections::HashSet; -pub async fn resolve_var_exist_name( - pool: &SqlitePool, - var_name: &str, -) -> Result { +pub async fn resolve_var_exist_name(pool: &SqlitePool, var_name: &str) -> Result { let parts: Vec<&str> = var_name.split('.').collect(); if parts.len() != 3 { return Ok("missing".to_string()); @@ -222,13 +219,11 @@ async fn list_dependencies_for_var( } async fn var_exists(pool: &SqlitePool, var_name: &str) -> Result { - let exists = sqlx::query_scalar::<_, i64>( - "SELECT 1 FROM vars WHERE varName = ?1 LIMIT 1", - ) - .bind(var_name) - .fetch_optional(pool) - .await - .map_err(|err| err.to_string())?; + let exists = sqlx::query_scalar::<_, i64>("SELECT 1 FROM vars WHERE varName = ?1 LIMIT 1") + .bind(var_name) + .fetch_optional(pool) + .await + .map_err(|err| err.to_string())?; Ok(exists.is_some()) } diff --git a/varManager_backend/src/infra/db.rs b/varManager_backend/src/infra/db.rs index eb2c30c..ffaf9d9 100644 --- a/varManager_backend/src/infra/db.rs +++ b/varManager_backend/src/infra/db.rs @@ -221,13 +221,11 @@ pub async fn ensure_schema(pool: &SqlitePool) -> Result<(), String> { } pub async fn var_exists_conn(pool: &SqlitePool, var_name: &str) -> Result { - let exists = sqlx::query_scalar::<_, i64>( - "SELECT 1 FROM vars WHERE varName = ?1 LIMIT 1", - ) - .bind(var_name) - .fetch_optional(pool) - .await - .map_err(|err| err.to_string())?; + let exists = sqlx::query_scalar::<_, i64>("SELECT 1 FROM vars WHERE varName = ?1 LIMIT 1") + .bind(var_name) + .fetch_optional(pool) + .await + .map_err(|err| err.to_string())?; Ok(exists.is_some()) } @@ -268,9 +266,7 @@ pub async fn list_dependencies_all(pool: &SqlitePool) -> Result, Str Ok(deps) } -pub async fn list_dependencies_for_installed( - pool: &SqlitePool, -) -> Result, String> { +pub async fn list_dependencies_for_installed(pool: &SqlitePool) -> Result, String> { let rows = sqlx::query( "SELECT d.dependency FROM dependencies d \ JOIN installStatus i ON d.varName = i.varName \ @@ -296,9 +292,8 @@ pub async fn list_dependencies_for_vars( if var_names.is_empty() { return Ok(Vec::new()); } - let mut builder = sqlx::QueryBuilder::new( - "SELECT dependency FROM dependencies WHERE varName IN (", - ); + let mut builder = + sqlx::QueryBuilder::new("SELECT dependency FROM dependencies WHERE varName IN ("); let mut separated = builder.separated(", "); for name in var_names { separated.push_bind(name); @@ -451,16 +446,14 @@ pub async fn replace_hide_fav( if !entry.hide && !entry.fav { continue; } - sqlx::query( - "INSERT INTO HideFav (varName, scenePath, hide, fav) VALUES (?1, ?2, ?3, ?4)", - ) - .bind(var_name) - .bind(&entry.scene_path) - .bind(if entry.hide { 1 } else { 0 }) - .bind(if entry.fav { 1 } else { 0 }) - .execute(tx.as_mut()) - .await - .map_err(|err| err.to_string())?; + sqlx::query("INSERT INTO HideFav (varName, scenePath, hide, fav) VALUES (?1, ?2, ?3, ?4)") + .bind(var_name) + .bind(&entry.scene_path) + .bind(if entry.hide { 1 } else { 0 }) + .bind(if entry.fav { 1 } else { 0 }) + .execute(tx.as_mut()) + .await + .map_err(|err| err.to_string())?; } Ok(()) } @@ -542,10 +535,7 @@ pub async fn delete_var_related( Ok(()) } -pub async fn delete_var_related_conn( - pool: &SqlitePool, - var_name: &str, -) -> Result<(), String> { +pub async fn delete_var_related_conn(pool: &SqlitePool, var_name: &str) -> Result<(), String> { sqlx::query("DELETE FROM dependencies WHERE varName = ?1") .bind(var_name) .execute(pool) diff --git a/varManager_backend/src/infra/download_manager.rs b/varManager_backend/src/infra/download_manager.rs index 75ac680..b3670a9 100644 --- a/varManager_backend/src/infra/download_manager.rs +++ b/varManager_backend/src/infra/download_manager.rs @@ -1,16 +1,14 @@ use crate::app::Config; use crate::infra::downloader::{ ensure_dir, finalize_download, is_retryable_error, resolve_download_save_path_config, - resolve_file_info, resolve_final_url_with_retry, -}; -use http_downloader::{ - speed_limiter::DownloadSpeedLimiterExtension, - speed_tracker::DownloadSpeedTrackerExtension, - status_tracker::DownloadStatusTrackerExtension, - DownloadingEndCause, HttpDownloaderBuilder, + resolve_file_info, resolve_final_url_with_retry, sanitize_download_filename, }; use dashmap::DashMap; use headers::{HeaderMap, HeaderName, HeaderValue}; +use http_downloader::{ + speed_limiter::DownloadSpeedLimiterExtension, speed_tracker::DownloadSpeedTrackerExtension, + status_tracker::DownloadStatusTrackerExtension, DownloadingEndCause, HttpDownloaderBuilder, +}; use reqwest::Client; use serde::Serialize; use sqlx::{Row, SqlitePool}; @@ -211,7 +209,9 @@ impl DownloadManager { }; for row in rows { - let status: String = row.try_get("status").unwrap_or_else(|_| "queued".to_string()); + let status: String = row + .try_get("status") + .unwrap_or_else(|_| "queued".to_string()); let downloaded_bytes: i64 = row.try_get("downloaded_bytes").unwrap_or(0); let total_bytes: Option = row.try_get("total_bytes").ok(); let speed_bytes: i64 = row.try_get("speed_bytes").unwrap_or(0); @@ -312,13 +312,11 @@ impl DownloadManager { async fn delete_ids(&self, ids: Vec) -> Result<(), String> { for id in ids { self.cancel_active(id).await; - let row = sqlx::query( - "SELECT save_path, temp_path FROM downloads WHERE id = ?1", - ) - .bind(id) - .fetch_optional(&self.db_pool) - .await - .map_err(|err| err.to_string())?; + let row = sqlx::query("SELECT save_path, temp_path FROM downloads WHERE id = ?1") + .bind(id) + .fetch_optional(&self.db_pool) + .await + .map_err(|err| err.to_string())?; if let Some(row) = row { let save_path: Option = row.try_get("save_path").ok(); let temp_path: Option = row.try_get("temp_path").ok(); @@ -360,13 +358,11 @@ impl DownloadManager { if self.active.contains_key(&id) { return Ok(()); } - let row = sqlx::query( - "SELECT url, name FROM downloads WHERE id = ?1", - ) - .bind(id) - .fetch_optional(&self.db_pool) - .await - .map_err(|err| err.to_string())?; + let row = sqlx::query("SELECT url, name FROM downloads WHERE id = ?1") + .bind(id) + .fetch_optional(&self.db_pool) + .await + .map_err(|err| err.to_string())?; let Some(row) = row else { return Ok(()); }; @@ -376,12 +372,7 @@ impl DownloadManager { return Ok(()); } let (cancel_tx, cancel_rx) = watch::channel(false); - self.active.insert( - id, - DownloadHandle { - cancel: cancel_tx, - }, - ); + self.active.insert(id, DownloadHandle { cancel: cancel_tx }); let db_pool = self.db_pool.clone(); let client = Arc::clone(&self.client); let config = Arc::clone(&self.config); @@ -392,7 +383,13 @@ impl DownloadManager { let _permit = match semaphore.acquire().await { Ok(permit) => permit, Err(_) => { - let _ = update_status(&db_pool, id, "failed", Some("failed to acquire download slot".to_string())).await; + let _ = update_status( + &db_pool, + id, + "failed", + Some("failed to acquire download slot".to_string()), + ) + .await; active.remove(&id); return; } @@ -441,21 +438,22 @@ async fn download_with_progress( // Priority: Content-Disposition filename > name_hint > URL filename // Content-Disposition from server is the most accurate source - let final_name = if filename != "default_filename" && filename.to_lowercase().ends_with(".var") { + let final_name = if filename != "default_filename" && filename.to_lowercase().ends_with(".var") + { // Server returned a valid .var filename, use it - filename + sanitize_download_filename(&filename) } else if let Some(hint) = name_hint { // Fallback to name_hint if provided let trimmed = hint.trim(); if trimmed.is_empty() { - filename + sanitize_download_filename(&filename) } else if trimmed.to_lowercase().ends_with(".var") { - trimmed.to_string() + sanitize_download_filename(trimmed) } else { - format!("{}.var", trimmed) + sanitize_download_filename(&format!("{}.var", trimmed)) } } else { - filename + sanitize_download_filename(&filename) }; let url_obj = Url::parse(&final_url).map_err(|err| err.to_string())?; let temp_path = download_temp_path(&url_obj, &save_dir); @@ -575,25 +573,34 @@ fn download_temp_path(url: &Url, save_dir: &Path) -> PathBuf { .path_segments() .and_then(|mut s| s.next_back()) .unwrap_or("unknown_temp_file"); - save_dir.join(filename) + save_dir.join(sanitize_download_filename(filename)) } fn read_runtime_config(config: &Arc>) -> DownloadRuntimeConfig { let defaults = crate::app::DownloadConfig::default(); - let cfg = config.read().map(|guard| guard.download.clone()).unwrap_or(defaults.clone()); - let concurrency = if cfg.concurrency >= 1 { cfg.concurrency } else { defaults.concurrency }; - let connection_count = NonZeroU8::new(cfg.connection_count.max(1)).unwrap_or_else(|| { - NonZeroU8::new(defaults.connection_count.max(1)).unwrap() - }); - let chunk_mb = if cfg.chunk_size_mb >= 1 { cfg.chunk_size_mb } else { defaults.chunk_size_mb }; + let cfg = config + .read() + .map(|guard| guard.download.clone()) + .unwrap_or(defaults.clone()); + let concurrency = if cfg.concurrency >= 1 { + cfg.concurrency + } else { + defaults.concurrency + }; + let connection_count = NonZeroU8::new(cfg.connection_count.max(1)) + .unwrap_or_else(|| NonZeroU8::new(defaults.connection_count.max(1)).unwrap()); + let chunk_mb = if cfg.chunk_size_mb >= 1 { + cfg.chunk_size_mb + } else { + defaults.chunk_size_mb + }; let mut chunk_bytes = chunk_mb.saturating_mul(1024 * 1024); if chunk_bytes == 0 { chunk_bytes = 1024 * 1024; } let chunk_bytes = chunk_bytes.min(usize::MAX as u64) as usize; - let chunk_size = NonZeroUsize::new(chunk_bytes).unwrap_or_else(|| { - NonZeroUsize::new(1024 * 1024).unwrap() - }); + let chunk_size = + NonZeroUsize::new(chunk_bytes).unwrap_or_else(|| NonZeroUsize::new(1024 * 1024).unwrap()); let http_timeout_secs = if cfg.http_timeout_secs > 0 { cfg.http_timeout_secs } else { @@ -647,7 +654,10 @@ fn hub_headers_compat() -> HeaderMap { HeaderName::from_static("cookie"), HeaderValue::from_static("vamhubconsent=yes"), ); - headers.insert(HeaderName::from_static("dnt"), HeaderValue::from_static("1")); + headers.insert( + HeaderName::from_static("dnt"), + HeaderValue::from_static("1"), + ); headers.insert( HeaderName::from_static("sec-ch-ua"), HeaderValue::from_static( @@ -698,16 +708,14 @@ async fn update_status( error: Option, ) -> Result<(), String> { let now = now_ts(); - sqlx::query( - "UPDATE downloads SET status = ?1, error = ?2, updated_at = ?3 WHERE id = ?4", - ) - .bind(status) - .bind(error) - .bind(now) - .bind(id) - .execute(db_pool) - .await - .map_err(|err| err.to_string())?; + sqlx::query("UPDATE downloads SET status = ?1, error = ?2, updated_at = ?3 WHERE id = ?4") + .bind(status) + .bind(error) + .bind(now) + .bind(id) + .execute(db_pool) + .await + .map_err(|err| err.to_string())?; Ok(()) } @@ -733,21 +741,15 @@ async fn update_progress( Ok(()) } -async fn update_total_bytes( - db_pool: &SqlitePool, - id: i64, - total: u64, -) -> Result<(), String> { +async fn update_total_bytes(db_pool: &SqlitePool, id: i64, total: u64) -> Result<(), String> { let now = now_ts(); - sqlx::query( - "UPDATE downloads SET total_bytes = ?1, updated_at = ?2 WHERE id = ?3", - ) - .bind(total as i64) - .bind(now) - .bind(id) - .execute(db_pool) - .await - .map_err(|err| err.to_string())?; + sqlx::query("UPDATE downloads SET total_bytes = ?1, updated_at = ?2 WHERE id = ?3") + .bind(total as i64) + .bind(now) + .bind(id) + .execute(db_pool) + .await + .map_err(|err| err.to_string())?; Ok(()) } @@ -782,3 +784,32 @@ fn delete_file(path: &str) -> Result<(), String> { fn now_ts() -> i64 { chrono::Local::now().timestamp() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_download_filename_strips_path_segments() { + assert_eq!(sanitize_download_filename("../evil.var"), "evil.var"); + assert_eq!(sanitize_download_filename("C:\\Temp\\evil.var"), "evil.var"); + } + + #[test] + fn sanitize_download_filename_removes_windows_invalid_chars() { + assert_eq!(sanitize_download_filename("bad:name?.var"), "badname.var"); + } + + #[test] + fn sanitize_download_filename_falls_back_when_empty() { + assert_eq!(sanitize_download_filename(":/\\*?"), "default_filename"); + } + + #[test] + fn download_temp_path_sanitizes_url_leaf() { + let url = Url::parse("https://example.invalid/files/bad:name.var").unwrap(); + let path = download_temp_path(&url, Path::new("C:\\Downloads")); + + assert_eq!(path, PathBuf::from("C:\\Downloads\\badname.var")); + } +} diff --git a/varManager_backend/src/infra/downloader.rs b/varManager_backend/src/infra/downloader.rs index 47cb69f..84e833c 100644 --- a/varManager_backend/src/infra/downloader.rs +++ b/varManager_backend/src/infra/downloader.rs @@ -3,11 +3,10 @@ use crate::app::{app_root, AppState}; use crate::infra::paths::addon_packages_dir; use crate::jobs::job_channel::JobReporter; +use crate::util; use http_downloader::{ - speed_limiter::DownloadSpeedLimiterExtension, - speed_tracker::DownloadSpeedTrackerExtension, - status_tracker::DownloadStatusTrackerExtension, - DownloadingEndCause, HttpDownloaderBuilder, + speed_limiter::DownloadSpeedLimiterExtension, speed_tracker::DownloadSpeedTrackerExtension, + status_tracker::DownloadStatusTrackerExtension, DownloadingEndCause, HttpDownloaderBuilder, }; use percent_encoding::percent_decode; use regex::Regex; @@ -150,7 +149,10 @@ pub async fn download_urls( )); if summary.failed > 0 && !failures.is_empty() { for failure in failures { - reporter.log(format!("Failure sample: {} -> {}", failure.url, failure.error)); + reporter.log(format!( + "Failure sample: {} -> {}", + failure.url, failure.error + )); } } @@ -180,7 +182,10 @@ pub(crate) fn ensure_dir(path: &Path) -> Result<(), String> { if path.is_dir() { return Ok(()); } - return Err(format!("save path exists but is not a directory: {}", path.display())); + return Err(format!( + "save path exists but is not a directory: {}", + path.display() + )); } fs::create_dir_all(path).map_err(|err| err.to_string()) } @@ -231,26 +236,23 @@ async fn download_one( let mut attempt: u8 = 0; loop { attempt += 1; - let result = timeout( - Duration::from_secs(PER_FILE_TIMEOUT_SECS), - async { - let (mut downloader, (_status_state, _speed_state, _speed_limiter, ..)) = - HttpDownloaderBuilder::new(download_url_obj.clone(), save_dir.clone()) - .chunk_size(NonZeroUsize::new(1024 * 1024 * 10).unwrap()) - .download_connection_count(NonZeroU8::new(4).unwrap()) - .build(( - DownloadStatusTrackerExtension { log: false }, - DownloadSpeedTrackerExtension { log: false }, - DownloadSpeedLimiterExtension::new(None), - )); - - let download_future = downloader - .prepare_download() - .map_err(|err| err.to_string())?; - let dec = download_future.await.map_err(|err| err.to_string())?; - Ok::(dec) - }, - ) + let result = timeout(Duration::from_secs(PER_FILE_TIMEOUT_SECS), async { + let (mut downloader, (_status_state, _speed_state, _speed_limiter, ..)) = + HttpDownloaderBuilder::new(download_url_obj.clone(), save_dir.clone()) + .chunk_size(NonZeroUsize::new(1024 * 1024 * 10).unwrap()) + .download_connection_count(NonZeroU8::new(4).unwrap()) + .build(( + DownloadStatusTrackerExtension { log: false }, + DownloadSpeedTrackerExtension { log: false }, + DownloadSpeedLimiterExtension::new(None), + )); + + let download_future = downloader + .prepare_download() + .map_err(|err| err.to_string())?; + let dec = download_future.await.map_err(|err| err.to_string())?; + Ok::(dec) + }) .await; match result { @@ -267,10 +269,7 @@ async fn download_one( tokio::time::sleep(Duration::from_secs(2)).await; continue; } - return Err(format!( - "download timed out after {} attempts", - attempt - )); + return Err(format!("download timed out after {} attempts", attempt)); } } } @@ -311,7 +310,10 @@ pub(crate) async fn resolve_final_url_with_retry( if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); - return Err(format!("GET {} failed with status {}: {}", url, status, body)); + return Err(format!( + "GET {} failed with status {}: {}", + url, status, body + )); } Ok(url.to_string()) } @@ -354,7 +356,7 @@ pub(crate) async fn resolve_file_info( .filter(|size| *size > 0); let content_disposition = response.headers().get(header::CONTENT_DISPOSITION); - let mut extracted = if let Some(cd_val) = content_disposition { + let extracted = if let Some(cd_val) = content_disposition { let cd_str = percent_decode(cd_val.as_bytes()) .decode_utf8() .unwrap_or_else(|_| "".into()); @@ -379,24 +381,16 @@ pub(crate) async fn resolve_file_info( .unwrap_or_else(|| "default_filename".to_string()) }; - extracted = extracted.trim_end_matches(';').to_string(); - let invalid_chars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']; - for c in invalid_chars.iter() { - extracted = extracted.replace(*c, "_"); - } - if extracted.is_empty() { - extracted = "default_filename".to_string(); - } - Ok((extracted, content_length)) + Ok((sanitize_download_filename(&extracted), content_length)) } pub(crate) fn finalize_download(url: &Url, save_dir: &Path, filename: &str) -> Result<(), String> { - let downloaded_file_path = save_dir.join( + let downloaded_file_path = save_dir.join(sanitize_download_filename( url.path_segments() .and_then(|mut s| s.next_back()) .unwrap_or("unknown_temp_file"), - ); - let new_file_path = save_dir.join(filename); + )); + let new_file_path = save_dir.join(sanitize_download_filename(filename)); if downloaded_file_path == new_file_path { verify_file_size(&new_file_path)?; @@ -420,6 +414,23 @@ pub(crate) fn finalize_download(url: &Url, save_dir: &Path, filename: &str) -> R )) } +pub(crate) fn sanitize_download_filename(raw: &str) -> String { + let trimmed = raw.trim().trim_end_matches(';'); + let leaf = trimmed + .rsplit(['/', '\\']) + .find(|part| !part.is_empty()) + .unwrap_or(trimmed); + let cleaned = util::valid_file_name(leaf) + .trim() + .trim_matches('.') + .to_string(); + if cleaned.is_empty() { + "default_filename".to_string() + } else { + cleaned + } +} + fn verify_file_size(path: &Path) -> Result<(), String> { let metadata = fs::metadata(path).map_err(|err| err.to_string())?; if metadata.len() == 0 { @@ -448,10 +459,7 @@ pub(crate) fn hub_headers() -> header::HeaderMap { header::ACCEPT_ENCODING, "gzip, deflate, br, zstd".parse().unwrap(), ); - headers.insert( - header::ACCEPT_LANGUAGE, - "en-US,en;q=0.9".parse().unwrap(), - ); + headers.insert(header::ACCEPT_LANGUAGE, "en-US,en;q=0.9".parse().unwrap()); headers.insert(header::COOKIE, "vamhubconsent=yes".parse().unwrap()); headers.insert(header::DNT, "1".parse().unwrap()); headers.insert( @@ -502,7 +510,9 @@ pub(crate) fn resolve_download_save_path(state: &AppState) -> Result Result { +pub(crate) fn resolve_download_save_path_config( + cfg: &crate::app::Config, +) -> Result { if let Some(path) = cfg .downloader_save_path .as_ref() diff --git a/varManager_backend/src/infra/mod.rs b/varManager_backend/src/infra/mod.rs index 04c273a..bad0405 100644 --- a/varManager_backend/src/infra/mod.rs +++ b/varManager_backend/src/infra/mod.rs @@ -1,6 +1,6 @@ pub mod db; -pub mod downloader; pub mod download_manager; +pub mod downloader; pub mod fs_util; pub mod paths; pub mod system_ops; diff --git a/varManager_backend/src/infra/paths.rs b/varManager_backend/src/infra/paths.rs index c74a5a8..91bc2b9 100644 --- a/varManager_backend/src/infra/paths.rs +++ b/varManager_backend/src/infra/paths.rs @@ -1,5 +1,6 @@ use crate::app::AppState; -use std::path::{Path, PathBuf}; +use crate::util; +use std::path::{Component, Path, PathBuf}; pub const TIDIED_DIR: &str = "___VarTidied___"; pub const INSTALL_LINK_DIR: &str = "___VarsLink___"; @@ -39,9 +40,57 @@ pub fn normalize_path(value: &str) -> Option { } } +pub fn safe_relative_path(relative: &str, label: &str) -> Result { + let relative = PathBuf::from(relative.replace('/', "\\")); + if relative.as_os_str().is_empty() { + return Err(format!("{} is empty", label)); + } + for component in relative.components() { + match component { + Component::ParentDir | Component::Prefix(_) | Component::RootDir => { + return Err(format!("invalid {}", label)); + } + Component::CurDir => {} + Component::Normal(name) => { + if name.to_string_lossy().contains(':') { + return Err(format!("invalid {}", label)); + } + } + } + } + Ok(relative) +} + +pub fn is_safe_file_name(name: &str) -> bool { + !name.is_empty() + && name != "." + && name != ".." + && !name.ends_with(['.', ' ']) + && util::valid_file_name(name) == name +} + +pub fn validate_file_name(name: &str, label: &str) -> Result { + let name = name.trim(); + if is_safe_file_name(name) { + Ok(name.to_string()) + } else { + Err(format!("invalid {}", label)) + } +} + +pub fn marker_path_for_file(path: &Path, marker: &str) -> PathBuf { + let extension = path.extension().and_then(|s| s.to_str()).unwrap_or(""); + if extension.is_empty() { + path.with_extension(marker) + } else { + path.with_extension(format!("{}.{}", extension, marker)) + } +} + pub fn resolve_var_file_path(varspath: &Path, var_name: &str) -> Result { let parts: Vec<&str> = var_name.split('.').collect(); - if parts.len() != 3 { + if parts.len() != 3 || parts.iter().any(|part| part.is_empty()) || !is_safe_file_name(var_name) + { return Err(format!("invalid var name: {}", var_name)); } let creator = parts[0]; @@ -89,3 +138,38 @@ pub fn feelfar_dir(vampath: &Path) -> PathBuf { pub fn loadscene_path(vampath: &Path) -> PathBuf { feelfar_dir(vampath).join(LOADSCENE_FILE) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn safe_relative_path_rejects_paths_outside_root() { + assert!(safe_relative_path("../Foo.json", "scene path").is_err()); + assert!(safe_relative_path("C:/Foo.json", "scene path").is_err()); + assert!(safe_relative_path("/Saves/scene/Foo.json", "scene path").is_err()); + assert!(safe_relative_path("Saves/scene/Foo:bar.json", "scene path").is_err()); + } + + #[test] + fn validate_file_name_rejects_path_like_names() { + assert_eq!( + validate_file_name(" creator.package.1 ", "var name").unwrap(), + "creator.package.1" + ); + assert!(validate_file_name("creator/package.1", "var name").is_err()); + assert!(validate_file_name("creator:package.1", "var name").is_err()); + assert!(validate_file_name(".", "var name").is_err()); + assert!(validate_file_name("name.", "var name").is_err()); + } + + #[test] + fn resolve_var_file_path_rejects_path_like_var_names() { + let root = Path::new("C:\\Vars"); + + assert!(resolve_var_file_path(root, "creator/package.name.1").is_err()); + assert!(resolve_var_file_path(root, "creator\\package.name.1").is_err()); + assert!(resolve_var_file_path(root, "creator:package.name.1").is_err()); + assert!(resolve_var_file_path(root, "creator.package").is_err()); + } +} diff --git a/varManager_backend/src/infra/system_ops.rs b/varManager_backend/src/infra/system_ops.rs index c972148..d56a776 100644 --- a/varManager_backend/src/infra/system_ops.rs +++ b/varManager_backend/src/infra/system_ops.rs @@ -1,5 +1,5 @@ -use crate::infra::paths::loadscene_path; use crate::app::AppState; +use crate::infra::paths::loadscene_path; use serde_json::json; use std::fs; use std::path::{Path, PathBuf}; diff --git a/varManager_backend/src/infra/winfs.rs b/varManager_backend/src/infra/winfs.rs index fae0b19..4ad1301 100644 --- a/varManager_backend/src/infra/winfs.rs +++ b/varManager_backend/src/infra/winfs.rs @@ -8,10 +8,10 @@ use windows::Win32::Foundation::{ WIN32_ERROR, }; use windows::Win32::Storage::FileSystem::{ - CreateFileW, CreateSymbolicLinkW, SetFileTime, FILE_ATTRIBUTE_NORMAL, FILE_FLAG_BACKUP_SEMANTICS, - FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, - FILE_WRITE_ATTRIBUTES, OPEN_EXISTING, SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE, - SYMBOLIC_LINK_FLAG_DIRECTORY, SYMBOLIC_LINK_FLAGS, + CreateFileW, CreateSymbolicLinkW, SetFileTime, FILE_ATTRIBUTE_NORMAL, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_DELETE, FILE_SHARE_READ, + FILE_SHARE_WRITE, FILE_WRITE_ATTRIBUTES, OPEN_EXISTING, SYMBOLIC_LINK_FLAGS, + SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE, SYMBOLIC_LINK_FLAG_DIRECTORY, }; const WINDOWS_EPOCH_OFFSET_SECS: u64 = 11_644_473_600; @@ -45,7 +45,12 @@ pub fn set_symlink_file_times( None, ) } - .map_err(|err| format!("CreateFileW for file time failed ({}).", format_hresult_error(err)))?; + .map_err(|err| { + format!( + "CreateFileW for file time failed ({}).", + format_hresult_error(err) + ) + })?; let creation = system_time_to_filetime(created); let last_write = system_time_to_filetime(modified); @@ -54,11 +59,17 @@ pub fn set_symlink_file_times( if let Err(err) = close_result { if set_result.is_ok() { - return Err(format!("CloseHandle failed ({}).", format_hresult_error(err))); + return Err(format!( + "CloseHandle failed ({}).", + format_hresult_error(err) + )); } } if let Err(err) = set_result { - return Err(format!("SetFileTime failed ({}).", format_hresult_error(err))); + return Err(format!( + "SetFileTime failed ({}).", + format_hresult_error(err) + )); } Ok(()) diff --git a/varManager_backend/src/jobs/deps_jobs.rs b/varManager_backend/src/jobs/deps_jobs.rs index 6e071a4..a19a719 100644 --- a/varManager_backend/src/jobs/deps_jobs.rs +++ b/varManager_backend/src/jobs/deps_jobs.rs @@ -1,20 +1,20 @@ +use crate::app::AppState; +use crate::domain::var_logic::{resolve_var_exist_name, vars_dependencies}; use crate::infra::db::upsert_install_status; use crate::infra::fs_util; -use crate::jobs::job_channel::JobReporter; use crate::infra::paths::{config_paths, resolve_var_file_path, INSTALL_LINK_DIR}; -use crate::domain::var_logic::{resolve_var_exist_name, vars_dependencies}; -use crate::app::AppState; use crate::infra::winfs; +use crate::jobs::job_channel::JobReporter; use chrono::{DateTime, Local}; use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::Value; +use sqlx::{Row, SqlitePool}; use std::collections::HashSet; use std::fs; use std::io::Read; use std::path::{Path, PathBuf}; use walkdir::WalkDir; -use sqlx::{Row, SqlitePool}; #[derive(Deserialize)] struct SavesDepsArgs {} @@ -60,7 +60,11 @@ pub async fn run_log_deps_job( .map_err(|err| err.to_string())? } -fn saves_deps_blocking(state: &AppState, reporter: &JobReporter, _args: SavesDepsArgs) -> Result<(), String> { +fn saves_deps_blocking( + state: &AppState, + reporter: &JobReporter, + _args: SavesDepsArgs, +) -> Result<(), String> { let (varspath, vampath) = config_paths(state)?; let vampath = vampath.ok_or_else(|| "vampath is required in config.json".to_string())?; reporter.log("SavesDeps start".to_string()); @@ -116,14 +120,13 @@ fn saves_deps_blocking(state: &AppState, reporter: &JobReporter, _args: SavesDep let installed = collect_installed_names(&vampath); dependencies.retain(|dep| !installed.contains(dep)); - let (missing, installed_now) = - handle.block_on(install_missing_dependencies( - reporter, - pool, - &varspath, - &vampath, - &dependencies, - ))?; + let (missing, installed_now) = handle.block_on(install_missing_dependencies( + reporter, + pool, + &varspath, + &vampath, + &dependencies, + ))?; reporter.set_result( serde_json::to_value(DepsJobResult { @@ -139,7 +142,11 @@ fn saves_deps_blocking(state: &AppState, reporter: &JobReporter, _args: SavesDep Ok(()) } -fn log_deps_blocking(state: &AppState, reporter: &JobReporter, _args: LogDepsArgs) -> Result<(), String> { +fn log_deps_blocking( + state: &AppState, + reporter: &JobReporter, + _args: LogDepsArgs, +) -> Result<(), String> { let (varspath, vampath) = config_paths(state)?; let vampath = vampath.ok_or_else(|| "vampath is required in config.json".to_string())?; reporter.log("LogDeps start".to_string()); diff --git a/varManager_backend/src/jobs/hub.rs b/varManager_backend/src/jobs/hub.rs index 5183ee0..3f507f6 100644 --- a/varManager_backend/src/jobs/hub.rs +++ b/varManager_backend/src/jobs/hub.rs @@ -1,29 +1,40 @@ -use crate::jobs::job_channel::JobReporter; -use crate::domain::var_logic::resolve_var_exist_name; use crate::app::AppState; +use crate::domain::var_logic::resolve_var_exist_name; +use crate::jobs::job_channel::JobReporter; use reqwest::blocking::Client; use reqwest::header; +use scraper::{Html, Selector}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use sqlx::{QueryBuilder, Row, SqlitePool}; use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use std::sync::{Mutex, OnceLock}; use std::time::{Duration, SystemTime}; -use scraper::{Html, Selector}; -use sqlx::{QueryBuilder, Row, SqlitePool}; const HUB_API: &str = "https://hub.virtamate.com/citizenx/api.php"; const HUB_PACKAGES: &str = "https://s3cdn.virtamate.com/data/packages.json"; type DownloadUrlMaps = (HashMap, HashMap); -type DownloadUrlMapsWithSizes = - (HashMap, HashMap, HashMap); +type DownloadUrlMapsWithSizes = ( + HashMap, + HashMap, + HashMap, +); -#[derive(Deserialize)] +#[derive(Deserialize, Default)] pub struct HubFindPackagesArgs { + #[serde(default)] pub packages: Vec, } +fn parse_hub_find_packages_args(args: Option) -> Result { + match args { + None | Some(Value::Null) => Ok(HubFindPackagesArgs::default()), + Some(value) => serde_json::from_value(value).map_err(|err| err.to_string()), + } +} + #[derive(Deserialize)] pub struct HubResourcesQuery { pub perpage: Option, @@ -93,9 +104,7 @@ pub async fn run_hub_missing_scan_job( args: Option, ) -> Result<(), String> { tokio::task::spawn_blocking(move || { - let args: HubFindPackagesArgs = - args.map_or_else(|| Ok(HubFindPackagesArgs { packages: Vec::new() }), serde_json::from_value) - .map_err(|err| err.to_string())?; + let args = parse_hub_find_packages_args(args)?; missing_scan_blocking(&state, &reporter, args) }) .await @@ -107,11 +116,9 @@ pub async fn run_hub_updates_scan_job( reporter: JobReporter, _args: Option, ) -> Result<(), String> { - tokio::task::spawn_blocking(move || { - updates_scan_blocking(&state, &reporter) - }) - .await - .map_err(|err| err.to_string())? + tokio::task::spawn_blocking(move || updates_scan_blocking(&state, &reporter)) + .await + .map_err(|err| err.to_string())? } pub async fn run_hub_download_all_job( @@ -149,7 +156,8 @@ pub async fn run_hub_resources_job( ) -> Result<(), String> { tokio::task::spawn_blocking(move || { let args = args.ok_or_else(|| "hub_resources args required".to_string())?; - let query: HubResourcesQuery = serde_json::from_value(args).map_err(|err| err.to_string())?; + let query: HubResourcesQuery = + serde_json::from_value(args).map_err(|err| err.to_string())?; let resources = get_resources(query)?; reporter.set_result(resources); Ok(()) @@ -200,10 +208,7 @@ pub async fn run_hub_overview_panel_job( let args: HubResourceDetailArgs = serde_json::from_value(args).map_err(|err| err.to_string())?; let overview_data = get_overview_panel(&args.resource_id)?; - reporter.set_result( - serde_json::to_value(&overview_data) - .map_err(|err| err.to_string())?, - ); + reporter.set_result(serde_json::to_value(&overview_data).map_err(|err| err.to_string())?); Ok(()) }) .await @@ -272,22 +277,22 @@ fn updates_scan_blocking(state: &AppState, reporter: &JobReporter) -> Result<(), let pool = &state.db_pool; let handle = tokio::runtime::Handle::current(); - let hub_packages = fetch_hub_packages()?; - let mut newest_by_package: HashMap = HashMap::new(); - for (filename, download_id) in hub_packages { + let hub_packages = fetch_hub_package_filenames()?; + let mut newest_by_package: HashMap = HashMap::new(); + for filename in hub_packages { let name = filename.trim_end_matches(".var"); if let Some((base, version)) = split_var_version(name) { if let Ok(ver) = version.parse::() { - let entry = newest_by_package.entry(base.to_string()).or_insert((ver, download_id.clone())); - if ver > entry.0 { - *entry = (ver, download_id.clone()); + let entry = newest_by_package.entry(base.to_string()).or_insert(ver); + if ver > *entry { + *entry = ver; } } } } let mut to_update = Vec::new(); - for (base, (hub_ver, _)) in newest_by_package.iter() { + for (base, hub_ver) in newest_by_package.iter() { let latest_name = format!("{}.latest", base); let exist = handle.block_on(resolve_var_exist_name(pool, &latest_name))?; if exist != "missing" { @@ -327,13 +332,23 @@ async fn download_all_async( if url.is_empty() { continue; } - let entry = merged.entry(url.clone()).or_insert(crate::infra::download_manager::DownloadEnqueueItem { - url: url.clone(), - name: item.name.clone(), - size: item.size, - }); - if entry.name.as_ref().map(|v| v.trim().is_empty()).unwrap_or(true) - && item.name.as_ref().map(|v| !v.trim().is_empty()).unwrap_or(false) + let entry = merged.entry(url.clone()).or_insert( + crate::infra::download_manager::DownloadEnqueueItem { + url: url.clone(), + name: item.name.clone(), + size: item.size, + }, + ); + if entry + .name + .as_ref() + .map(|v| v.trim().is_empty()) + .unwrap_or(true) + && item + .name + .as_ref() + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) { entry.name = item.name.clone(); } @@ -348,11 +363,13 @@ async fn download_all_async( if trimmed.is_empty() { continue; } - merged.entry(trimmed.clone()).or_insert(crate::infra::download_manager::DownloadEnqueueItem { - url: trimmed, - name: None, - size: None, - }); + merged.entry(trimmed.clone()).or_insert( + crate::infra::download_manager::DownloadEnqueueItem { + url: trimmed, + name: None, + size: None, + }, + ); } } if merged.is_empty() { @@ -464,7 +481,9 @@ pub fn search_hub_options( if refresh || guard.is_none() { *guard = Some(load_hub_options(refresh)?); } - let options = guard.clone().ok_or_else(|| "hub options empty".to_string())?; + let options = guard + .clone() + .ok_or_else(|| "hub options empty".to_string())?; let mut items = match kind { "location" => options.locations, "paytype" => options.pay_types, @@ -580,9 +599,7 @@ pub fn get_resource_detail(resource_id: &str) -> Result { resp.json::().map_err(|err| err.to_string()) } -pub fn find_packages_maps( - packages: &[String], -) -> Result { +pub fn find_packages_maps(packages: &[String]) -> Result { if packages.is_empty() { return Ok((HashMap::new(), HashMap::new())); } @@ -655,14 +672,31 @@ async fn collect_missing_dependencies(pool: &SqlitePool) -> Result, Ok(missing) } -fn fetch_hub_packages() -> Result, String> { +fn fetch_hub_package_filenames() -> Result, String> { let client = Client::new(); let resp = client .get(HUB_PACKAGES) .send() - .map_err(|err| err.to_string())?; - resp.json::>() - .map_err(|err| err.to_string()) + .map_err(|err| format!("failed to request Hub package index: {err}"))? + .error_for_status() + .map_err(|err| format!("Hub package index request failed: {err}"))?; + let json = resp + .json::() + .map_err(|err| format!("failed to decode Hub package index: {err}"))?; + parse_hub_package_filenames(json) +} + +fn parse_hub_package_filenames(json: Value) -> Result, String> { + let Value::Object(map) = json else { + return Err("Hub package index expected a JSON object".to_string()); + }; + let mut filenames: Vec = map + .keys() + .filter(|filename| filename.ends_with(".var")) + .cloned() + .collect(); + filenames.sort(); + Ok(filenames) } fn split_var_version(name: &str) -> Option<(&str, &str)> { @@ -674,16 +708,10 @@ fn parse_file_size(value: Option<&Value>) -> Option { if let Some(size) = value.as_i64() { return Some(size); } - value - .as_str() - .and_then(|size| size.parse::().ok()) + value.as_str().and_then(|size| size.parse::().ok()) } -fn record_download_size( - download_sizes: &mut HashMap, - url: &str, - size: Option, -) { +fn record_download_size(download_sizes: &mut HashMap, url: &str, size: Option) { let Some(size) = size else { return }; if size <= 0 { return; @@ -813,10 +841,7 @@ fn hub_headers() -> header::HeaderMap { .parse() .unwrap(), ); - headers.insert( - header::ACCEPT_LANGUAGE, - "en-US,en;q=0.9".parse().unwrap(), - ); + headers.insert(header::ACCEPT_LANGUAGE, "en-US,en;q=0.9".parse().unwrap()); headers.insert(header::COOKIE, "vamhubconsent=yes".parse().unwrap()); headers.insert( header::USER_AGENT, @@ -828,7 +853,10 @@ fn hub_headers() -> header::HeaderMap { } pub fn get_overview_panel(resource_id: &str) -> Result { - let url = format!("https://hub.virtamate.com/resources/{}/overview-panel", resource_id); + let url = format!( + "https://hub.virtamate.com/resources/{}/overview-panel", + resource_id + ); let client = Client::new(); let response = client @@ -838,7 +866,10 @@ pub fn get_overview_panel(resource_id: &str) -> Result Result Result = None; let ld_selector = Selector::parse("script[type=\"application/ld+json\"]").unwrap(); for element in document.select(&ld_selector) { - let json_text = element.text().collect::>().join("").trim().to_string(); + let json_text = element + .text() + .collect::>() + .join("") + .trim() + .to_string(); if json_text.is_empty() { continue; } @@ -1049,6 +1088,58 @@ mod tests { assert_eq!(no_version.get("other.item").unwrap(), "c"); } + #[test] + fn parse_hub_find_packages_args_defaults_empty_packages() { + let none = parse_hub_find_packages_args(None).unwrap(); + assert!(none.packages.is_empty()); + + let null = parse_hub_find_packages_args(Some(Value::Null)).unwrap(); + assert!(null.packages.is_empty()); + + let empty_object = parse_hub_find_packages_args(Some(json!({}))).unwrap(); + assert!(empty_object.packages.is_empty()); + } + + #[test] + fn parse_hub_find_packages_args_accepts_packages() { + let args = parse_hub_find_packages_args(Some(json!({ + "packages": ["AcidBubbles.Timeline.latest", "Creator.Package.1"] + }))) + .unwrap(); + + assert_eq!( + args.packages, + vec![ + "AcidBubbles.Timeline.latest".to_string(), + "Creator.Package.1".to_string(), + ] + ); + } + + #[test] + fn parse_hub_package_filenames_reads_keys_with_mixed_values() { + let filenames = parse_hub_package_filenames(json!({ + "Creator.Asset.1.var": 123, + "Creator.Asset.2.var": "456", + "ignore.txt": 789 + })) + .unwrap(); + + assert_eq!( + filenames, + vec![ + "Creator.Asset.1.var".to_string(), + "Creator.Asset.2.var".to_string(), + ] + ); + } + + #[test] + fn parse_hub_package_filenames_rejects_non_object() { + let error = parse_hub_package_filenames(json!([])).unwrap_err(); + assert!(error.contains("JSON object")); + } + #[test] fn is_filter_value_rejects_all() { assert!(!is_filter_value("all")); diff --git a/varManager_backend/src/jobs/job_channel.rs b/varManager_backend/src/jobs/job_channel.rs index a61afe0..1485565 100644 --- a/varManager_backend/src/jobs/job_channel.rs +++ b/varManager_backend/src/jobs/job_channel.rs @@ -389,10 +389,16 @@ mod tests { #[test] fn infer_log_level_detects_prefixes() { - assert!(matches!(infer_log_level("[warn] something"), JobLogLevel::Warn)); + assert!(matches!( + infer_log_level("[warn] something"), + JobLogLevel::Warn + )); assert!(matches!(infer_log_level("error: bad"), JobLogLevel::Error)); assert!(matches!(infer_log_level("info: ok"), JobLogLevel::Info)); - assert!(matches!(infer_log_level("plain message"), JobLogLevel::Info)); + assert!(matches!( + infer_log_level("plain message"), + JobLogLevel::Info + )); } #[test] diff --git a/varManager_backend/src/jobs/links.rs b/varManager_backend/src/jobs/links.rs index e0d4ba4..5ffdc2f 100644 --- a/varManager_backend/src/jobs/links.rs +++ b/varManager_backend/src/jobs/links.rs @@ -1,9 +1,11 @@ +use crate::app::AppState; use crate::infra::db::{upsert_install_status, var_exists_conn}; use crate::infra::fs_util; -use crate::jobs::job_channel::JobReporter; -use crate::infra::paths::{config_paths, resolve_var_file_path, INSTALL_LINK_DIR, MISSING_LINK_DIR}; -use crate::app::AppState; +use crate::infra::paths::{ + config_paths, resolve_var_file_path, validate_file_name, INSTALL_LINK_DIR, MISSING_LINK_DIR, +}; use crate::infra::winfs; +use crate::jobs::job_channel::JobReporter; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::fs; @@ -67,9 +69,13 @@ pub async fn run_rebuild_links_job( ) -> Result<(), String> { tokio::task::spawn_blocking(move || { let args = args - .map(|value| serde_json::from_value::(value).map_err(|e| e.to_string())) + .map(|value| { + serde_json::from_value::(value).map_err(|e| e.to_string()) + }) .transpose()? - .unwrap_or(RebuildLinksArgs { include_missing: true }); + .unwrap_or(RebuildLinksArgs { + include_missing: true, + }); rebuild_links_blocking(&state, &reporter, args) }) .await @@ -104,7 +110,11 @@ pub async fn run_missing_links_create_job( .map_err(|err| err.to_string())? } -fn rebuild_links_blocking(state: &AppState, reporter: &JobReporter, args: RebuildLinksArgs) -> Result<(), String> { +fn rebuild_links_blocking( + state: &AppState, + reporter: &JobReporter, + args: RebuildLinksArgs, +) -> Result<(), String> { let (varspath, vampath) = config_paths(state)?; let vampath = vampath.ok_or_else(|| "vampath is required in config.json".to_string())?; reporter.log("RebuildLinks start".to_string()); @@ -135,11 +145,7 @@ fn rebuild_links_blocking(state: &AppState, reporter: &JobReporter, args: Rebuil let target = match winfs::read_link_target(link_path) { Ok(target) => target, Err(err) => { - reporter.log(format!( - "skip non-link {} ({})", - link_path.display(), - err - )); + reporter.log(format!("skip non-link {} ({})", link_path.display(), err)); skipped += 1; continue; } @@ -221,25 +227,38 @@ fn rebuild_links_blocking(state: &AppState, reporter: &JobReporter, args: Rebuil Ok(()) } -fn move_links_blocking(state: &AppState, reporter: &JobReporter, args: MoveLinksArgs) -> Result<(), String> { +fn move_links_blocking( + state: &AppState, + reporter: &JobReporter, + args: MoveLinksArgs, +) -> Result<(), String> { let (_, vampath) = config_paths(state)?; let vampath = vampath.ok_or_else(|| "vampath is required in config.json".to_string())?; let target_dir = args.target_dir.trim(); if target_dir.is_empty() { return Err("target_dir is required".to_string()); } + let target_dir = validate_file_name(target_dir, "target_dir")?; let link_root = vampath.join("AddonPackages").join(INSTALL_LINK_DIR); fs::create_dir_all(&link_root).map_err(|err| err.to_string())?; - let dest_dir = link_root.join(target_dir); + let dest_dir = link_root.join(&target_dir); fs::create_dir_all(&dest_dir).map_err(|err| err.to_string())?; let total = args.var_names.len(); let mut moved = 0; let mut skipped = 0; - for var_name in &args.var_names { - let match_path = find_link_path(&link_root, var_name); + for raw_var_name in &args.var_names { + let var_name = match validate_file_name(raw_var_name, "var name") { + Ok(name) => name, + Err(err) => { + reporter.log(format!("skip {} ({})", raw_var_name, err)); + skipped += 1; + continue; + } + }; + let match_path = find_link_path(&link_root, &var_name); let Some(src) = match_path else { skipped += 1; continue; @@ -291,6 +310,14 @@ fn missing_links_create_blocking( skipped += 1; continue; } + missing_var = match validate_file_name(&missing_var, "missing var name") { + Ok(name) => name, + Err(err) => { + reporter.log(format!("missing link skip {} ({})", missing_var, err)); + failed += 1; + continue; + } + }; let matches = find_missing_matches(&missing_dir, &missing_var); for old in matches { @@ -311,6 +338,14 @@ fn missing_links_create_blocking( } } } + missing_var = match validate_file_name(&missing_var, "missing var name") { + Ok(name) => name, + Err(err) => { + reporter.log(format!("missing link skip {} ({})", missing_var, err)); + failed += 1; + continue; + } + }; let dest = match resolve_var_file_path(&varspath, dest_var) { Ok(path) => path, @@ -352,7 +387,10 @@ fn find_link_path(root: &Path, var_name: &str) -> Option { let walker = WalkDir::new(root).follow_links(false).into_iter(); for entry in walker.filter_map(|e| e.ok()) { if entry.file_type().is_file() - && entry.file_name().to_string_lossy().eq_ignore_ascii_case(&target) + && entry + .file_name() + .to_string_lossy() + .eq_ignore_ascii_case(&target) { return Some(entry.path().to_path_buf()); } @@ -364,7 +402,9 @@ fn find_missing_matches(root: &Path, missing_var: &str) -> Vec { let mut matches = Vec::new(); let is_latest = missing_var.to_ascii_lowercase().ends_with(".latest"); let target_base = if is_latest { - missing_var.rsplit_once('.').map(|(base, _)| base.to_string()) + missing_var + .rsplit_once('.') + .map(|(base, _)| base.to_string()) } else { None }; diff --git a/varManager_backend/src/jobs/missing_deps.rs b/varManager_backend/src/jobs/missing_deps.rs index ce6f925..c7acb15 100644 --- a/varManager_backend/src/jobs/missing_deps.rs +++ b/varManager_backend/src/jobs/missing_deps.rs @@ -1,16 +1,16 @@ +use crate::app::AppState; use crate::infra::db::{ list_dependencies_all, list_dependencies_for_installed, list_dependencies_for_vars, list_var_versions, upsert_install_status, var_exists_conn, }; -use crate::jobs::job_channel::JobReporter; use crate::infra::paths::{config_paths, resolve_var_file_path, INSTALL_LINK_DIR}; -use crate::app::AppState; use crate::infra::winfs; +use crate::jobs::job_channel::JobReporter; use serde::{Deserialize, Serialize}; use serde_json::Value; +use sqlx::SqlitePool; use std::fs; use std::path::Path; -use sqlx::SqlitePool; #[derive(Deserialize)] struct MissingDepsArgs { @@ -35,15 +35,18 @@ pub async fn run_missing_deps_job( ) -> Result<(), String> { tokio::task::spawn_blocking(move || { let args = args.ok_or_else(|| "missing_deps args required".to_string())?; - let args: MissingDepsArgs = - serde_json::from_value(args).map_err(|err| err.to_string())?; + let args: MissingDepsArgs = serde_json::from_value(args).map_err(|err| err.to_string())?; missing_deps_blocking(&state, &reporter, args) }) .await .map_err(|err| err.to_string())? } -fn missing_deps_blocking(state: &AppState, reporter: &JobReporter, args: MissingDepsArgs) -> Result<(), String> { +fn missing_deps_blocking( + state: &AppState, + reporter: &JobReporter, + args: MissingDepsArgs, +) -> Result<(), String> { reporter.log(format!("MissingDeps start: scope={}", args.scope)); reporter.progress(1); @@ -139,14 +142,16 @@ fn missing_deps_blocking(state: &AppState, reporter: &JobReporter, args: Missing install_failed.sort(); install_failed.dedup(); - reporter.set_result(serde_json::to_value(MissingDepsResult { - scope: args.scope, - missing, - installed, - install_failed, - dependency_count: total, - }) - .map_err(|err| err.to_string())?); + reporter.set_result( + serde_json::to_value(MissingDepsResult { + scope: args.scope, + missing, + installed, + install_failed, + dependency_count: total, + }) + .map_err(|err| err.to_string())?, + ); reporter.progress(100); reporter.log("MissingDeps completed".to_string()); @@ -312,7 +317,9 @@ mod tests { insert_var(&pool, "creator.pkg.1", "creator", "pkg", "1").await; insert_var(&pool, "creator.pkg.10", "creator", "pkg", "10").await; - let resolved = resolve_dependency(&pool, "creator.pkg.latest").await.unwrap(); + let resolved = resolve_dependency(&pool, "creator.pkg.latest") + .await + .unwrap(); match resolved { ResolvedDep::Found(name) => assert_eq!(name, "creator.pkg.10"), _ => panic!("expected latest version match"), diff --git a/varManager_backend/src/jobs/mod.rs b/varManager_backend/src/jobs/mod.rs index 9edc228..2ba6904 100644 --- a/varManager_backend/src/jobs/mod.rs +++ b/varManager_backend/src/jobs/mod.rs @@ -63,19 +63,25 @@ pub async fn dispatch( "missing_deps" => { missing_deps::run_missing_deps_job(state.clone(), reporter.clone(), args).await } - "rebuild_links" => links::run_rebuild_links_job(state.clone(), reporter.clone(), args).await, + "rebuild_links" => { + links::run_rebuild_links_job(state.clone(), reporter.clone(), args).await + } "links_move" => links::run_move_links_job(state.clone(), reporter.clone(), args).await, "links_missing_create" => { links::run_missing_links_create_job(state.clone(), reporter.clone(), args).await } - "install_vars" => vars_jobs::run_install_vars_job(state.clone(), reporter.clone(), args).await, + "install_vars" => { + vars_jobs::run_install_vars_job(state.clone(), reporter.clone(), args).await + } "preview_uninstall" => { vars_jobs::run_preview_uninstall_job(state.clone(), reporter.clone(), args).await } "uninstall_vars" => { vars_jobs::run_uninstall_vars_job(state.clone(), reporter.clone(), args).await } - "delete_vars" => vars_jobs::run_delete_vars_job(state.clone(), reporter.clone(), args).await, + "delete_vars" => { + vars_jobs::run_delete_vars_job(state.clone(), reporter.clone(), args).await + } "vars_export_installed" => { vars_misc::run_export_installed_job(state.clone(), reporter.clone(), args).await } @@ -91,7 +97,9 @@ pub async fn dispatch( } "saves_deps" => deps_jobs::run_saves_deps_job(state.clone(), reporter.clone(), args).await, "log_deps" => deps_jobs::run_log_deps_job(state.clone(), reporter.clone(), args).await, - "fix_previews" => preview_jobs::run_fix_previews_job(state.clone(), reporter.clone(), args).await, + "fix_previews" => { + preview_jobs::run_fix_previews_job(state.clone(), reporter.clone(), args).await + } "stale_vars" => stale_jobs::run_stale_vars_job(state.clone(), reporter.clone(), args).await, "old_version_vars" => { stale_jobs::run_old_version_vars_job(state.clone(), reporter.clone(), args).await @@ -108,9 +116,15 @@ pub async fn dispatch( "packswitch_set" => { packswitch::run_packswitch_set_job(state.clone(), reporter.clone(), args).await } - "hub_missing_scan" => hub::run_hub_missing_scan_job(state.clone(), reporter.clone(), args).await, - "hub_updates_scan" => hub::run_hub_updates_scan_job(state.clone(), reporter.clone(), args).await, - "hub_download_all" => hub::run_hub_download_all_job(state.clone(), reporter.clone(), args).await, + "hub_missing_scan" => { + hub::run_hub_missing_scan_job(state.clone(), reporter.clone(), args).await + } + "hub_updates_scan" => { + hub::run_hub_updates_scan_job(state.clone(), reporter.clone(), args).await + } + "hub_download_all" => { + hub::run_hub_download_all_job(state.clone(), reporter.clone(), args).await + } "hub_info" => hub::run_hub_info_job(state.clone(), reporter.clone()).await, "hub_resources" => hub::run_hub_resources_job(state.clone(), reporter.clone(), args).await, "hub_resource_detail" => { @@ -123,7 +137,9 @@ pub async fn dispatch( hub::run_hub_find_packages_job(state.clone(), reporter.clone(), args).await } "scene_load" => scenes::run_scene_load_job(state.clone(), reporter.clone(), args).await, - "scene_analyze" => scenes::run_scene_analyze_job(state.clone(), reporter.clone(), args).await, + "scene_analyze" => { + scenes::run_scene_analyze_job(state.clone(), reporter.clone(), args).await + } "scene_preset_look" => { scenes::run_scene_preset_look_job(state.clone(), reporter.clone(), args).await } @@ -139,7 +155,9 @@ pub async fn dispatch( "scene_preset_scene" => { scenes::run_scene_preset_scene_job(state.clone(), reporter.clone(), args).await } - "scene_add_atoms" => scenes::run_scene_add_atoms_job(state.clone(), reporter.clone(), args).await, + "scene_add_atoms" => { + scenes::run_scene_add_atoms_job(state.clone(), reporter.clone(), args).await + } "scene_add_subscene" => { scenes::run_scene_add_subscene_job(state.clone(), reporter.clone(), args).await } @@ -166,8 +184,8 @@ mod tests { use crate::jobs::job_channel::{create_job_channel, create_job_map}; use crate::services::image_cache::ImageCacheService; use sqlx::sqlite::SqlitePoolOptions; - use std::sync::{Arc, RwLock}; use std::sync::atomic::AtomicU64; + use std::sync::{Arc, RwLock}; use tokio::sync::{oneshot, Semaphore}; async fn build_state() -> AppState { @@ -187,7 +205,10 @@ mod tests { .await .unwrap(), ); - let download_manager = Arc::new(DownloadManager::new(pool.clone(), Arc::clone(&config_state))); + let download_manager = Arc::new(DownloadManager::new( + pool.clone(), + Arc::clone(&config_state), + )); AppState { config: Arc::clone(&config_state), shutdown_tx: Arc::new(tokio::sync::Mutex::new(None::>)), diff --git a/varManager_backend/src/jobs/packswitch.rs b/varManager_backend/src/jobs/packswitch.rs index d9a199f..97185c1 100644 --- a/varManager_backend/src/jobs/packswitch.rs +++ b/varManager_backend/src/jobs/packswitch.rs @@ -1,18 +1,18 @@ +use crate::app::AppState; use crate::infra::db::{upsert_install_status, var_exists_conn}; use crate::infra::fs_util; -use crate::jobs::job_channel::JobReporter; use crate::infra::paths::{ - addon_packages_dir, addon_switch_root, config_paths, INSTALL_LINK_DIR, MISSING_LINK_DIR, - TEMP_LINK_DIR, + addon_packages_dir, addon_switch_root, config_paths, validate_file_name, INSTALL_LINK_DIR, + MISSING_LINK_DIR, TEMP_LINK_DIR, }; -use crate::app::AppState; use crate::infra::{system_ops, winfs}; +use crate::jobs::job_channel::JobReporter; use serde::{Deserialize, Serialize}; use serde_json::Value; +use sqlx::SqlitePool; use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; -use sqlx::SqlitePool; use walkdir::WalkDir; #[derive(Deserialize)] @@ -98,18 +98,19 @@ pub async fn run_packswitch_set_job( tokio::task::spawn_blocking(move || { let args = args.ok_or_else(|| "packswitch_set args required".to_string())?; let args: PackSwitchArgs = serde_json::from_value(args).map_err(|err| err.to_string())?; - let outcome = set_switch_blocking(&state, &reporter, &args.name)?; - let result = match outcome { - PackSwitchSetOutcome::Switched => PackSwitchSetResult { - status: "switched".to_string(), - name: args.name, - addon_path: None, - }, - PackSwitchSetOutcome::UpdateDbRequired { addon_path } => PackSwitchSetResult { - status: "update_db_required".to_string(), - name: args.name, - addon_path: Some(addon_path.display().to_string()), - }, + let name = validate_switch_name(&args.name)?; + let outcome = set_switch_blocking(&state, &reporter, &name)?; + let (status, addon_path) = match outcome { + PackSwitchSetOutcome::Switched => ("switched".to_string(), None), + PackSwitchSetOutcome::UpdateDbRequired { addon_path } => ( + "update_db_required".to_string(), + Some(addon_path.display().to_string()), + ), + }; + let result = PackSwitchSetResult { + status, + name, + addon_path, }; reporter.set_result(serde_json::to_value(result).map_err(|err| err.to_string())?); Ok(()) @@ -118,49 +119,45 @@ pub async fn run_packswitch_set_job( .map_err(|err| err.to_string())? } -fn add_switch_blocking(state: &AppState, reporter: &JobReporter, args: PackSwitchArgs) -> Result<(), String> { +fn add_switch_blocking( + state: &AppState, + reporter: &JobReporter, + args: PackSwitchArgs, +) -> Result<(), String> { let (_, vampath) = config_paths(state)?; let vampath = vampath.ok_or_else(|| "vampath is required in config.json".to_string())?; - let name = args.name.trim(); - if name.is_empty() { - return Err("switch name is required".to_string()); - } + let name = validate_switch_name(&args.name)?; let root = addon_switch_root(&vampath); fs::create_dir_all(&root).map_err(|err| err.to_string())?; - let target = root.join(name); + let target = root.join(&name); if target.exists() { return Err(format!("switch already exists: {}", name)); } fs::create_dir_all(&target).map_err(|err| err.to_string())?; reporter.set_result( - serde_json::to_value(PackSwitchResult { - name: name.to_string(), - }) - .map_err(|err| err.to_string())?, + serde_json::to_value(PackSwitchResult { name }).map_err(|err| err.to_string())?, ); Ok(()) } -fn delete_switch_blocking(state: &AppState, reporter: &JobReporter, args: PackSwitchArgs) -> Result<(), String> { +fn delete_switch_blocking( + state: &AppState, + reporter: &JobReporter, + args: PackSwitchArgs, +) -> Result<(), String> { let (_, vampath) = config_paths(state)?; let vampath = vampath.ok_or_else(|| "vampath is required in config.json".to_string())?; - let name = args.name.trim(); - if name.is_empty() { - return Err("switch name is required".to_string()); - } + let name = validate_switch_name(&args.name)?; if name.eq_ignore_ascii_case(DEFAULT_SWITCH_NAME) { return Err("cannot delete default switch".to_string()); } let root = addon_switch_root(&vampath); - let target = root.join(name); + let target = root.join(&name); if target.exists() { fs::remove_dir_all(&target).map_err(|err| err.to_string())?; } reporter.set_result( - serde_json::to_value(PackSwitchResult { - name: name.to_string(), - }) - .map_err(|err| err.to_string())?, + serde_json::to_value(PackSwitchResult { name }).map_err(|err| err.to_string())?, ); Ok(()) } @@ -172,17 +169,14 @@ fn rename_switch_blocking( ) -> Result<(), String> { let (_, vampath) = config_paths(state)?; let vampath = vampath.ok_or_else(|| "vampath is required in config.json".to_string())?; - let old_name = args.old_name.trim(); - let new_name = args.new_name.trim(); - if old_name.is_empty() || new_name.is_empty() { - return Err("old_name and new_name are required".to_string()); - } + let old_name = validate_switch_name(&args.old_name)?; + let new_name = validate_switch_name(&args.new_name)?; if old_name.eq_ignore_ascii_case(DEFAULT_SWITCH_NAME) { return Err("cannot rename default switch".to_string()); } let root = addon_switch_root(&vampath); - let src = root.join(old_name); - let dest = root.join(new_name); + let src = root.join(&old_name); + let dest = root.join(&new_name); if !src.exists() { return Err(format!("switch not found: {}", old_name)); } @@ -190,12 +184,9 @@ fn rename_switch_blocking( return Err(format!("switch already exists: {}", new_name)); } fs::rename(&src, &dest).map_err(|err| err.to_string())?; - set_switch_blocking(state, reporter, new_name)?; + set_switch_blocking(state, reporter, &new_name)?; reporter.set_result( - serde_json::to_value(PackSwitchResult { - name: new_name.to_string(), - }) - .map_err(|err| err.to_string())?, + serde_json::to_value(PackSwitchResult { name: new_name }).map_err(|err| err.to_string())?, ); Ok(()) } @@ -205,10 +196,11 @@ fn set_switch_blocking( reporter: &JobReporter, name: &str, ) -> Result { + let name = validate_switch_name(name)?; let (_, vampath) = config_paths(state)?; let vampath = vampath.ok_or_else(|| "vampath is required in config.json".to_string())?; let switch_root = addon_switch_root(&vampath); - let target = switch_root.join(name); + let target = switch_root.join(&name); fs::create_dir_all(&target).map_err(|err| err.to_string())?; let addon_path = addon_packages_dir(&vampath); @@ -244,6 +236,10 @@ fn set_switch_blocking( Ok(PackSwitchSetOutcome::Switched) } +fn validate_switch_name(name: &str) -> Result { + validate_file_name(name, "switch name") +} + fn collect_managed_dirs() -> BTreeSet { let mut dirs = BTreeSet::new(); for name in MANAGED_DIRS { @@ -341,7 +337,10 @@ fn move_controlled_dir( reporter: &JobReporter, ) -> Result<(), String> { if !src.is_dir() { - return Err(format!("controlled path is not a directory: {}", src.display())); + return Err(format!( + "controlled path is not a directory: {}", + src.display() + )); } if !default_pack.exists() { fs::create_dir_all(default_pack).map_err(|err| err.to_string())?; @@ -353,10 +352,7 @@ fn move_controlled_dir( dest }; fs::rename(src, &dest).map_err(|err| err.to_string())?; - reporter.log(format!( - "moved existing link folder to {}", - dest.display() - )); + reporter.log(format!("moved existing link folder to {}", dest.display())); Ok(()) } @@ -414,9 +410,9 @@ async fn refresh_install_status(pool: &SqlitePool, vampath: &Path) -> Result bool { let root = make_temp_dir("packswitch_symlink_probe"); let target = root.join("target"); @@ -595,7 +601,10 @@ mod tests { for dir_name in collect_managed_dirs() { let addon_dir = addon_path.join(&dir_name); let target = winfs::read_link_target(&addon_dir).unwrap(); - assert!(target.to_string_lossy().to_ascii_lowercase().contains("alt")); + assert!(target + .to_string_lossy() + .to_ascii_lowercase() + .contains("alt")); } let _ = fs::remove_dir_all(&root); diff --git a/varManager_backend/src/jobs/preview_jobs.rs b/varManager_backend/src/jobs/preview_jobs.rs index 32c330e..8139883 100644 --- a/varManager_backend/src/jobs/preview_jobs.rs +++ b/varManager_backend/src/jobs/preview_jobs.rs @@ -1,13 +1,13 @@ -use crate::jobs::job_channel::JobReporter; -use crate::infra::paths::{config_paths, resolve_var_file_path, PREVIEW_DIR}; use crate::app::AppState; +use crate::infra::paths::{config_paths, is_safe_file_name, resolve_var_file_path, PREVIEW_DIR}; +use crate::jobs::job_channel::JobReporter; use serde::Serialize; use serde_json::Value; +use sqlx::{Row, SqlitePool}; use std::fs::{self, File}; use std::io::{BufReader, Write}; use std::path::{Path, PathBuf}; use zip::ZipArchive; -use sqlx::{Row, SqlitePool}; #[derive(Serialize)] struct FixPreviewResult { @@ -41,7 +41,14 @@ fn fix_previews_blocking(state: &AppState, reporter: &JobReporter) -> Result<(), let mut failed = 0; for (idx, scene) in scenes.iter().enumerate() { - let preview_path = preview_file_path(&varspath, scene); + let preview_path = match preview_file_path(&varspath, scene) { + Ok(path) => path, + Err(err) => { + failed += 1; + reporter.log(format!("skip preview path {} ({})", scene.scene_path, err)); + continue; + } + }; if preview_path.exists() { skipped += 1; } else { @@ -108,12 +115,21 @@ async fn list_scenes_with_preview(pool: &SqlitePool) -> Result Ok(scenes) } -fn preview_file_path(varspath: &Path, scene: &ScenePreview) -> PathBuf { - varspath +fn preview_file_path(varspath: &Path, scene: &ScenePreview) -> Result { + if !is_safe_file_name(&scene.atom_type) { + return Err("invalid atom type".to_string()); + } + if !is_safe_file_name(&scene.var_name) { + return Err("invalid var name".to_string()); + } + if !is_safe_file_name(&scene.preview_pic) { + return Err("invalid preview file".to_string()); + } + Ok(varspath .join(PREVIEW_DIR) .join(&scene.atom_type) .join(&scene.var_name) - .join(&scene.preview_pic) + .join(&scene.preview_pic)) } fn reextract_preview( @@ -159,7 +175,7 @@ mod tests { preview_pic: "preview.jpg".to_string(), scene_path: "Saves/scene/test.json".to_string(), }; - let path = preview_file_path(&varspath, &scene); + let path = preview_file_path(&varspath, &scene).unwrap(); assert!(path.ends_with( PathBuf::from(PREVIEW_DIR) .join("scenes") @@ -167,4 +183,17 @@ mod tests { .join("preview.jpg") )); } + + #[test] + fn preview_file_path_rejects_path_like_preview_name() { + let varspath = PathBuf::from("C:\\vars"); + let scene = ScenePreview { + var_name: "creator.pack.1".to_string(), + atom_type: "scenes".to_string(), + preview_pic: "../preview.jpg".to_string(), + scene_path: "Saves/scene/test.json".to_string(), + }; + + assert!(preview_file_path(&varspath, &scene).is_err()); + } } diff --git a/varManager_backend/src/jobs/stale_jobs.rs b/varManager_backend/src/jobs/stale_jobs.rs index 9726905..0f9d07e 100644 --- a/varManager_backend/src/jobs/stale_jobs.rs +++ b/varManager_backend/src/jobs/stale_jobs.rs @@ -1,15 +1,15 @@ +use crate::app::AppState; use crate::infra::db::{delete_var_related_conn, upsert_install_status}; use crate::infra::fs_util; -use crate::jobs::job_channel::JobReporter; use crate::infra::paths::{config_paths, resolve_var_file_path, OLD_VERSION_DIR, STALE_DIR}; -use crate::app::AppState; use crate::infra::{system_ops, winfs}; +use crate::jobs::job_channel::JobReporter; use serde::{Deserialize, Serialize}; use serde_json::Value; +use sqlx::{Row, SqlitePool}; use std::collections::HashMap; use std::fs; use std::path::Path; -use sqlx::{Row, SqlitePool}; #[derive(Deserialize)] struct StaleVarsArgs { @@ -89,7 +89,10 @@ struct VarInfo { look: i64, } -fn stale_vars_blocking(state: &AppState, reporter: &JobReporter) -> Result { +fn stale_vars_blocking( + state: &AppState, + reporter: &JobReporter, +) -> Result { reporter.log("StaleVars start".to_string()); reporter.progress(1); let (varspath, vampath) = config_paths(state)?; @@ -153,7 +156,10 @@ fn stale_vars_blocking(state: &AppState, reporter: &JobReporter) -> Result Result { +fn old_version_vars_blocking( + state: &AppState, + reporter: &JobReporter, +) -> Result { reporter.log("OldVersionVars start".to_string()); reporter.progress(1); let (varspath, vampath) = config_paths(state)?; @@ -279,9 +285,7 @@ async fn load_vars(pool: &SqlitePool, filter_old: bool) -> Result, Ok(vars) } -fn find_old_versions( - vars: &[VarInfo], -) -> (Vec, HashMap) { +fn find_old_versions(vars: &[VarInfo]) -> (Vec, HashMap) { let mut grouped: HashMap> = HashMap::new(); for info in vars { let key = format!("{}.{}", info.creator, info.package); @@ -305,21 +309,15 @@ fn find_old_versions( } async fn has_dependents(pool: &SqlitePool, var_name: &str) -> Result { - let count: i64 = sqlx::query_scalar( - "SELECT COUNT(1) FROM dependencies WHERE dependency = ?1", - ) - .bind(var_name) - .fetch_one(pool) - .await - .map_err(|err| err.to_string())?; + let count: i64 = sqlx::query_scalar("SELECT COUNT(1) FROM dependencies WHERE dependency = ?1") + .bind(var_name) + .fetch_one(pool) + .await + .map_err(|err| err.to_string())?; Ok(count > 0) } -async fn cleanup_var( - pool: &SqlitePool, - varspath: &Path, - var_name: &str, -) -> Result<(), String> { +async fn cleanup_var(pool: &SqlitePool, varspath: &Path, var_name: &str) -> Result<(), String> { delete_var_related_conn(pool, var_name).await?; sqlx::query("DELETE FROM installStatus WHERE varName = ?1") .bind(var_name) @@ -332,7 +330,14 @@ async fn cleanup_var( fn delete_preview_pics(varspath: &Path, var_name: &str) -> Result<(), String> { let types = [ - "scenes", "looks", "hairstyle", "clothing", "assets", "morphs", "skin", "pose", + "scenes", + "looks", + "hairstyle", + "clothing", + "assets", + "morphs", + "skin", + "pose", ]; for typename in types { let dir = varspath @@ -356,7 +361,9 @@ async fn install_var( vampath: &Path, var_name: &str, ) -> Result<(), String> { - let link_dir = vampath.join("AddonPackages").join(crate::infra::paths::INSTALL_LINK_DIR); + let link_dir = vampath + .join("AddonPackages") + .join(crate::infra::paths::INSTALL_LINK_DIR); fs::create_dir_all(&link_dir).map_err(|err| err.to_string())?; let link_path = link_dir.join(format!("{}.var", var_name)); if link_path.exists() { diff --git a/varManager_backend/src/jobs/system_jobs.rs b/varManager_backend/src/jobs/system_jobs.rs index 32a4514..ff089de 100644 --- a/varManager_backend/src/jobs/system_jobs.rs +++ b/varManager_backend/src/jobs/system_jobs.rs @@ -1,6 +1,6 @@ -use crate::jobs::job_channel::JobReporter; use crate::app::AppState; use crate::infra::system_ops; +use crate::jobs::job_channel::JobReporter; use crate::util; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -25,16 +25,16 @@ pub async fn run_vam_start_job( reporter: JobReporter, _args: Option, ) -> Result<(), String> { - tokio::task::spawn_blocking(move || { - vam_start_blocking(&state, &reporter) - }) - .await - .map_err(|err| err.to_string())? + tokio::task::spawn_blocking(move || vam_start_blocking(&state, &reporter)) + .await + .map_err(|err| err.to_string())? } fn vam_start_blocking(state: &AppState, reporter: &JobReporter) -> Result<(), String> { system_ops::start_vam(state)?; - reporter.set_result(serde_json::to_value(StartResult { started: true }).map_err(|e| e.to_string())?); + reporter.set_result( + serde_json::to_value(StartResult { started: true }).map_err(|e| e.to_string())?, + ); Ok(()) } @@ -43,11 +43,9 @@ pub async fn run_rescan_packages_job( reporter: JobReporter, _args: Option, ) -> Result<(), String> { - tokio::task::spawn_blocking(move || { - rescan_packages_blocking(&state, &reporter) - }) - .await - .map_err(|err| err.to_string())? + tokio::task::spawn_blocking(move || rescan_packages_blocking(&state, &reporter)) + .await + .map_err(|err| err.to_string())? } fn rescan_packages_blocking(state: &AppState, reporter: &JobReporter) -> Result<(), String> { @@ -61,11 +59,9 @@ pub async fn run_open_url_job( reporter: JobReporter, args: Option, ) -> Result<(), String> { - tokio::task::spawn_blocking(move || { - open_url_blocking(&reporter, args) - }) - .await - .map_err(|err| err.to_string())? + tokio::task::spawn_blocking(move || open_url_blocking(&reporter, args)) + .await + .map_err(|err| err.to_string())? } fn open_url_blocking(reporter: &JobReporter, args: Option) -> Result<(), String> { diff --git a/varManager_backend/src/jobs/update_db.rs b/varManager_backend/src/jobs/update_db.rs index 5d7f99d..a79686f 100644 --- a/varManager_backend/src/jobs/update_db.rs +++ b/varManager_backend/src/jobs/update_db.rs @@ -1,17 +1,18 @@ +use crate::app::AppState; +use crate::domain::var_logic::vars_dependencies; use crate::infra::db::{ delete_var_related, list_scenes_for_var, list_var_scan_info, list_vars, replace_dependencies, replace_hide_fav, replace_scenes, upsert_install_status, upsert_var, var_exists_conn, HideFavRecord, SceneRecord, VarRecord, }; use crate::infra::fs_util; -use crate::jobs::job_channel::JobReporter; -use crate::infra::paths::resolve_var_file_path; -use crate::domain::var_logic::vars_dependencies; -use crate::app::AppState; +use crate::infra::paths::{is_safe_file_name, resolve_var_file_path, safe_relative_path}; use crate::infra::{system_ops, winfs}; +use crate::jobs::job_channel::JobReporter; use chrono::{DateTime, Local}; use regex::Regex; use serde::Serialize; +use sqlx::{Sqlite, SqlitePool, Transaction}; use std::cmp::Ordering; use std::collections::{HashMap, HashSet}; use std::fs::{self, File}; @@ -20,7 +21,6 @@ use std::path::{Path, PathBuf}; use std::time::SystemTime; use walkdir::WalkDir; use zip::ZipArchive; -use sqlx::{Sqlite, SqlitePool, Transaction}; const TIDIED_DIR: &str = "___VarTidied___"; const REDUNDANT_DIR: &str = "___VarRedundant___"; @@ -96,11 +96,9 @@ struct UpdateDbSummary { } pub async fn run_update_db_job(state: AppState, reporter: JobReporter) -> Result<(), String> { - tokio::task::spawn_blocking(move || { - update_db_blocking(&state, &reporter) - }) - .await - .map_err(|err| err.to_string())? + tokio::task::spawn_blocking(move || update_db_blocking(&state, &reporter)) + .await + .map_err(|err| err.to_string())? } fn update_db_blocking(state: &AppState, reporter: &JobReporter) -> Result<(), String> { @@ -115,7 +113,9 @@ fn update_db_blocking(state: &AppState, reporter: &JobReporter) -> Result<(), St Some(vampath) => collect_addonpackages_vars(vampath), None => Vec::new(), }; - let addon_root = vampath.as_ref().map(|vampath| vampath.join("AddonPackages")); + let addon_root = vampath + .as_ref() + .map(|vampath| vampath.join("AddonPackages")); let mut vars_for_install = load_vars_for_install(); for varfile in &addon_vars { @@ -134,7 +134,11 @@ fn update_db_blocking(state: &AppState, reporter: &JobReporter) -> Result<(), St reporter.log("Phase 1/5: Tidying VAR files...".to_string()); let mut tidy_stats = tidy_vars( &varspath, - if vampath.is_some() { Some(&addon_vars) } else { None }, + if vampath.is_some() { + Some(&addon_vars) + } else { + None + }, addon_root.as_deref(), reporter, )?; @@ -157,7 +161,7 @@ fn update_db_blocking(state: &AppState, reporter: &JobReporter) -> Result<(), St false, ); if var_files.is_empty() { - reporter.log("No VAR files found under tidied directory".to_string()); + reporter.log("No VAR files found under tidied directory".to_string()); let summary = UpdateDbSummary { scanned: tidy_stats.scanned, moves: tidy_stats.moves.to_summary(), @@ -168,7 +172,10 @@ fn update_db_blocking(state: &AppState, reporter: &JobReporter) -> Result<(), St return Ok(()); } - reporter.log(format!("Phase 2/5: Processing {} VAR files into database...", var_files.len())); + reporter.log(format!( + "Phase 2/5: Processing {} VAR files into database...", + var_files.len() + )); let dependency_regex = Regex::new( r#"\x22(([^\r\n\x22\x3A\x2E]{1,60})\x2E([^\r\n\x22\x3A\x2E]{1,80})\x2E(\d+|latest))(\x22?\s*)\x3A"#, @@ -331,13 +338,22 @@ fn update_db_blocking(state: &AppState, reporter: &JobReporter) -> Result<(), St if total > 0 && (idx % 20 == 0 || idx + 1 == total) { let progress = 90 + ((idx + 1) * 5 / total) as u8; let elapsed = start_time.elapsed().as_secs_f64(); - let speed = if elapsed > 0.0 { (idx + 1) as f64 / elapsed } else { 0.0 }; - let remaining = if speed > 0.0 { (total - idx - 1) as f64 / speed } else { 0.0 }; + let speed = if elapsed > 0.0 { + (idx + 1) as f64 / elapsed + } else { + 0.0 + }; + let remaining = if speed > 0.0 { + (total - idx - 1) as f64 / speed + } else { + 0.0 + }; reporter.progress(progress.min(95)); reporter.log(format!( "Installing VARs: {}/{} ({:.1}%) | Speed: {:.1} VAR/s | ETA: {:.0}s", - idx + 1, total, + idx + 1, + total, (idx + 1) as f64 / total as f64 * 100.0, speed, remaining @@ -437,11 +453,17 @@ fn normalize_path(value: &str) -> Option { } fn read_hide_fav_for_scene(vampath: &Path, var_name: &str, scene_path: &str) -> (bool, bool) { - let scenepath = Path::new(scene_path) + if !is_safe_file_name(var_name) { + return (false, false); + } + let Ok(scene_rel) = safe_relative_path(scene_path, "scene path") else { + return (false, false); + }; + let scenepath = scene_rel .parent() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_default(); - let scenename = Path::new(scene_path) + let scenename = scene_rel .file_name() .and_then(|s| s.to_str()) .unwrap_or("") @@ -462,8 +484,7 @@ fn collect_hide_fav_records( scenes .iter() .filter_map(|scene| { - let (hide, fav) = - read_hide_fav_for_scene(vampath, var_name, &scene.scene_path); + let (hide, fav) = read_hide_fav_for_scene(vampath, var_name, &scene.scene_path); if hide || fav { Some(HideFavRecord { scene_path: scene.scene_path.clone(), @@ -571,11 +592,20 @@ fn tidy_vars( if idx % 200 == 0 && total > 0 { let elapsed = start_time.elapsed().as_secs_f64(); - let speed = if elapsed > 0.0 { (idx + 1) as f64 / elapsed } else { 0.0 }; - let remaining = if speed > 0.0 { (total - idx - 1) as f64 / speed } else { 0.0 }; + let speed = if elapsed > 0.0 { + (idx + 1) as f64 / elapsed + } else { + 0.0 + }; + let remaining = if speed > 0.0 { + (total - idx - 1) as f64 / speed + } else { + 0.0 + }; reporter.log(format!( "TidyVars: {}/{} ({:.1}%) | Speed: {:.1} VAR/s | ETA: {:.0}s", - idx + 1, total, + idx + 1, + total, (idx + 1) as f64 / total as f64 * 100.0, speed, remaining @@ -629,10 +659,7 @@ fn move_to_not_comply(varspath: &Path, src: &Path, reporter: &JobReporter) -> Re .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| "unknown.var".to_string()); let dest = unique_path(¬_comply_path, &filename); - reporter.log(format!( - "Move non-compliant var to {}", - dest.display() - )); + reporter.log(format!("Move non-compliant var to {}", dest.display())); move_file(src, &dest) } @@ -869,19 +896,15 @@ fn process_var_file( let version = parts[2].to_string(); let meta = fs::metadata(var_file).map_err(|err| ProcessError::Io(err.to_string()))?; - let var_date = meta - .modified() - .ok() - .map(format_system_time); + let var_date = meta.modified().ok().map(format_system_time); // Calculate file size in MB let fsize_mb = meta.len() as f64 / (1024.0 * 1024.0); let file = File::open(var_file).map_err(|err| ProcessError::Io(err.to_string()))?; let reader = BufReader::new(file); - let mut zip = ZipArchive::new(reader).map_err(|err| { - ProcessError::InvalidPackage(format!("zip open failed: {}", err)) - })?; + let mut zip = ZipArchive::new(reader) + .map_err(|err| ProcessError::InvalidPackage(format!("zip open failed: {}", err)))?; let meta_json = read_meta_json(&mut zip).map_err(ProcessError::InvalidPackage)?; let meta_date = meta_json.meta_date; @@ -906,15 +929,8 @@ fn process_var_file( let (typename, is_preset) = entry_info; let count = counts.bump(typename); - let preview_pic = extract_preview( - &mut zip, - &entry_name, - varspath, - basename, - typename, - count, - ) - .ok(); + let preview_pic = + extract_preview(&mut zip, &entry_name, varspath, basename, typename, count).ok(); if is_scene_record_type(typename) { scenes.push(SceneRecord { @@ -983,10 +999,7 @@ async fn cleanup_missing_vars( if !exist_vars.contains(&var_name) { delete_var_related(tx, &var_name).await?; if let Err(err) = delete_preview_pics(varspath, &var_name) { - reporter.log(format!( - "delete preview pics failed {} ({})", - var_name, err - )); + reporter.log(format!("delete preview pics failed {} ({})", var_name, err)); } removed += 1; } @@ -999,7 +1012,14 @@ async fn cleanup_missing_vars( fn delete_preview_pics(varspath: &Path, var_name: &str) -> Result<(), String> { let types = [ - "scenes", "looks", "hairstyle", "clothing", "assets", "morphs", "skin", "pose", + "scenes", + "looks", + "hairstyle", + "clothing", + "assets", + "morphs", + "skin", + "pose", ]; for typename in types { let dir = varspath.join(PREVIEW_DIR).join(typename).join(var_name); @@ -1024,7 +1044,10 @@ fn read_meta_json(zip: &mut ZipArchive>) -> Result Result { - let dot = entry_name.rfind('.').ok_or_else(|| "no extension".to_string())?; + let dot = entry_name + .rfind('.') + .ok_or_else(|| "no extension".to_string())?; let jpg_entry = format!("{}{}", &entry_name[..dot], ".jpg"); let mut jpg = zip .by_name(&jpg_entry) @@ -1154,7 +1179,16 @@ fn extract_preview( .unwrap_or("preview") .to_lowercase(); - let jpgname = format!("{}{:03}_{}.jpg", typename, count, namejpg); + let safe_namejpg = { + let cleaned = crate::util::valid_file_name(&namejpg); + if cleaned.is_empty() { + "preview".to_string() + } else { + cleaned + } + }; + + let jpgname = format!("{}{:03}_{}.jpg", typename, count, safe_namejpg); let type_dir = varspath.join(PREVIEW_DIR).join(typename).join(var_name); fs::create_dir_all(&type_dir).map_err(|err| err.to_string())?; let jpg_path = type_dir.join(&jpgname); @@ -1277,7 +1311,11 @@ mod tests { let base = root.join("sample.var"); fs::write(&base, b"test").unwrap(); let candidate = unique_path(&root, "sample.var"); - assert!(candidate.file_name().unwrap().to_string_lossy().contains("(1)")); + assert!(candidate + .file_name() + .unwrap() + .to_string_lossy() + .contains("(1)")); let _ = fs::remove_dir_all(&root); } diff --git a/varManager_backend/src/jobs/vars_jobs.rs b/varManager_backend/src/jobs/vars_jobs.rs index 6b2d2c1..5603ca4 100644 --- a/varManager_backend/src/jobs/vars_jobs.rs +++ b/varManager_backend/src/jobs/vars_jobs.rs @@ -1,16 +1,16 @@ +use crate::app::AppState; +use crate::domain::var_logic::{implicated_vars, vars_dependencies}; use crate::infra::db::{self, delete_var_related_conn, upsert_install_status}; use crate::infra::fs_util; -use crate::jobs::job_channel::JobReporter; use crate::infra::paths::{config_paths, resolve_var_file_path, DELETED_DIR, INSTALL_LINK_DIR}; -use crate::domain::var_logic::{implicated_vars, vars_dependencies}; -use crate::app::AppState; use crate::infra::winfs; +use crate::jobs::job_channel::JobReporter; use serde::{Deserialize, Serialize}; use serde_json::Value; +use sqlx::SqlitePool; use std::fs; use std::path::Path; use std::time::Instant; -use sqlx::SqlitePool; #[derive(Deserialize)] struct InstallVarsArgs { @@ -98,7 +98,8 @@ pub async fn run_uninstall_vars_job( ) -> Result<(), String> { tokio::task::spawn_blocking(move || { let args = args.ok_or_else(|| "uninstall_vars args required".to_string())?; - let args: UninstallVarsArgs = serde_json::from_value(args).map_err(|err| err.to_string())?; + let args: UninstallVarsArgs = + serde_json::from_value(args).map_err(|err| err.to_string())?; uninstall_vars_blocking(&state, &reporter, args) }) .await @@ -119,7 +120,11 @@ pub async fn run_delete_vars_job( .map_err(|err| err.to_string())? } -fn install_vars_blocking(state: &AppState, reporter: &JobReporter, args: InstallVarsArgs) -> Result<(), String> { +fn install_vars_blocking( + state: &AppState, + reporter: &JobReporter, + args: InstallVarsArgs, +) -> Result<(), String> { let (varspath, vampath) = config_paths(state)?; let vampath = vampath.ok_or_else(|| "vampath is required in config.json".to_string())?; reporter.log("InstallVars start".to_string()); @@ -178,7 +183,11 @@ fn install_vars_blocking(state: &AppState, reporter: &JobReporter, args: Install Ok(()) } -fn uninstall_vars_blocking(state: &AppState, reporter: &JobReporter, args: UninstallVarsArgs) -> Result<(), String> { +fn uninstall_vars_blocking( + state: &AppState, + reporter: &JobReporter, + args: UninstallVarsArgs, +) -> Result<(), String> { let (_, vampath) = config_paths(state)?; let vampath = vampath.ok_or_else(|| "vampath is required in config.json".to_string())?; let started = Instant::now(); @@ -266,8 +275,12 @@ fn uninstall_vars_blocking(state: &AppState, reporter: &JobReporter, args: Unins } reporter.set_result( - serde_json::to_value(UninstallVarsResult { total, removed, skipped }) - .map_err(|err| err.to_string())?, + serde_json::to_value(UninstallVarsResult { + total, + removed, + skipped, + }) + .map_err(|err| err.to_string())?, ); reporter.progress(100); @@ -285,14 +298,19 @@ pub async fn run_preview_uninstall_job( ) -> Result<(), String> { tokio::task::spawn_blocking(move || { let args = args.ok_or_else(|| "preview_uninstall args required".to_string())?; - let args: PreviewUninstallArgs = serde_json::from_value(args).map_err(|err| err.to_string())?; + let args: PreviewUninstallArgs = + serde_json::from_value(args).map_err(|err| err.to_string())?; preview_uninstall_blocking(&state, &reporter, args) }) .await .map_err(|err| err.to_string())? } -fn preview_uninstall_blocking(state: &AppState, reporter: &JobReporter, args: PreviewUninstallArgs) -> Result<(), String> { +fn preview_uninstall_blocking( + state: &AppState, + reporter: &JobReporter, + args: PreviewUninstallArgs, +) -> Result<(), String> { let started = Instant::now(); reporter.log("PreviewUninstall start".to_string()); reporter.progress(1); @@ -392,7 +410,11 @@ fn preview_uninstall_blocking(state: &AppState, reporter: &JobReporter, args: Pr Ok(()) } -fn delete_vars_blocking(state: &AppState, reporter: &JobReporter, args: DeleteVarsArgs) -> Result<(), String> { +fn delete_vars_blocking( + state: &AppState, + reporter: &JobReporter, + args: DeleteVarsArgs, +) -> Result<(), String> { let (varspath, vampath) = config_paths(state)?; let vampath = vampath.ok_or_else(|| "vampath is required in config.json".to_string())?; reporter.log("DeleteVars start".to_string()); @@ -449,8 +471,12 @@ fn delete_vars_blocking(state: &AppState, reporter: &JobReporter, args: DeleteVa } reporter.set_result( - serde_json::to_value(DeleteVarsResult { total, deleted, failed }) - .map_err(|err| err.to_string())?, + serde_json::to_value(DeleteVarsResult { + total, + deleted, + failed, + }) + .map_err(|err| err.to_string())?, ); reporter.progress(100); @@ -530,10 +556,20 @@ async fn remove_install_status(pool: &SqlitePool, var_name: &str) -> Result<(), fn delete_preview_pics(varspath: &Path, var_name: &str) -> Result<(), String> { let types = [ - "scenes", "looks", "hairstyle", "clothing", "assets", "morphs", "skin", "pose", + "scenes", + "looks", + "hairstyle", + "clothing", + "assets", + "morphs", + "skin", + "pose", ]; for typename in types { - let dir = varspath.join(crate::infra::paths::PREVIEW_DIR).join(typename).join(var_name); + let dir = varspath + .join(crate::infra::paths::PREVIEW_DIR) + .join(typename) + .join(var_name); if dir.exists() { fs::remove_dir_all(&dir).map_err(|err| err.to_string())?; } diff --git a/varManager_backend/src/jobs/vars_misc.rs b/varManager_backend/src/jobs/vars_misc.rs index dbc7c38..72159c2 100644 --- a/varManager_backend/src/jobs/vars_misc.rs +++ b/varManager_backend/src/jobs/vars_misc.rs @@ -1,16 +1,16 @@ +use crate::app::AppState; +use crate::domain::var_logic::{implicated_vars, vars_dependencies}; use crate::infra::db::{upsert_install_status, var_exists_conn}; use crate::infra::fs_util; -use crate::jobs::job_channel::JobReporter; use crate::infra::paths::{config_paths, resolve_var_file_path, INSTALL_LINK_DIR}; -use crate::domain::var_logic::{implicated_vars, vars_dependencies}; -use crate::app::AppState; use crate::infra::winfs; +use crate::jobs::job_channel::JobReporter; use crate::util; use serde::{Deserialize, Serialize}; use serde_json::Value; +use sqlx::{Row, SqlitePool}; use std::fs; use std::path::{Path, PathBuf}; -use sqlx::{Row, SqlitePool}; #[derive(Deserialize)] struct ExportInstalledArgs { @@ -74,7 +74,8 @@ pub async fn run_export_installed_job( ) -> Result<(), String> { tokio::task::spawn_blocking(move || { let args = args.ok_or_else(|| "vars_export_installed args required".to_string())?; - let args: ExportInstalledArgs = serde_json::from_value(args).map_err(|err| err.to_string())?; + let args: ExportInstalledArgs = + serde_json::from_value(args).map_err(|err| err.to_string())?; export_installed_blocking(&state, &reporter, args) }) .await @@ -102,7 +103,8 @@ pub async fn run_toggle_install_job( ) -> Result<(), String> { tokio::task::spawn_blocking(move || { let args = args.ok_or_else(|| "vars_toggle_install args required".to_string())?; - let args: ToggleInstallArgs = serde_json::from_value(args).map_err(|err| err.to_string())?; + let args: ToggleInstallArgs = + serde_json::from_value(args).map_err(|err| err.to_string())?; toggle_install_blocking(&state, &reporter, args) }) .await @@ -128,11 +130,9 @@ pub async fn run_refresh_install_status_job( reporter: JobReporter, _args: Option, ) -> Result<(), String> { - tokio::task::spawn_blocking(move || { - refresh_install_status_blocking(&state, &reporter) - }) - .await - .map_err(|err| err.to_string())? + tokio::task::spawn_blocking(move || refresh_install_status_blocking(&state, &reporter)) + .await + .map_err(|err| err.to_string())? } fn export_installed_blocking( @@ -144,8 +144,7 @@ fn export_installed_blocking( let handle = tokio::runtime::Handle::current(); let rows = handle .block_on( - sqlx::query("SELECT varName FROM installStatus WHERE installed = 1") - .fetch_all(pool), + sqlx::query("SELECT varName FROM installStatus WHERE installed = 1").fetch_all(pool), ) .map_err(|err| err.to_string())?; let mut vars = Vec::new(); @@ -200,12 +199,7 @@ fn install_batch_blocking( continue; } match handle.block_on(install_var( - pool, - &varspath, - &vampath, - var_name, - false, - false, + pool, &varspath, &vampath, var_name, false, false, )) { Ok(InstallOutcome::Installed) => installed.push(var_name.clone()), Ok(InstallOutcome::AlreadyInstalled) => already_installed.push(var_name.clone()), @@ -293,12 +287,7 @@ fn toggle_install_blocking( let mut failed = Vec::new(); for (idx, var_name) in var_list.iter().enumerate() { match handle.block_on(install_var( - pool, - &varspath, - &vampath, - var_name, - false, - false, + pool, &varspath, &vampath, var_name, false, false, )) { Ok(InstallOutcome::Installed) => installed.push(var_name.clone()), Ok(InstallOutcome::AlreadyInstalled) => {} @@ -325,22 +314,37 @@ fn toggle_install_blocking( Ok(()) } -fn locate_blocking(state: &AppState, reporter: &JobReporter, args: LocateArgs) -> Result<(), String> { +fn locate_blocking( + state: &AppState, + reporter: &JobReporter, + args: LocateArgs, +) -> Result<(), String> { let (varspath, vampath) = config_paths(state)?; - if let Some(var_name) = args.var_name.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { + if let Some(var_name) = args + .var_name + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { let path = resolve_var_file_path(&varspath, var_name)?; util::open_explorer_select(&path)?; reporter.log(format!("locate {}", var_name)); return Ok(()); } - if let Some(path) = args.path.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { + if let Some(path) = args + .path + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + { let p = PathBuf::from(path); let final_path = if p.is_absolute() { p } else { - let vampath = vampath.ok_or_else(|| "vampath is required to resolve path".to_string())?; + let vampath = + vampath.ok_or_else(|| "vampath is required to resolve path".to_string())?; vampath.join(p) }; util::open_explorer_select(&final_path)?; diff --git a/varManager_backend/src/main.rs b/varManager_backend/src/main.rs index 979de41..d4501ad 100644 --- a/varManager_backend/src/main.rs +++ b/varManager_backend/src/main.rs @@ -4,15 +4,12 @@ use axum::{ }; use std::{ net::SocketAddr, - sync::{ - atomic::AtomicU64, - Arc, RwLock, - }, + sync::{atomic::AtomicU64, Arc, RwLock}, }; use tokio::sync::{oneshot, Semaphore}; -mod app; mod api; +mod app; mod domain; mod infra; mod jobs; @@ -46,7 +43,10 @@ async fn main() -> Result<(), Box> { ); image_cache.clone().start_maintenance(); let config_state = Arc::new(RwLock::new(config.clone())); - let download_manager = Arc::new(DownloadManager::new(db_pool.clone(), Arc::clone(&config_state))); + let download_manager = Arc::new(DownloadManager::new( + db_pool.clone(), + Arc::clone(&config_state), + )); download_manager .pause_incomplete() .await @@ -95,7 +95,10 @@ async fn main() -> Result<(), Box> { .route("/preview", get(api::get_preview)) .route("/cache/stats", get(api::get_cache_stats)) .route("/cache/clear", post(api::clear_cache)) - .route("/cache/entry", axum::routing::delete(api::delete_cache_entry)) + .route( + "/cache/entry", + axum::routing::delete(api::delete_cache_entry), + ) .route("/packswitch", get(api::list_packswitch)) .route("/hub/options", get(api::list_hub_options)) .route("/dependents", get(api::list_dependents)) @@ -116,8 +119,7 @@ async fn main() -> Result<(), Box> { .route("/shutdown", post(api::shutdown)) .with_state(state); - let addr: SocketAddr = - format!("{}:{}", config.listen_host, config.listen_port).parse()?; + let addr: SocketAddr = format!("{}:{}", config.listen_host, config.listen_port).parse()?; let listener = tokio::net::TcpListener::bind(&addr).await?; tracing::info!(%addr, version = APP_VERSION, "backend listening"); diff --git a/varManager_backend/src/scenes/core.rs b/varManager_backend/src/scenes/core.rs index c3db124..c351b49 100644 --- a/varManager_backend/src/scenes/core.rs +++ b/varManager_backend/src/scenes/core.rs @@ -1,10 +1,13 @@ +use crate::app::{data_dir, AppState}; +use crate::domain::var_logic::{resolve_var_exist_name, vars_dependencies}; use crate::infra::db::var_exists_conn; use crate::infra::fs_util; -use crate::jobs::job_channel::JobReporter; -use crate::infra::paths::{config_paths, loadscene_path, resolve_var_file_path, temp_links_dir, CACHE_DIR}; -use crate::domain::var_logic::{resolve_var_exist_name, vars_dependencies}; -use crate::app::{data_dir, AppState}; +use crate::infra::paths::{ + config_paths, is_safe_file_name, loadscene_path, marker_path_for_file, resolve_var_file_path, + safe_relative_path, temp_links_dir, CACHE_DIR, +}; use crate::infra::winfs; +use crate::jobs::job_channel::JobReporter; use crate::util; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -59,7 +62,12 @@ pub struct AnalysisSummary { pub parent_links: Vec, } -const SCENE_BASE_ATOMS: [&str; 4] = ["CoreControl", "PlayerNavigationPanel", "VRController", "WindowCamera"]; +const SCENE_BASE_ATOMS: [&str; 4] = [ + "CoreControl", + "PlayerNavigationPanel", + "VRController", + "WindowCamera", +]; const POSE_CONTROL_IDS: [&str; 26] = [ "hipControl", "pelvisControl", @@ -232,7 +240,11 @@ pub(crate) struct CacheClearArgs { entry_name: String, } -pub(crate) fn scene_load_blocking(state: &AppState, reporter: &JobReporter, args: SceneLoadArgs) -> Result<(), String> { +pub(crate) fn scene_load_blocking( + state: &AppState, + reporter: &JobReporter, + args: SceneLoadArgs, +) -> Result<(), String> { let (_, vampath) = config_paths(state)?; let vampath = vampath.ok_or_else(|| "vampath is required in config.json".to_string())?; let mut json_ls = args.json; @@ -267,11 +279,17 @@ pub(crate) fn scene_load_blocking(state: &AppState, reporter: &JobReporter, args let deps = read_lines(&depend_path)?; let gender = if gender_path.exists() { fs::read_to_string(&gender_path) - .unwrap_or_else(|_| args.character_gender.clone().unwrap_or_else(|| "unknown".to_string())) + .unwrap_or_else(|_| { + args.character_gender + .clone() + .unwrap_or_else(|| "unknown".to_string()) + }) .trim() .to_string() } else { - args.character_gender.clone().unwrap_or_else(|| "unknown".to_string()) + args.character_gender + .clone() + .unwrap_or_else(|| "unknown".to_string()) }; let result = build_loadscene( @@ -289,8 +307,14 @@ pub(crate) fn scene_load_blocking(state: &AppState, reporter: &JobReporter, args Ok(()) } -pub(crate) fn scene_analyze_blocking(state: &AppState, reporter: &JobReporter, args: SceneAnalyzeArgs) -> Result<(), String> { - let gender = args.character_gender.unwrap_or_else(|| "female".to_string()); +pub(crate) fn scene_analyze_blocking( + state: &AppState, + reporter: &JobReporter, + args: SceneAnalyzeArgs, +) -> Result<(), String> { + let gender = args + .character_gender + .unwrap_or_else(|| "female".to_string()); let result = read_save_name(state, &args.save_name, &gender, true)?; reporter.set_result( serde_json::to_value(SceneAnalyzeResult { @@ -560,9 +584,11 @@ fn read_save_name( let file = fs::File::open(destvarfile).map_err(|err| err.to_string())?; let mut zip = ZipArchive::new(file).map_err(|err| err.to_string())?; let mut entry = zip.by_name(&entry_name).map_err(|err| err.to_string())?; - entry.read_to_string(&mut jsonscene).map_err(|err| err.to_string())?; + entry + .read_to_string(&mut jsonscene) + .map_err(|err| err.to_string())?; } else { - let jsonfile = vampath.join(save_name.replace('/', "\\")); + let jsonfile = local_save_path(&vampath, &entry_name)?; jsonscene = fs::read_to_string(&jsonfile).map_err(|err| err.to_string())?; } @@ -622,10 +648,18 @@ fn analysis_atoms(jsonscene: &str, scene_folder: &Path, is_person: bool) -> Resu } write_json_file(&scene_folder.join("posinfo.bin"), &Value::Object(posinfo))?; - let atoms = value.get("atoms").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let atoms = value + .get("atoms") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); let mut parent_atoms: HashMap> = HashMap::new(); for atom in atoms { - let mut atom_type = atom.get("type").and_then(|v| v.as_str()).unwrap_or("").to_string(); + let mut atom_type = atom + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); if atom_type.is_empty() { continue; } @@ -842,7 +876,10 @@ fn build_atom_tree(dir: &Path, cache_root: &Path) -> Result, S Ok(nodes) } -fn list_person_info(atoms_root: &Path, entry_name: &str) -> Result, String> { +fn list_person_info( + atoms_root: &Path, + entry_name: &str, +) -> Result, String> { let person_dir = atoms_root.join("Person"); let mut people = Vec::new(); if person_dir.exists() { @@ -869,7 +906,11 @@ fn list_person_info(atoms_root: &Path, entry_name: &str) -> Result Result { } } } - let first = files.first().ok_or_else(|| "CoreControl not found".to_string())?; + let first = files + .first() + .ok_or_else(|| "CoreControl not found".to_string())?; let contents = fs::read_to_string(first).map_err(|err| err.to_string())?; serde_json::from_str(&contents).map_err(|err| err.to_string()) } @@ -1147,32 +1193,113 @@ fn save_preset( } if skin { - save_static_preset(state, "Custom\\Atom\\Person\\Appearance\\Preset_eyeDefault.vap", DEFAULT_EYE_COLOR)?; - add_preset_resource(save_names, "looks", "Custom/Atom/Person/Appearance/Preset_eyeDefault.vap", character_gender, ignore_gender, person_order); + save_static_preset( + state, + "Custom\\Atom\\Person\\Appearance\\Preset_eyeDefault.vap", + DEFAULT_EYE_COLOR, + )?; + add_preset_resource( + save_names, + "looks", + "Custom/Atom/Person/Appearance/Preset_eyeDefault.vap", + character_gender, + ignore_gender, + person_order, + ); } if clothing { - save_static_preset(state, "Custom\\Atom\\Person\\Clothing\\Preset_ClothNaked.vap", CLOTH_NAKED)?; - add_preset_resource(save_names, "clothing", "Custom/Atom/Person/Clothing/Preset_ClothNaked.vap", character_gender, ignore_gender, person_order); + save_static_preset( + state, + "Custom\\Atom\\Person\\Clothing\\Preset_ClothNaked.vap", + CLOTH_NAKED, + )?; + add_preset_resource( + save_names, + "clothing", + "Custom/Atom/Person/Clothing/Preset_ClothNaked.vap", + character_gender, + ignore_gender, + person_order, + ); } if hair { - save_static_preset(state, "Custom\\Atom\\Person\\Hair\\Preset_HairBald.vap", HAIR_BALD)?; - add_preset_resource(save_names, "hairstyle", "Custom/Atom/Person/Hair/Preset_HairBald.vap", character_gender, ignore_gender, person_order); + save_static_preset( + state, + "Custom\\Atom\\Person\\Hair\\Preset_HairBald.vap", + HAIR_BALD, + )?; + add_preset_resource( + save_names, + "hairstyle", + "Custom/Atom/Person/Hair/Preset_HairBald.vap", + character_gender, + ignore_gender, + person_order, + ); } if morphs { - save_json_preset(state, var_name, "Custom\\Atom\\Person\\Morphs\\Preset_temp.vap", &json_morphs)?; - add_preset_resource(save_names, "morphs", "Custom/Atom/Person/Morphs/Preset_temp.vap", character_gender, ignore_gender, person_order); + save_json_preset( + state, + var_name, + "Custom\\Atom\\Person\\Morphs\\Preset_temp.vap", + &json_morphs, + )?; + add_preset_resource( + save_names, + "morphs", + "Custom/Atom/Person/Morphs/Preset_temp.vap", + character_gender, + ignore_gender, + person_order, + ); } if breast { - save_json_preset(state, var_name, "Custom\\Atom\\Person\\BreastPhysics\\Preset_temp.vap", &json_breast)?; - add_preset_resource(save_names, "breast", "Custom/Atom/Person/BreastPhysics/Preset_temp.vap", character_gender, ignore_gender, person_order); + save_json_preset( + state, + var_name, + "Custom\\Atom\\Person\\BreastPhysics\\Preset_temp.vap", + &json_breast, + )?; + add_preset_resource( + save_names, + "breast", + "Custom/Atom/Person/BreastPhysics/Preset_temp.vap", + character_gender, + ignore_gender, + person_order, + ); } if glute { - save_json_preset(state, var_name, "Custom\\Atom\\Person\\GlutePhysics\\Preset_temp.vap", &json_glute)?; - add_preset_resource(save_names, "glute", "Custom/Atom/Person/GlutePhysics/Preset_temp.vap", character_gender, ignore_gender, person_order); + save_json_preset( + state, + var_name, + "Custom\\Atom\\Person\\GlutePhysics\\Preset_temp.vap", + &json_glute, + )?; + add_preset_resource( + save_names, + "glute", + "Custom/Atom/Person/GlutePhysics/Preset_temp.vap", + character_gender, + ignore_gender, + person_order, + ); } if clothing || hair || skin { - save_json_preset(state, var_name, "Custom\\Atom\\Person\\Appearance\\Preset_temp.vap", &json_preset)?; - add_preset_resource(save_names, "looks", "Custom/Atom/Person/Appearance/Preset_temp.vap", character_gender, ignore_gender, person_order); + save_json_preset( + state, + var_name, + "Custom\\Atom\\Person\\Appearance\\Preset_temp.vap", + &json_preset, + )?; + add_preset_resource( + save_names, + "looks", + "Custom/Atom/Person/Appearance/Preset_temp.vap", + character_gender, + ignore_gender, + person_order, + ); } Ok(()) @@ -1207,8 +1334,20 @@ fn save_plugin_preset( } } } - save_json_preset(state, var_name, "Custom\\Atom\\Person\\Plugins\\Preset_temp.vap", &json_plugin)?; - add_preset_resource(save_names, "plugin", "Custom/Atom/Person/Plugins/Preset_temp.vap", character_gender, ignore_gender, person_order); + save_json_preset( + state, + var_name, + "Custom\\Atom\\Person\\Plugins\\Preset_temp.vap", + &json_plugin, + )?; + add_preset_resource( + save_names, + "plugin", + "Custom/Atom/Person/Plugins/Preset_temp.vap", + character_gender, + ignore_gender, + person_order, + ); Ok(()) } @@ -1237,8 +1376,20 @@ fn save_pose_preset( } } } - save_json_preset(state, var_name, "Custom\\Atom\\Person\\Pose\\Preset_temp.vap", &json_pose)?; - add_preset_resource(save_names, "pose", "Custom/Atom/Person/Pose/Preset_temp.vap", character_gender, ignore_gender, person_order); + save_json_preset( + state, + var_name, + "Custom\\Atom\\Person\\Pose\\Preset_temp.vap", + &json_pose, + )?; + add_preset_resource( + save_names, + "pose", + "Custom/Atom/Person/Pose/Preset_temp.vap", + character_gender, + ignore_gender, + person_order, + ); Ok(()) } @@ -1275,7 +1426,11 @@ fn save_animation_preset( .ok_or_else(|| "MotionAnimationMaster not found".to_string())?; json_animation["motionAnimationMaster"] = master; - save_raw_json(state, "Custom\\Atom\\Person\\AnimationPresets\\Preset_temp.bin", &json_animation)?; + save_raw_json( + state, + "Custom\\Atom\\Person\\AnimationPresets\\Preset_temp.bin", + &json_animation, + )?; add_preset_resource( save_names, "animation", @@ -1328,7 +1483,7 @@ fn add_atom_resources( let mut resources = Vec::new(); for atom_path in atom_paths { - let src = resolve_atom_source(cache_root, atom_path); + let src = resolve_atom_source(cache_root, atom_path)?; if !src.exists() { continue; } @@ -1359,11 +1514,12 @@ pub(crate) fn set_hide_fav( ) -> Result { let (_, vampath) = config_paths(state)?; let vampath = vampath.ok_or_else(|| "vampath is required in config.json".to_string())?; - let scenepath = Path::new(scene_path) + let scene_rel = safe_relative_path(scene_path, "scene path")?; + let scenepath = scene_rel .parent() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_default(); - let scenename = Path::new(scene_path) + let scenename = scene_rel .file_name() .and_then(|s| s.to_str()) .unwrap_or("") @@ -1372,12 +1528,16 @@ pub(crate) fn set_hide_fav( if use_root == "(save)." || use_root == "save" { use_root.clear(); } + if !use_root.is_empty() && !is_safe_file_name(&use_root) { + return Err("invalid var name".to_string()); + } let pathhide; let pathfav; if use_root.is_empty() { - pathhide = vampath.join(format!("{}.hide", scene_path.replace('/', "\\"))); - pathfav = vampath.join(format!("{}.fav", scene_path.replace('/', "\\"))); + let scene_file = vampath.join(&scene_rel); + pathhide = marker_path_for_file(&scene_file, "hide"); + pathfav = marker_path_for_file(&scene_file, "fav"); } else { pathhide = vampath .join("AddonPackagesFilePrefs") @@ -1502,13 +1662,13 @@ fn build_loadscene( resource["characterGender"] = Value::String(character_gender.to_string()); } if resource.get("ignoreGender").is_none() { - resource["ignoreGender"] = Value::String(ignore_gender.to_string().to_ascii_lowercase()); + resource["ignoreGender"] = + Value::String(ignore_gender.to_string().to_ascii_lowercase()); } if resource.get("personOrder").is_none() { resource["personOrder"] = Value::String(person_order.to_string()); } - if delete_temp.is_empty() - && resource.get("type").and_then(|v| v.as_str()) == Some("scenes") + if delete_temp.is_empty() && resource.get("type").and_then(|v| v.as_str()) == Some("scenes") { delete_temp = collect_temp_links(vampath)?; } @@ -1538,8 +1698,11 @@ fn build_loadscene( if loadscene.exists() { let _ = fs::remove_file(&loadscene); } - fs::write(&loadscene, serde_json::to_string_pretty(json_ls).map_err(|err| err.to_string())?) - .map_err(|err| err.to_string())?; + fs::write( + &loadscene, + serde_json::to_string_pretty(json_ls).map_err(|err| err.to_string())?, + ) + .map_err(|err| err.to_string())?; if !delete_temp.is_empty() { spawn_delete_temp_thread(vampath.to_path_buf(), delete_temp); @@ -1552,7 +1715,11 @@ fn build_loadscene( }) } -fn install_temp(state: &AppState, reporter: &JobReporter, deps: &[String]) -> Result<(Vec, bool), String> { +fn install_temp( + state: &AppState, + reporter: &JobReporter, + deps: &[String], +) -> Result<(Vec, bool), String> { let (varspath, vampath) = config_paths(state)?; let vampath = vampath.ok_or_else(|| "vampath is required in config.json".to_string())?; @@ -1639,23 +1806,27 @@ fn spawn_delete_temp_thread(vampath: PathBuf, files: Vec) { }); } -fn resolve_atom_source(cache_root: &Path, atom_path: &str) -> PathBuf { - let candidate = PathBuf::from(atom_path); - if candidate.is_absolute() { - return candidate; - } - cache_root.join(atom_path) +fn resolve_atom_source(cache_root: &Path, atom_path: &str) -> Result { + Ok(cache_root.join(safe_relative_path(atom_path, "atom path")?)) } fn write_json_file(path: &Path, value: &Value) -> Result<(), String> { - fs::write(path, serde_json::to_string(value).map_err(|err| err.to_string())?) - .map_err(|err| err.to_string()) + fs::write( + path, + serde_json::to_string(value).map_err(|err| err.to_string())?, + ) + .map_err(|err| err.to_string()) } -fn save_json_preset(state: &AppState, var_name: &str, rel: &str, value: &Value) -> Result<(), String> { +fn save_json_preset( + state: &AppState, + var_name: &str, + rel: &str, + value: &Value, +) -> Result<(), String> { let (_, vampath) = config_paths(state)?; let vampath = vampath.ok_or_else(|| "vampath is required in config.json".to_string())?; - let path = vampath.join(rel); + let path = vampath.join(safe_relative_path(rel, "preset path")?); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|err| err.to_string())?; } @@ -1668,19 +1839,22 @@ fn save_json_preset(state: &AppState, var_name: &str, rel: &str, value: &Value) fn save_raw_json(state: &AppState, rel: &str, value: &Value) -> Result<(), String> { let (_, vampath) = config_paths(state)?; let vampath = vampath.ok_or_else(|| "vampath is required in config.json".to_string())?; - let path = vampath.join(rel); + let path = vampath.join(safe_relative_path(rel, "preset path")?); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|err| err.to_string())?; } - fs::write(&path, serde_json::to_string(value).map_err(|err| err.to_string())?) - .map_err(|err| err.to_string())?; + fs::write( + &path, + serde_json::to_string(value).map_err(|err| err.to_string())?, + ) + .map_err(|err| err.to_string())?; Ok(()) } fn save_static_preset(state: &AppState, rel: &str, content: &str) -> Result<(), String> { let (_, vampath) = config_paths(state)?; let vampath = vampath.ok_or_else(|| "vampath is required in config.json".to_string())?; - let path = vampath.join(rel); + let path = vampath.join(safe_relative_path(rel, "preset path")?); if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|err| err.to_string())?; } @@ -1772,7 +1946,9 @@ fn cache_dir(var_name: &str, entry_name: &str) -> PathBuf { data_dir() .join(CACHE_DIR) .join(util::valid_file_name(key)) - .join(util::valid_file_name(&util::normalize_entry_name(entry_name))) + .join(util::valid_file_name(&util::normalize_entry_name( + entry_name, + ))) } fn normalize_cache_key(var_name: &str, entry_name: &str) -> (String, String) { @@ -1784,13 +1960,77 @@ fn normalize_cache_key(var_name: &str, entry_name: &str) -> (String, String) { (key.to_string(), entry_name.to_string()) } +fn local_save_path(vampath: &Path, entry_name: &str) -> Result { + Ok(vampath.join(safe_relative_path(entry_name, "local save path")?)) +} + fn save_name_split(save_name: &str) -> (String, String) { if let Some((var_name, entry)) = save_name.split_once(":/") { - return (var_name.to_string(), entry.to_string()); + let key = if var_name == "(save)." || var_name.is_empty() { + "save" + } else { + var_name + }; + return (key.to_string(), entry.to_string()); } ("save".to_string(), save_name.to_string()) } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn save_name_split_accepts_local_save_scheme() { + let (var_name, entry_name) = save_name_split("save:/Saves/scene/Foo.json"); + + assert_eq!(var_name, "save"); + assert_eq!(entry_name, "Saves/scene/Foo.json"); + } + + #[test] + fn save_name_split_accepts_legacy_local_save_marker() { + let (var_name, entry_name) = save_name_split("(save).:/Saves/scene/Foo.json"); + + assert_eq!(var_name, "save"); + assert_eq!(entry_name, "Saves/scene/Foo.json"); + } + + #[test] + fn local_save_path_uses_entry_name_without_scheme() { + let path = local_save_path(Path::new("C:\\VaM"), "Saves/scene/Foo.json").unwrap(); + + assert_eq!(path, PathBuf::from("C:\\VaM\\Saves\\scene\\Foo.json")); + } + + #[test] + fn local_save_path_rejects_unsafe_paths() { + assert!(local_save_path(Path::new("C:\\VaM"), "../Foo.json").is_err()); + assert!(local_save_path(Path::new("C:\\VaM"), "C:/Foo.json").is_err()); + assert!(local_save_path(Path::new("C:\\VaM"), "/Saves/scene/Foo.json").is_err()); + assert!(local_save_path(Path::new("C:\\VaM"), "Saves/scene/Foo:bar.json").is_err()); + } + + #[test] + fn marker_path_for_file_preserves_scene_extension() { + let path = marker_path_for_file(Path::new("C:\\VaM\\Saves\\scene\\Foo.json"), "hide"); + + assert_eq!(path, PathBuf::from("C:\\VaM\\Saves\\scene\\Foo.json.hide")); + } + + #[test] + fn resolve_atom_source_rejects_paths_outside_cache() { + let root = Path::new("C:\\cache"); + + assert_eq!( + resolve_atom_source(root, "atoms/Person/Foo.bin").unwrap(), + PathBuf::from("C:\\cache\\atoms\\Person\\Foo.bin") + ); + assert!(resolve_atom_source(root, "../Foo.bin").is_err()); + assert!(resolve_atom_source(root, "C:/Foo.bin").is_err()); + } +} + fn find_atom_file(root: &Path, atom_name: &str) -> Option { if !root.exists() { return None; diff --git a/varManager_backend/src/scenes/jobs.rs b/varManager_backend/src/scenes/jobs.rs index 7f134b9..159e899 100644 --- a/varManager_backend/src/scenes/jobs.rs +++ b/varManager_backend/src/scenes/jobs.rs @@ -1,5 +1,5 @@ -use crate::jobs::job_channel::JobReporter; use crate::app::AppState; +use crate::jobs::job_channel::JobReporter; use serde_json::Value; use super::core; @@ -11,7 +11,8 @@ pub async fn run_scene_load_job( ) -> Result<(), String> { tokio::task::spawn_blocking(move || { let args = args.ok_or_else(|| "scene_load args required".to_string())?; - let args: core::SceneLoadArgs = serde_json::from_value(args).map_err(|err| err.to_string())?; + let args: core::SceneLoadArgs = + serde_json::from_value(args).map_err(|err| err.to_string())?; core::scene_load_blocking(&state, &reporter, args) }) .await @@ -25,7 +26,8 @@ pub async fn run_scene_analyze_job( ) -> Result<(), String> { tokio::task::spawn_blocking(move || { let args = args.ok_or_else(|| "scene_analyze args required".to_string())?; - let args: core::SceneAnalyzeArgs = serde_json::from_value(args).map_err(|err| err.to_string())?; + let args: core::SceneAnalyzeArgs = + serde_json::from_value(args).map_err(|err| err.to_string())?; core::scene_analyze_blocking(&state, &reporter, args) }) .await @@ -141,7 +143,8 @@ pub async fn run_cache_clear_job( ) -> Result<(), String> { tokio::task::spawn_blocking(move || { let args = args.ok_or_else(|| "cache_clear args required".to_string())?; - let args: core::CacheClearArgs = serde_json::from_value(args).map_err(|err| err.to_string())?; + let args: core::CacheClearArgs = + serde_json::from_value(args).map_err(|err| err.to_string())?; core::cache_clear_blocking(&state, &reporter, args) }) .await @@ -156,7 +159,8 @@ async fn run_scene_preset_job( ) -> Result<(), String> { tokio::task::spawn_blocking(move || { let args = args.ok_or_else(|| "scene_preset args required".to_string())?; - let args: core::ScenePresetArgs = serde_json::from_value(args).map_err(|err| err.to_string())?; + let args: core::ScenePresetArgs = + serde_json::from_value(args).map_err(|err| err.to_string())?; core::scene_preset_blocking(&state, &reporter, args, kind) }) .await @@ -187,12 +191,18 @@ async fn run_scene_hide_fav_job( hide_fav: i32, ) -> Result<(), String> { let args = args.ok_or_else(|| "scene_hide_fav args required".to_string())?; - let args: core::SceneHideFavArgs = serde_json::from_value(args).map_err(|err| err.to_string())?; + let args: core::SceneHideFavArgs = + serde_json::from_value(args).map_err(|err| err.to_string())?; let state_for_blocking = state.clone(); let var_name = args.var_name.clone(); let scene_path = args.scene_path.clone(); let status = tokio::task::spawn_blocking(move || { - core::set_hide_fav(&state_for_blocking, var_name.as_deref(), &scene_path, hide_fav) + core::set_hide_fav( + &state_for_blocking, + var_name.as_deref(), + &scene_path, + hide_fav, + ) }) .await .map_err(|err| err.to_string())??; diff --git a/varManager_backend/src/scenes/mod.rs b/varManager_backend/src/scenes/mod.rs index 8572e45..67542b4 100644 --- a/varManager_backend/src/scenes/mod.rs +++ b/varManager_backend/src/scenes/mod.rs @@ -1,25 +1,11 @@ mod core; mod jobs; -pub use core::{ - AnalysisSummary, - AtomTreeNode, - analysis_summary, - list_analysis_atoms, -}; +pub use core::{analysis_summary, list_analysis_atoms, AnalysisSummary, AtomTreeNode}; pub use jobs::{ - run_cache_clear_job, - run_scene_add_atoms_job, - run_scene_add_subscene_job, - run_scene_analyze_job, - run_scene_fav_job, - run_scene_hide_job, - run_scene_load_job, - run_scene_preset_animation_job, - run_scene_preset_look_job, - run_scene_preset_plugin_job, - run_scene_preset_pose_job, - run_scene_preset_scene_job, - run_scene_unfav_job, + run_cache_clear_job, run_scene_add_atoms_job, run_scene_add_subscene_job, + run_scene_analyze_job, run_scene_fav_job, run_scene_hide_job, run_scene_load_job, + run_scene_preset_animation_job, run_scene_preset_look_job, run_scene_preset_plugin_job, + run_scene_preset_pose_job, run_scene_preset_scene_job, run_scene_unfav_job, run_scene_unhide_job, }; diff --git a/varManager_backend/src/services/image_cache.rs b/varManager_backend/src/services/image_cache.rs index fee4456..a5ec3cd 100644 --- a/varManager_backend/src/services/image_cache.rs +++ b/varManager_backend/src/services/image_cache.rs @@ -3,9 +3,9 @@ use bytes::Bytes; use dashmap::DashMap; use moka::future::Cache; use reqwest::header; -use sqlx::{Row, SqlitePool}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use sqlx::{Row, SqlitePool}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; @@ -136,8 +136,7 @@ impl ImageCacheService { config: ImageCacheConfig, db_pool: SqlitePool, ) -> Result { - let memory_max_bytes = - config.memory_cache_size_mb as u64 * 1024_u64 * 1024_u64; + let memory_max_bytes = config.memory_cache_size_mb as u64 * 1024_u64 * 1024_u64; let disk_max_bytes = config.disk_cache_size_mb as u64 * 1024_u64 * 1024_u64; let ttl = Duration::from_secs(config.cache_ttl_hours as u64 * 3600); @@ -253,7 +252,9 @@ impl ImageCacheService { let result = self.fetch_source(&source).await; if let Ok((bytes, content_type)) = &result { - self.metrics.downloads_success.fetch_add(1, Ordering::Relaxed); + self.metrics + .downloads_success + .fetch_add(1, Ordering::Relaxed); if self.config.enabled { self.insert_memory(&key, bytes.clone(), content_type.clone()) .await; @@ -317,7 +318,10 @@ impl ImageCacheService { } async fn insert_memory(&self, key: &str, bytes: Bytes, content_type: String) { - let cached = CachedImage { bytes, content_type }; + let cached = CachedImage { + bytes, + content_type, + }; self.memory_cache.insert(key.to_string(), cached).await; } @@ -339,10 +343,9 @@ impl ImageCacheService { match &source.source { ImageSource::Hub { url } => self.download_hub_image(url).await, ImageSource::LocalFile { .. } => { - let full_path = source - .full_path - .as_ref() - .ok_or_else(|| ImageCacheError::Invalid("local file path missing".to_string()))?; + let full_path = source.full_path.as_ref().ok_or_else(|| { + ImageCacheError::Invalid("local file path missing".to_string()) + })?; read_local_image(full_path).await } } @@ -415,9 +418,8 @@ impl ImageCacheService { tokio::time::sleep(Duration::from_millis(delay_ms)).await; } } - Err(last_err.unwrap_or_else(|| { - ImageCacheError::Network("hub image download failed".to_string()) - })) + Err(last_err + .unwrap_or_else(|| ImageCacheError::Network("hub image download failed".to_string()))) } } @@ -474,11 +476,11 @@ impl DiskCache { access_count INTEGER NOT NULL DEFAULT 1 ); CREATE INDEX IF NOT EXISTS idx_last_accessed ON image_cache_entries(last_accessed); - "# + "#, ) - .execute(&db_pool) - .await - .map_err(ImageCacheError::from)?; + .execute(&db_pool) + .await + .map_err(ImageCacheError::from)?; let cache = Self { base_dir, @@ -499,9 +501,7 @@ impl DiskCache { None => return Ok(None), }; let now = now_ts(); - if self.ttl.as_secs() > 0 - && now.saturating_sub(entry.last_accessed) > self.ttl.as_secs() - { + if self.ttl.as_secs() > 0 && now.saturating_sub(entry.last_accessed) > self.ttl.as_secs() { self.remove_entry(&entry.key, &entry.file_name).await?; return Ok(None); } @@ -553,16 +553,13 @@ impl DiskCache { } let existing = self.lookup_entry_meta(&key).await?; - let effective_needed = size_bytes.saturating_sub( - existing - .as_ref() - .map(|entry| entry.size_bytes) - .unwrap_or(0), - ); + let effective_needed = + size_bytes.saturating_sub(existing.as_ref().map(|entry| entry.size_bytes).unwrap_or(0)); self.ensure_disk_space(effective_needed).await?; let extension = extension_from_content_type(&content_type) .or_else(|| extension_from_source(&source)) + .and_then(safe_cache_extension) .unwrap_or_else(|| "bin".to_string()); let file_name = format!("{}.{}", sha256_hex(&key), extension); let file_path = self.images_dir.join(&file_name); @@ -574,17 +571,17 @@ impl DiskCache { let (source_type, source_url, source_root, source_path) = source_fields(&source); let result = self .insert_entry( - &key, - &file_name, - &content_type, - size_bytes, - now, - source_type, - source_url, - source_root, - source_path, - ) - .await; + &key, + &file_name, + &content_type, + size_bytes, + now, + source_type, + source_url, + source_root, + source_path, + ) + .await; if let Err(err) = result { self.remove_file_best_effort(&file_name).await; return Err(err); @@ -622,12 +619,11 @@ impl DiskCache { } async fn stats(&self) -> Result { - let row = sqlx::query( - "SELECT COUNT(1), COALESCE(SUM(size_bytes), 0) FROM image_cache_entries", - ) - .fetch_one(&self.db_pool) - .await - .map_err(ImageCacheError::from)?; + let row = + sqlx::query("SELECT COUNT(1), COALESCE(SUM(size_bytes), 0) FROM image_cache_entries") + .fetch_one(&self.db_pool) + .await + .map_err(ImageCacheError::from)?; let entries: i64 = row.try_get(0).map_err(ImageCacheError::from)?; let size_bytes: i64 = row.try_get(1).map_err(ImageCacheError::from)?; Ok(DiskCacheStatsSnapshot { @@ -673,12 +669,10 @@ impl DiskCache { } self.cleanup_expired().await?; - let row = sqlx::query( - "SELECT COALESCE(SUM(size_bytes), 0) FROM image_cache_entries", - ) - .fetch_one(&self.db_pool) - .await - .map_err(ImageCacheError::from)?; + let row = sqlx::query("SELECT COALESCE(SUM(size_bytes), 0) FROM image_cache_entries") + .fetch_one(&self.db_pool) + .await + .map_err(ImageCacheError::from)?; let total_size: i64 = row.try_get(0).map_err(ImageCacheError::from)?; let total_size = total_size as u64; @@ -964,26 +958,30 @@ fn extension_from_source(source: &ImageSource) -> Option { } fn extension_from_url(url: &str) -> Option { - url::Url::parse(url) - .ok() - .and_then(|parsed| { - Path::new(parsed.path()) - .extension() - .and_then(|s| s.to_str()) - .map(|s| s.to_lowercase()) - }) + url::Url::parse(url).ok().and_then(|parsed| { + Path::new(parsed.path()) + .extension() + .and_then(|s| s.to_str()) + .map(|s| s.to_lowercase()) + }) } -fn source_fields( - source: &ImageSource, -) -> (String, Option, Option, Option) { +fn safe_cache_extension(extension: String) -> Option { + let cleaned: String = extension + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .take(16) + .collect(); + if cleaned.is_empty() { + None + } else { + Some(cleaned) + } +} + +fn source_fields(source: &ImageSource) -> (String, Option, Option, Option) { match source { - ImageSource::Hub { url } => ( - "hub".to_string(), - Some(url.to_string()), - None, - None, - ), + ImageSource::Hub { url } => ("hub".to_string(), Some(url.to_string()), None, None), ImageSource::LocalFile { root, path } => ( "local".to_string(), None, @@ -993,15 +991,35 @@ fn source_fields( } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn safe_cache_extension_removes_path_invalid_chars() { + assert_eq!( + safe_cache_extension("jpg:bad?".to_string()), + Some("jpgbad".to_string()) + ); + assert_eq!(safe_cache_extension(":?".to_string()), None); + } +} + fn is_disk_full_error(err: &std::io::Error) -> bool { err.raw_os_error() == Some(112) } fn map_io_error(context: &'static str, err: std::io::Error) -> ImageCacheError { if is_disk_full_error(&err) { - ImageCacheError::DiskFull { context, source: err } + ImageCacheError::DiskFull { + context, + source: err, + } } else { - ImageCacheError::Io { context, source: err } + ImageCacheError::Io { + context, + source: err, + } } } @@ -1017,10 +1035,7 @@ fn hub_headers() -> header::HeaderMap { header::ACCEPT_ENCODING, "gzip, deflate, br, zstd".parse().unwrap(), ); - headers.insert( - header::ACCEPT_LANGUAGE, - "en-US,en;q=0.9".parse().unwrap(), - ); + headers.insert(header::ACCEPT_LANGUAGE, "en-US,en;q=0.9".parse().unwrap()); headers.insert(header::COOKIE, "vamhubconsent=yes".parse().unwrap()); headers.insert(header::DNT, "1".parse().unwrap()); headers.insert( diff --git a/varManager_backend/src/util/mod.rs b/varManager_backend/src/util/mod.rs index f54875d..7ff638c 100644 --- a/varManager_backend/src/util/mod.rs +++ b/varManager_backend/src/util/mod.rs @@ -38,4 +38,3 @@ pub fn open_url(url: &str) -> Result<(), String> { .map_err(|err| err.to_string())?; Ok(()) } - diff --git a/varmanager_flutter/pubspec.yaml b/varmanager_flutter/pubspec.yaml index 7181789..4149dc8 100644 --- a/varmanager_flutter/pubspec.yaml +++ b/varmanager_flutter/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2.0.3+0 +version: 2.0.4+0 environment: sdk: ^3.10.4